From fafe1676ffe693b68eb39790f24f2901272687ae Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 30 Sep 2026 14:04:53 -0400 Subject: [PATCH 1/3] feat(ifc-converter): import IFC into the room-first structure model - Join wall ends onto neighbour centrelines (IfcRelConnectsPathElements + geometric fallback), read layer-set justification, keep hosted openings in place. - Slabs keep their real top, thickness and holes; floors that fully cover rooms become room floor plates, others stay manual slabs. - Finish floors and IfcCovering FLOORING become room floor finishes and regions; IfcCovering CEILING becomes room ceilings (holes kept, sloped coverings stay meshes). - IfcSpace zones get seeds, separators split open-plan spaces, doors and windows use the current threshold model, the site covers the model. - Pascal-authored IFC keeps node ids and skips cleanup merges. - Node ids are seeded from the file bytes so an import is reproducible. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L8CFFhDVTArznXMZn48Xu3 --- packages/ifc-converter/README.md | 68 +- packages/ifc-converter/src/cleanup.ts | 24 +- packages/ifc-converter/src/ids.ts | 39 + packages/ifc-converter/src/index.ts | 684 ++++++++--- packages/ifc-converter/src/room-first.ts | 1011 +++++++++++++++++ packages/ifc-converter/src/wall-joins.ts | 392 +++++++ packages/ifc-converter/tests/ifc-builder.ts | 300 +++++ .../tests/imported-mesh-conversion.test.ts | 7 +- packages/ifc-converter/tests/load-scene.ts | 50 + .../tests/room-first-load.test.ts | 376 ++++++ .../tests/room-first-regressions.test.ts | 211 ++++ 11 files changed, 3012 insertions(+), 150 deletions(-) create mode 100644 packages/ifc-converter/src/ids.ts create mode 100644 packages/ifc-converter/src/room-first.ts create mode 100644 packages/ifc-converter/src/wall-joins.ts create mode 100644 packages/ifc-converter/tests/ifc-builder.ts create mode 100644 packages/ifc-converter/tests/load-scene.ts create mode 100644 packages/ifc-converter/tests/room-first-load.test.ts create mode 100644 packages/ifc-converter/tests/room-first-regressions.test.ts diff --git a/packages/ifc-converter/README.md b/packages/ifc-converter/README.md index ea9e508248..46380576e0 100644 --- a/packages/ifc-converter/README.md +++ b/packages/ifc-converter/README.md @@ -17,10 +17,11 @@ Native Pascal nodes are produced when the converter can recover the required parameters for sites, buildings, levels, walls, doors, windows, slabs, columns, and IFC spaces (as room zones). IFC stair flights are retained as exact imported meshes; roofs retain Pascal hierarchy and source metadata but are not yet a -complete parametric conversion. Railings, coverings, furnishings, proxies, -curtain walls, plates, members, -footings, and elements whose native parameters cannot be recovered are retained -as selectable `imported-mesh` nodes using their IFC triangle geometry and color. +complete parametric conversion. Railings, coverings other than ceilings and +flooring, furnishings, proxies, curtain walls, plates, members, footings, +vegetation (`IfcGeographicElement`), and elements whose native parameters +cannot be recovered are retained as selectable `imported-mesh` nodes using +their IFC triangle geometry and color. Imported meshes are import-only and do not appear as empty objects in the editor palette. @@ -28,5 +29,64 @@ Door families are derived from `IfcDoor.OperationType`. Glazing is applied from the standardized `Pset_DoorCommon.GlazingAreaFraction` property rather than from element names or project-specific conventions. +## Room-first structure + +The importer writes intent for Pascal's room-first model; the scene load +migrations (room adoption, floor plates) and the structure kernel turn it into +rooms, floor plates and ceilings. `tests/room-first-load.test.ts` converts the +reference files and runs that load chain. + +- **Walls.** Merged collinear fragments, then every straight wall end moves + along its axis onto the centreline it meets (L and T joins; ends inside + another wall's body reach its centreline, `IfcRelConnectsPathElements` widens + the reach). Walls crossing mid-span split; thin cladding (tiles, skirting) + and walls lining or hidden in another wall stay imported meshes. Openings + keep their plan position. An `IfcMaterialLayerSetUsage` whose reference line + is a wall face becomes `justification` (`a`/`b`) with start/end on that face, + and each junction is rebuilt where the reference lines meet (junctions they + cannot close stay centred); other offsets are moved to the body centreline. +- **Rooms.** `IfcSpace` becomes a room zone (`LongName` → name, `Name` → + room number) with a `seed` at its pole of inaccessibility; it is adopted by + the wall loop it fills. Spaces sharing one wall loop (open plan, or joined + through a gap in the walls) get separators along their shared borders or + across the gap; a space mostly bounded by walls but open on one side (a + curtain-wall facade) is closed along its open edges. Wall loops no space + claims become rooms too (`metadata.ifcDerived: 'wall-loop'`). +- **Floors.** Slab tops are the true walking surface (the extrusion's upper + end, or the mesh when the body is more than one extrusion) and thickness is + the IFC thickness. Structural floors (`FLOOR`/`BASESLAB`/unset) whose top + matches the level's main floor within 2 cm and that floor at least one room + whole (openings aside) become floor-plate templates: the + kernel re-derives the plate from the rooms and keeps the IFC top and + thickness (`referenceFloorElevation`, `foundation`). A part reaching more than + 1 m² / 5 % past the rooms and walls stays a hand-drawn slab (`metadata.ifcSplit: + 'outside-rooms'`). Other slabs stay hand-drawn with their holes, and rooms + standing on them point at them with `floor.sourceSlabId`; rooms with no slab + under them have `hasFloor: false`. Holes in room floors, and the unfloored + part of a room a plate only partly carries, become `floor-opening` nodes. + Levels are marked `floorOwnershipMigrated` so the load migration's legacy + floor guesses leave these decisions alone. +- **Finishes.** Slabs up to 6 cm thick lying on a structural floor inside rooms + (Revit "Finish Floor" types) and `IfcCovering` `FLOORING` become the room's + `floor.finish` when one covers most of the room, and `floor.regions` for the + rest (a library material chosen from the material/type name — wood, tile, + marble, stone, concrete — else the surface colour), plus the `floorFinish` + label. A finish slab is removed only when all of it is represented. The + plate's top is the finish top and its thickness includes the finish. +- **Ceilings.** `IfcCovering` `CEILING` becomes the automatic ceiling of the + room it covers, at the covering's underside, keeping the covering's own + holes (profile voids and `IfcRelVoidsElement` openings). Sloped coverings stay + imported meshes. When a file models ceilings, a + room without one gets `hasCeiling: false`; files without ceiling coverings + keep Pascal's default ceilings except for rooms without a floor. +- **Doors and windows** carry `wallId` and `floorThresholdVersion: 1`. +- **Site.** The site polygon is the `IfcSite` footprint when it holds the + model, else the imported extent plus 5 m. +- **Pascal round trip.** Elements carrying a `Pascal` property set (written by + `@pascal-app/ifc-converter/export`) keep their node id (imported meshes + included) and skip wall cleanup + (merging, joins, duplicate-opening removal); storeys read the `GrossHeight` + quantity for the top storey's height. + Browser callers use the default WebIFC WASM path (`/`). Node callers can pass `{ wasmPath: '/absolute/path/to/web-ifc/' }` in `ConversionOptions`. diff --git a/packages/ifc-converter/src/cleanup.ts b/packages/ifc-converter/src/cleanup.ts index 60a2f07b70..b000fcdc2c 100644 --- a/packages/ifc-converter/src/cleanup.ts +++ b/packages/ifc-converter/src/cleanup.ts @@ -6,6 +6,7 @@ import { type WallNode, type WindowNode, } from '@pascal-app/core' +import { joinWallEnds, splitCrossingWalls } from './wall-joins' type SceneNodes = Record type OpeningNode = DoorNode | WindowNode @@ -30,6 +31,8 @@ export type IfcConversionSimplificationStats = { mergedWallGroups: number removedMergedWalls: number removedDuplicateOpenings: number + joinedWallEnds: number + splitCrossingWalls: number } type WallSegment = { @@ -75,6 +78,8 @@ function getInitialStats(nodes: SceneNodes): IfcConversionSimplificationStats { mergedWallGroups: 0, removedMergedWalls: 0, removedDuplicateOpenings: 0, + joinedWallEnds: 0, + splitCrossingWalls: 0, } } @@ -86,6 +91,13 @@ function finishStats(nodes: SceneNodes, stats: IfcConversionSimplificationStats) } } +/** Walls and openings a Pascal export wrote are kept exactly as they were authored. */ +export function isPascalAuthored(node: AnyNode | undefined) { + return ( + typeof (node?.metadata as { pascalNodeId?: unknown } | undefined)?.pascalNodeId === 'string' + ) +} + function isOpeningNode(node: AnyNode | undefined): node is OpeningNode { return node?.type === 'door' || node?.type === 'window' } @@ -147,7 +159,7 @@ function wallLength(wall: WallNode) { function pruneTinyWalls(nodes: SceneNodes, stats: IfcConversionSimplificationStats) { for (const node of Object.values(nodes)) { - if (node.type !== 'wall') continue + if (node.type !== 'wall' || isPascalAuthored(node)) continue if (wallLength(node) >= MIN_WALL_LENGTH) continue if (node.children.length > 0) continue delete nodes[node.id] @@ -430,7 +442,7 @@ function mergeWallFragments( options: Required, ) { const segments = Object.values(nodes) - .filter((node): node is WallNode => node.type === 'wall') + .filter((node): node is WallNode => node.type === 'wall' && !isPascalAuthored(node)) .map(toWallSegment) .filter((segment): segment is WallSegment => segment !== null) @@ -493,7 +505,7 @@ function openingSignature(opening: OpeningNode) { function dedupeOpenings(nodes: SceneNodes, stats: IfcConversionSimplificationStats) { const byWall = new Map() for (const node of Object.values(nodes)) { - if (!isOpeningNode(node)) continue + if (!isOpeningNode(node) || isPascalAuthored(node)) continue const wallId = getOpeningWallId(node, nodes) if (!wallId) continue const openings = byWall.get(wallId) @@ -519,6 +531,10 @@ function dedupeOpenings(nodes: SceneNodes, stats: IfcConversionSimplificationSta export function simplifyConvertedSceneGraph( nodes: SceneNodes, options: IfcConversionSimplificationOptions = {}, + context: { + wallConnections?: readonly (readonly [number, number])[] + wallLinings?: ReadonlyMap + } = {}, ): IfcConversionSimplificationStats { const stats = getInitialStats(nodes) @@ -535,6 +551,8 @@ export function simplifyConvertedSceneGraph( pruneTinyWalls(nodes, stats) mergeWallFragments(nodes, stats, resolvedOptions) syncWallOpeningChildren(nodes) + stats.joinedWallEnds = joinWallEnds(nodes, context.wallConnections, context.wallLinings) + stats.splitCrossingWalls = splitCrossingWalls(nodes) dedupeOpenings(nodes, stats) normalizeChildren(nodes) finishStats(nodes, stats) diff --git a/packages/ifc-converter/src/ids.ts b/packages/ifc-converter/src/ids.ts new file mode 100644 index 0000000000..d8819d2800 --- /dev/null +++ b/packages/ifc-converter/src/ids.ts @@ -0,0 +1,39 @@ +// Node ids are drawn from a sequence seeded by the IFC bytes, not from a random +// source: room detection breaks near-ties by id order, so random ids made the +// same file import differently from run to run. +let seedA = 0 +let seedB = 0 +let counter = 0 + +function fnv1a(bytes: Uint8Array, basis: number): number { + let hash = basis + for (let i = 0; i < bytes.length; i++) { + hash ^= bytes[i]! + hash = Math.imul(hash, 0x01000193) + } + return hash >>> 0 +} + +function mix(value: number): number { + let x = value >>> 0 + x ^= x >>> 16 + x = Math.imul(x, 0x7feb352d) + x ^= x >>> 15 + x = Math.imul(x, 0x846ca68b) + x ^= x >>> 16 + return x >>> 0 +} + +export function seedIds(source: Uint8Array): void { + seedA = fnv1a(source, 0x811c9dc5) + seedB = fnv1a(source, 0x9e3779b9) + counter = 0 +} + +export function nextId(prefix: T): `${T}_${string}` { + counter++ + const high = mix(seedA ^ Math.imul(counter, 0x9e3779b1)) + const low = mix(seedB + Math.imul(counter, 0x85ebca6b)) + const body = `${high.toString(36).padStart(7, '0')}${low.toString(36).padStart(7, '0')}` + return `${prefix}_${body}` as `${T}_${string}` +} diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index b3d747bf67..d5d898833e 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -3,7 +3,9 @@ import { type AnyNodeId, BlockNode, BuildingNode, + CeilingNode, ColumnNode, + containsPoint, DEFAULT_LEVEL_HEIGHT, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, @@ -12,6 +14,7 @@ import { ImportedMeshNode, type ImportedMeshPrimitiveValue, LevelNode, + polygonInteriorPoint, RoofNode, SiteNode, SlabNode, @@ -19,12 +22,14 @@ import { WindowNode, ZoneNode, } from '@pascal-app/core' -import { customAlphabet } from 'nanoid' import * as WebIFC from 'web-ifc' import { extractBeamGeometry } from './beam-geometry' import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup' import { doorGlazingStyle, doorStyleFromIfcOperation } from './door-semantics' +import { nextId, seedIds } from './ids' +import { applyRoomFirstStructure } from './room-first' import { selectStoreyForElevation } from './storey-semantics' +import { redundantWallIds } from './wall-joins' export type { IfcConversionSimplificationOptions, @@ -97,12 +102,6 @@ function tryParse(schema: { parse: (input: unknown) => T }, kind: string, inp } } -const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) - -function generateId(prefix: T): `${T}_${string}` { - return `${prefix}_${nanoid()}` as `${T}_${string}` -} - // --- Unit detection --- function getLengthUnitFactor(ifcApi: WebIFC.IfcAPI, modelID: number): number { @@ -370,6 +369,49 @@ type ExtrusionData = { // 'round' carries `radius`; 'rectangular' carries xDim/yDim. profileShape: 'round' | 'rectangular' | null radius: number | null + // IfcArbitraryProfileDefWithVoids inner rings, same frame as profilePoints. + innerCurves: number[][][] + // ExtrudedDirection in the extrusion Position frame (model axes, unnormalised). + direction: number[] | null +} + +type ExtrudedPlan = { + polygon: [number, number][] | null + holes: [number, number][][] + top: number | null + bottom: number | null + flat: boolean | null +} + +const DEFAULT_SLAB_THICKNESS = 0.05 +/** Deepest covering mesh read as flat when it has no extrusion to check. */ +const MAX_FLAT_COVERING_DEPTH = 0.3 + +function readCurvePoints(ifcApi: WebIFC.IfcAPI, modelID: number, curveRef: number): number[][] { + const curve = ifcApi.GetLine(modelID, curveRef) + const pts: number[][] = [] + if (!curve.Points) return pts + // 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]) + } + } + } + return pts } function extractFromExtrusionItem( @@ -393,6 +435,14 @@ function extractFromExtrusionItem( result.depth = current.Depth.value } + if (current.ExtrudedDirection?.value) { + const direction = ifcApi.GetLine(modelID, current.ExtrudedDirection.value) + const ratios = (direction.DirectionRatios ?? []).map((c: any) => + typeof c === 'number' ? c : (c?.value ?? 0), + ) + if (ratios.length >= 2) result.direction = [ratios[0] ?? 0, ratios[1] ?? 0, ratios[2] ?? 0] + } + if (current.SweptArea?.value) { const profile = ifcApi.GetLine(modelID, current.SweptArea.value) @@ -413,37 +463,13 @@ function extractFromExtrusionItem( // 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 - } + const pts = readCurvePoints(ifcApi, modelID, curveRef) + if (pts.length >= 3) result.profilePoints = pts + } + for (const innerRef of Array.isArray(profile.InnerCurves) ? profile.InnerCurves : []) { + if (!innerRef?.value) continue + const pts = readCurvePoints(ifcApi, modelID, innerRef.value) + if (pts.length >= 3) result.innerCurves.push(pts) } } @@ -458,6 +484,8 @@ function getBodyExtrusionData(ifcApi: WebIFC.IfcAPI, modelID: number, element: a profilePoints: null, profileShape: null, radius: null, + innerCurves: [], + direction: null, } try { if (!element.Representation?.value) return result @@ -869,6 +897,7 @@ export async function convertIfcToPascal( await ifcApi.Init() progress('Opening IFC model...', 10) const modelID = ifcApi.OpenModel(ifcData) + seedIds(ifcData) console.log( `[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`, @@ -984,6 +1013,60 @@ export async function convertIfcToPascal( } } + // Elements a Pascal export wrote carry their node identity in a `Pascal` + // property set; storeys carry their height as the GrossHeight quantity. + const pascalIdentity = new Map() + const grossHeightByExpressId = new Map() + 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 definition = ifcApi.GetLine(modelID, rel.RelatingPropertyDefinition.value) + if (definition.Name?.value === 'Pascal' && definition.HasProperties) { + const values: Record = {} + for (const ref of definition.HasProperties) { + const property = ifcApi.GetLine(modelID, ref.value) + if (property.Name?.value) values[property.Name.value] = property.NominalValue?.value + } + if (typeof values.NodeId !== 'string' || typeof values.NodeType !== 'string') continue + for (const object of rel.RelatedObjects) + pascalIdentity.set(object.value, { id: values.NodeId, type: values.NodeType }) + } + for (const ref of definition.Quantities ?? []) { + const quantity = ifcApi.GetLine(modelID, ref.value) + if (quantity.Name?.value !== 'GrossHeight' || !(quantity.LengthValue?.value > 0)) continue + for (const object of rel.RelatedObjects) + grossHeightByExpressId.set(object.value, quantity.LengthValue.value * unitFactor) + } + } catch { + /* skip rel */ + } + } + } catch { + /* no property rels */ + } + // Re-importing a Pascal export keeps each node's id, so the scene keeps its + // identity (links, selections, history) across the round trip. + const usedPascalIds = new Set() + function nodeIdFor( + expressId: number, + kind: T, + type: string = kind, + ): `${T}_${string}` { + const identity = pascalIdentity.get(expressId) + if ( + identity?.type === type && + identity.id.startsWith(`${kind}_`) && + !usedPascalIds.has(identity.id) + ) { + usedPascalIds.add(identity.id) + return identity.id as `${T}_${string}` + } + return nextId(kind) + } + // Resolve containing storey for an element by walking the parent chain function findStoreyForElement(expressId: number): number | null { let current: number | undefined = expressId @@ -1078,6 +1161,85 @@ export async function convertIfcToPascal( return primitives } + // Level-local vertical extent of the element's triangles. + function meshHeightRange(expressId: number): { min: number; max: number } | null { + const heightAxis = opts.swapYZ ? 1 : 2 + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (const primitive of importedMeshPrimitivesFor(expressId)) { + for (let index = heightAxis; index < primitive.positions.length; index += 3) { + const value = primitive.positions[index]! + if (value < min) min = value + if (value > max) max = value + } + } + return min <= max ? { min, max } : null + } + + // Plan outline, holes and level-local vertical extent of an extruded + // element (slab, covering, opening, site). `flat` is false when the + // profile or extrusion is not horizontal/vertical. + function extrudedPlan(element: any, levelElevation: number): ExtrudedPlan | null { + const worldMat = element.ObjectPlacement?.value + ? resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value) + : identity() + const body = getBodyExtrusionData(ifcApi, modelID, element) + const extrusionMat = getExtrusionPosition(ifcApi, modelID, element) + const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat + const toPlan = (points: number[][]) => { + const ring = points.map((point) => { + const scene = worldToScene(transformPoint3(combinedMat, [point[0]!, point[1]!, 0])) + return [scene[0]!, scene[1]!] as [number, number] + }) + const first = ring[0] + const last = ring.at(-1) + if ( + ring.length > 3 && + first && + last && + Math.abs(first[0] - last[0]) < 1e-6 && + Math.abs(first[1] - last[1]) < 1e-6 + ) + ring.pop() + return ring + } + let polygon: [number, number][] | null = null + if (body.profilePoints && body.profilePoints.length >= 3) { + polygon = toPlan(body.profilePoints) + } else if (body.xDim && body.yDim) { + const hw = body.xDim / 2 + const hh = body.yDim / 2 + polygon = toPlan([ + [-hw, -hh], + [hw, -hh], + [hw, hh], + [-hw, hh], + ]) + } + if (polygon && polygon.length < 3) polygon = null + let top: number | null = null + let bottom: number | null = null + let flat: boolean | null = null + if (body.depth) { + const direction = body.direction ?? [0, 0, 1] + const length = Math.hypot(direction[0]!, direction[1]!, direction[2]!) || 1 + const tip = direction.map((value) => (value / length) * body.depth!) + const z0 = worldToScene(transformPoint3(combinedMat, [0, 0, 0]))[2]! + const z1 = worldToScene(transformPoint3(combinedMat, tip))[2]! + top = Math.max(z0, z1) - levelElevation + bottom = Math.min(z0, z1) - levelElevation + flat = + Math.abs(combinedMat[10]!) > 0.999 && Math.abs(z1 - z0) >= body.depth * unitFactor * 0.999 + } + return { + polygon, + holes: polygon ? body.innerCurves.map(toPlan).filter((ring) => ring.length >= 3) : [], + top, + bottom, + flat, + } + } + progress('Processing sites...', 30) // Process sites const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE) @@ -1086,7 +1248,7 @@ export async function convertIfcToPascal( const siteExpressID = sites.get(i) const site = ifcApi.GetLine(modelID, siteExpressID) - const nodeId = generateId('site') + const nodeId = nodeIdFor(siteExpressID, 'site') expressIdToNodeId.set(siteExpressID, nodeId) rootNodeIds.push(nodeId) @@ -1098,10 +1260,8 @@ export async function convertIfcToPascal( 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. + // Placeholder until the whole model is read: fitSitePolygons below + // replaces it with the site footprint or the imported extent. type: 'polygon', points: [ [-15, -15], @@ -1129,7 +1289,7 @@ export async function convertIfcToPascal( const buildingExpressID = buildings.get(i) const building = ifcApi.GetLine(modelID, buildingExpressID) - const nodeId = generateId('building') + const nodeId = nodeIdFor(buildingExpressID, 'building') expressIdToNodeId.set(buildingExpressID, nodeId) const parentExpressID = parentMap.get(buildingExpressID) @@ -1165,7 +1325,7 @@ export async function convertIfcToPascal( const storeyExpressID = storeys.get(i) const storey = ifcApi.GetLine(modelID, storeyExpressID) - const nodeId = generateId('level') + const nodeId = nodeIdFor(storeyExpressID, 'level') expressIdToNodeId.set(storeyExpressID, nodeId) const parentExpressID = parentMap.get(storeyExpressID) @@ -1231,7 +1391,7 @@ export async function convertIfcToPascal( const height = nextExpressId ? (storeyElevationByExpressId.get(nextExpressId) ?? 0) - (storeyElevationByExpressId.get(expressId) ?? 0) - : DEFAULT_LEVEL_HEIGHT + : (grossHeightByExpressId.get(expressId) ?? DEFAULT_LEVEL_HEIGHT) level.level = index level.height = height > 0.1 ? height : DEFAULT_LEVEL_HEIGHT } @@ -1239,17 +1399,17 @@ export async function convertIfcToPascal( progress('Processing walls...', 60) - // Build void/fill relationship maps for doors and windows - // IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement) - const wallToOpenings = new Map() + // Build void/fill relationship maps for doors, windows and slab holes + // IFCRELVOIDSELEMENT: wall/slab (RelatingBuildingElement) → opening (RelatedOpeningElement) + const hostToOpenings = new Map() 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 hostId = rel.RelatingBuildingElement?.value const openingId = rel.RelatedOpeningElement?.value - if (wallId && openingId) { - if (!wallToOpenings.has(wallId)) wallToOpenings.set(wallId, []) - wallToOpenings.get(wallId)!.push(openingId) + if (hostId && openingId) { + if (!hostToOpenings.has(hostId)) hostToOpenings.set(hostId, []) + hostToOpenings.get(hostId)!.push(openingId) } } @@ -1277,6 +1437,62 @@ export async function convertIfcToPascal( for (let i = 0; i < ids.size(); i++) windowExpressIds.add(ids.get(i)) } + // IfcMaterialLayerSetUsage places the wall body relative to its reference + // line: across [low, high] on the wall's local Y (model units). + const layerUsageByElement = new Map() + try { + const relMaterials = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL) + for (let i = 0; i < relMaterials.size(); i++) { + try { + const rel = ifcApi.GetLine(modelID, relMaterials.get(i)) + if (!rel.RelatingMaterial?.value || !rel.RelatedObjects) continue + const usage = ifcApi.GetLine(modelID, rel.RelatingMaterial.value) + if (usage.type !== WebIFC.IFCMATERIALLAYERSETUSAGE || !usage.ForLayerSet?.value) continue + if (String(usage.LayerSetDirection?.value ?? 'AXIS2').toUpperCase() !== 'AXIS2') continue + const layerSet = ifcApi.GetLine(modelID, usage.ForLayerSet.value) + let total = 0 + for (const layerRef of layerSet.MaterialLayers ?? []) { + total += Number(ifcApi.GetLine(modelID, layerRef.value).LayerThickness?.value ?? 0) + } + const offset = Number(usage.OffsetFromReferenceLine?.value ?? 0) + if (!(total > 0) || !Number.isFinite(offset)) continue + const negative = String(usage.DirectionSense?.value ?? '').toUpperCase() === 'NEGATIVE' + const extent = negative + ? { low: offset - total, high: offset } + : { low: offset, high: offset + total } + for (const object of rel.RelatedObjects) layerUsageByElement.set(object.value, extent) + } catch { + /* skip rel */ + } + } + } catch { + /* no material rels */ + } + // Wall node id → plan unit vector from the IFC reference line into the body. + const wallBodySides = new Map() + + // IfcRelConnectsPathElements: walls joined at an end (ATSTART/ATEND). + const wallConnections: [number, number][] = [] + try { + const relPaths = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONNECTSPATHELEMENTS) + for (let i = 0; i < relPaths.size(); i++) { + try { + const rel = ifcApi.GetLine(modelID, relPaths.get(i)) + const ends = [rel.RelatingConnectionType?.value, rel.RelatedConnectionType?.value].map( + (value) => String(value ?? '').toUpperCase(), + ) + if (!ends.some((value) => value === 'ATSTART' || value === 'ATEND')) continue + const a = rel.RelatingElement?.value + const b = rel.RelatedElement?.value + if (a && b) wallConnections.push([a, b]) + } catch { + /* skip rel */ + } + } + } catch { + /* no connection rels */ + } + // Process walls (both IFCWALL and IFCWALLSTANDARDCASE) const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE] for (const wallType of wallTypes) { @@ -1287,7 +1503,7 @@ export async function convertIfcToPascal( const wall = ifcApi.GetLine(modelID, wallExpressID) - const nodeId = generateId('wall') + const nodeId = nodeIdFor(wallExpressID, 'wall') expressIdToNodeId.set(wallExpressID, nodeId) const parentNodeId = resolveElementParent(wallExpressID) @@ -1296,6 +1512,7 @@ export async function convertIfcToPascal( let end: [number, number] | null = null let thickness: number | undefined let height: number | undefined + let onReferenceLine = false try { // Resolve world placement @@ -1307,6 +1524,7 @@ export async function convertIfcToPascal( const axisPts = getAxisPolyline(ifcApi, modelID, wall) if (axisPts && axisPts.length >= 2) { + onReferenceLine = true const s0 = worldToScene(transformPoint3(worldMat, axisPts[0])) const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1])) start = [s0[0], s0[1]] @@ -1410,6 +1628,28 @@ export async function convertIfcToPascal( if (height === undefined) height = DEFAULT_WALL_HEIGHT if (thickness === undefined) thickness = DEFAULT_WALL_THICKNESS + // Every later pass (merging, corner joins, room faces) works on body + // centrelines; a face reference line is restored at the end as + // `justification` (see applyWallReferenceLines). + const layers = layerUsageByElement.get(wallExpressID) + if (layers && onReferenceLine && wall.ObjectPlacement?.value) { + const worldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value) + const sideX = worldMat[1]! + const sideY = planDepthSign * worldMat[5]! + const sideLength = Math.hypot(sideX, sideY) + const low = layers.low * unitFactor + const high = layers.high * unitFactor + const centre = (low + high) / 2 + if (sideLength > 1e-6 && Math.abs(centre) > Math.max(0.001, thickness * 0.02)) { + const side: [number, number] = [sideX / sideLength, sideY / sideLength] + start = [start[0] + side[0] * centre, start[1] + side[1] * centre] + end = [end[0] + side[0] * centre, end[1] + side[1] * centre] + const faceTolerance = Math.max(0.002, thickness * 0.02) + if (Math.abs(low) <= faceTolerance) wallBodySides.set(nodeId, side) + else if (Math.abs(high) <= faceTolerance) wallBodySides.set(nodeId, [-side[0], -side[1]]) + } + } + const wallNode = tryParse(WallNode, 'wall', { object: 'node', id: nodeId, @@ -1437,8 +1677,30 @@ export async function convertIfcToPascal( } } + // Cladding, linings and embedded walls fall through to the imported-mesh + // pass. Ends that met a removed lining still reach its host wall's body. + const wallLinings = new Map() + for (const [wallNodeId, host] of redundantWallIds( + Object.values(nodes).filter((node): node is WallNode => node.type === 'wall'), + )) { + const expressId = meta(nodes[wallNodeId]).expressID + if (expressId === undefined || hostToOpenings.has(expressId) || pascalIdentity.has(expressId)) + continue + const hostExpressId = meta(host).expressID + const thickness = (nodes[wallNodeId] as WallNode).thickness ?? 0 + if (hostExpressId !== undefined) + wallLinings.set(hostExpressId, Math.max(wallLinings.get(hostExpressId) ?? 0, thickness)) + for (const node of Object.values(nodes) as { children?: string[] }[]) { + if (node.children?.includes(wallNodeId)) + node.children = node.children.filter((id) => id !== wallNodeId) + } + if (rootNodeIds.includes(wallNodeId)) rootNodeIds.splice(rootNodeIds.indexOf(wallNodeId), 1) + delete nodes[wallNodeId] + expressIdToNodeId.delete(expressId) + } + // Process doors and windows via void/fill relationships - for (const [wallExpressID, openingIds] of wallToOpenings) { + for (const [wallExpressID, openingIds] of hostToOpenings) { const wallNodeId = expressIdToNodeId.get(wallExpressID) if (!wallNodeId) continue const wallNode = nodes[wallNodeId] as WallNode @@ -1533,7 +1795,7 @@ export async function convertIfcToPascal( } if (isDoor) { - const nodeId = generateId('door') + const nodeId = nodeIdFor(fillId, 'door') // Vertical centering is now handled: door center Y = height/2 so the // opening sits at the correct position. Remaining caveat: door bottom @@ -1550,6 +1812,8 @@ export async function convertIfcToPascal( width: width ?? 0.9, height: height ?? 2.1, position: doorPosition, + wallId: wallNodeId, + floorThresholdVersion: 1, ...doorStyleFromIfcOperation(element.OperationType?.value), metadata: buildMetadata({ ifcType: 'IFCDOOR', @@ -1564,12 +1828,10 @@ export async function convertIfcToPascal( expressIdToNodeId.set(fillId, nodeId) wallNode.children.push(nodeId) } else { - const nodeId = generateId('window') + const nodeId = nodeIdFor(fillId, 'window') - // 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). + // WindowNode has no sill field: the sill sets the centre height in + // position[1]; the IFC value is kept in metadata as well. const windowPosition: [number, number, number] = [ position ?? 0, (sillHeight ?? 0) + (height ?? 1.2) / 2, @@ -1585,6 +1847,8 @@ export async function convertIfcToPascal( width: width ?? 1.0, height: height ?? 1.2, position: windowPosition, + wallId: wallNodeId, + floorThresholdVersion: 1, metadata: buildMetadata({ ifcType: 'IFCWINDOW', expressID: fillId, @@ -1727,7 +1991,7 @@ export async function convertIfcToPascal( if (isDoor) { const h = height ?? 2.1 - const nodeId = generateId('door') + const nodeId = nodeIdFor(fillId, 'door') const doorNode = tryParse(DoorNode, 'door', { object: 'node', id: nodeId, @@ -1739,6 +2003,7 @@ export async function convertIfcToPascal( height: h, position: [hosted.along, h / 2, 0], wallId: hosted.info.nodeId, + floorThresholdVersion: 1, ...doorStyleFromIfcOperation(element.OperationType?.value), metadata: buildMetadata({ ifcType: 'IFCDOOR', @@ -1755,7 +2020,7 @@ export async function convertIfcToPascal( } else { const h = height ?? 1.2 const sill = scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0 - const nodeId = generateId('window') + const nodeId = nodeIdFor(fillId, 'window') const windowNode = tryParse(WindowNode, 'window', { object: 'node', id: nodeId, @@ -1767,6 +2032,7 @@ export async function convertIfcToPascal( height: h, position: [hosted.along, sill + h / 2, 0], wallId: hosted.info.nodeId, + floorThresholdVersion: 1, metadata: buildMetadata({ ifcType: 'IFCWINDOW', expressID: fillId, @@ -1804,76 +2070,46 @@ export async function convertIfcToPascal( continue } - const nodeId = generateId('slab') - expressIdToNodeId.set(slabExpressID, nodeId) - - const parentNodeId = resolveElementParent(slabExpressID) - - let polygon: [number, number][] | null = null - let elevation = 0 - let thickness: number | undefined - + const levelElevation = elementLevelElevation(slabExpressID) + let plan: ExtrudedPlan | null = null 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] - elementLevelElevation(slabExpressID) - - // 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] - }) - } + plan = extrudedPlan(slab, levelElevation) } catch { - // keep defaults - } - - // Skip slabs where we couldn't extract a polygon - if (!polygon || polygon.length < 3) { - expressIdToNodeId.delete(slabExpressID) - continue + // mesh fallback below } + // A sloped or tilted slab is not a Pascal floor; keep its exact mesh. + if (plan?.flat === false && importedMeshPrimitivesFor(slabExpressID).length > 0) continue + if (!plan?.polygon) continue + + // Revit extrudes floors down from their top, other tools up from their + // bottom: the top is wherever the extrusion ends highest. The mesh wins + // when the body is more than that one extrusion (extra items, clipping). + const meshRange = meshHeightRange(slabExpressID) + const meshAgrees = + !meshRange || + (plan.top !== null && + Math.abs(meshRange.max - plan.top) <= 0.01 && + Math.abs(meshRange.min - (plan.bottom ?? plan.top)) <= 0.01) + const top = (meshAgrees ? plan.top : meshRange?.max) ?? 0 + const bottom = meshAgrees ? (plan.bottom ?? undefined) : meshRange?.min + const thickness = + bottom !== undefined && top - bottom >= 0.005 ? top - bottom : DEFAULT_SLAB_THICKNESS + + const holes = [ + ...plan.holes, + ...(hostToOpenings.get(slabExpressID) ?? []).flatMap((openingId) => { + try { + const opening = extrudedPlan(ifcApi.GetLine(modelID, openingId), levelElevation) + return opening?.polygon ? [opening.polygon] : [] + } catch { + return [] + } + }), + ] + const parentNodeId = resolveElementParent(slabExpressID) + const nodeId = nodeIdFor(slabExpressID, 'slab') + expressIdToNodeId.set(slabExpressID, nodeId) const slabNode = tryParse(SlabNode, 'slab', { object: 'node', id: nodeId, @@ -1881,17 +2117,17 @@ export async function convertIfcToPascal( 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. + polygon: plan.polygon, + holes, + holeMetadata: holes.map(() => ({ source: 'manual' })), + elevation: top, + thickness, metadata: buildMetadata({ ifcType: 'IFCSLAB', expressID: slabExpressID, globalId: slab.GlobalId?.value, predefinedType: slab.PredefinedType?.value, - thickness, + sourceColor: importedMeshPrimitivesFor(slabExpressID)[0]?.color, }), }) @@ -1900,6 +2136,94 @@ export async function convertIfcToPascal( attachNodeToGraph(nodeId, parentNodeId) } + // Ceiling and flooring coverings become Pascal ceilings and floor finishes + // (see room-first.ts). Everything else stays an exact imported mesh. + const coveringIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCCOVERING) + for (let i = 0; i < coveringIds.size(); i++) { + const coveringExpressID = coveringIds.get(i) + try { + const covering = ifcApi.GetLine(modelID, coveringExpressID) + const role = String(covering.PredefinedType?.value ?? '').toUpperCase() + if (role !== 'CEILING' && role !== 'FLOORING') continue + const levelElevation = elementLevelElevation(coveringExpressID) + const range = meshHeightRange(coveringExpressID) + if (!range) continue + let plan: ExtrudedPlan | null = null + try { + plan = extrudedPlan(covering, levelElevation) + } catch { + // hull fallback below + } + // A sloped covering is no Pascal ceiling or floor: it keeps its exact mesh. + if (plan?.flat === false) continue + let polygon = plan?.polygon ?? null + if (!polygon) { + // Without an extrusion to read, only a thin mesh is known to be flat. + if (range.max - range.min > MAX_FLAT_COVERING_DEPTH) continue + polygon = meshFootprint(importedMeshPrimitivesFor(coveringExpressID), opts.swapYZ) + } + if (!polygon || polygon.length < 3) continue + const holes = [ + ...(plan?.polygon ? plan.holes : []), + ...(hostToOpenings.get(coveringExpressID) ?? []).flatMap((openingId) => { + try { + const opening = extrudedPlan(ifcApi.GetLine(modelID, openingId), levelElevation) + return opening?.polygon ? [opening.polygon] : [] + } catch { + return [] + } + }), + ] + const holeMetadata = holes.map(() => ({ source: 'manual' as const })) + const parentNodeId = resolveElementParent(coveringExpressID) + if (!parentNodeId) continue + const color = importedMeshPrimitivesFor(coveringExpressID)[0]?.color + const metadata = buildMetadata({ + ifcType: 'IFCCOVERING', + expressID: coveringExpressID, + globalId: covering.GlobalId?.value, + predefinedType: role, + objectType: covering.ObjectType?.value, + sourceColor: color, + }) + const name = covering.Name?.value || (role === 'CEILING' ? 'Ceiling' : 'Flooring') + const node = + role === 'CEILING' + ? tryParse(CeilingNode, 'ceiling', { + object: 'node', + id: nodeIdFor(coveringExpressID, 'ceiling'), + type: 'ceiling', + name, + parentId: parentNodeId, + visible: true, + polygon, + holes, + holeMetadata, + height: range.min, + metadata, + }) + : tryParse(SlabNode, 'slab', { + object: 'node', + id: nodeIdFor(coveringExpressID, 'slab'), + type: 'slab', + name, + parentId: parentNodeId, + visible: true, + polygon, + holes, + holeMetadata, + elevation: range.max, + thickness: Math.max(0.005, range.max - range.min), + metadata, + }) + expressIdToNodeId.set(coveringExpressID, node.id) + nodes[node.id] = node + attachNodeToGraph(node.id, parentNodeId) + } catch { + // keep it for the imported-mesh fallback + } + } + // Process roofs const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF) for (let i = 0; i < roofs.size(); i++) { @@ -1907,7 +2231,7 @@ export async function convertIfcToPascal( if (expressIdToNodeId.has(roofExpressID)) continue const roof = ifcApi.GetLine(modelID, roofExpressID) - const nodeId = generateId('roof') + const nodeId = nodeIdFor(roofExpressID, 'roof') expressIdToNodeId.set(roofExpressID, nodeId) const parentNodeId = resolveElementParent(roofExpressID) @@ -2006,7 +2330,7 @@ export async function convertIfcToPascal( if (expressIdToNodeId.has(colExpressID)) continue const col = ifcApi.GetLine(modelID, colExpressID) - const nodeId = generateId('column') + const nodeId = nodeIdFor(colExpressID, 'column') expressIdToNodeId.set(colExpressID, nodeId) const parentNodeId = resolveElementParent(colExpressID) @@ -2149,7 +2473,7 @@ export async function convertIfcToPascal( const roomNumberCandidate = longName && spaceName !== longName ? (spaceName ?? '').trim() : '' const roomNumber = roomNumberCandidate.length <= 32 ? roomNumberCandidate : '' - const nodeId = generateId('zone') + const nodeId = nodeIdFor(spaceExpressId, 'zone') const zone = tryParse(ZoneNode, 'zone', { object: 'node', id: nodeId, @@ -2158,6 +2482,8 @@ export async function convertIfcToPascal( parentId: parentNodeId, visible: true, polygon, + // The room keeps this identity point through adoption and edits. + seed: polygonInteriorPoint({ polygon }), spaceRole: 'room', roomNumber, ceilingHeight: Math.max(0.1, ceilingHeight), @@ -2206,6 +2532,7 @@ export async function convertIfcToPascal( if (!geometry) throw new Error('No renderable beam geometry') const beamNode = tryParse(BlockNode, 'beam', { + id: nodeIdFor(beamExpressID, 'block'), name: beam.Name?.value || `Beam ${i + 1}`, parentId: parentNodeId ?? null, ...geometry, @@ -2250,6 +2577,7 @@ export async function convertIfcToPascal( addFallbackType(WebIFC.IFCPLATE, 'IFCPLATE') addFallbackType(WebIFC.IFCMEMBER, 'IFCMEMBER') addFallbackType(WebIFC.IFCFOOTING, 'IFCFOOTING') + addFallbackType(WebIFC.IFCGEOGRAPHICELEMENT, 'IFCGEOGRAPHICELEMENT') addFallbackType(WebIFC.IFCSTAIRFLIGHT, 'IFCSTAIRFLIGHT') addFallbackType(WebIFC.IFCSPACE, 'IFCSPACE') addFallbackType(WebIFC.IFCWALL, 'IFCWALL') @@ -2276,7 +2604,7 @@ export async function convertIfcToPascal( const primitives = importedMeshPrimitivesFor(expressId) if (primitives.length === 0) continue - const nodeId = generateId('imesh') + const nodeId = nodeIdFor(expressId, 'imesh', 'imported-mesh') const importedMesh = tryParse(ImportedMeshNode, 'imported mesh', { object: 'node', id: nodeId, @@ -2312,6 +2640,13 @@ export async function convertIfcToPascal( } } + // Pascal-authored nodes are already Pascal geometry: cleanup leaves them be. + for (const node of Object.values(nodes)) { + const m = meta(node) + const identity = m.expressID === undefined ? undefined : pascalIdentity.get(m.expressID) + if (identity) m.pascalNodeId = identity.id + } + // Post-process: extract property sets and materials const elementExpressIds = new Set() const expressIdToNode = new Map() @@ -2473,16 +2808,83 @@ export async function convertIfcToPascal( /* no type rels */ } + // The site's property line: the IfcSite footprint when it holds the whole + // model, else the imported extent plus a margin. + function fitSitePolygons() { + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + const include = (x: number, z: number, pad = 0) => { + minX = Math.min(minX, x - pad) + minZ = Math.min(minZ, z - pad) + maxX = Math.max(maxX, x + pad) + maxZ = Math.max(maxZ, z + pad) + } + const secondPlanAxis = opts.swapYZ ? 2 : 1 + for (const node of Object.values(nodes)) { + if (node.type === 'wall') { + for (const point of [node.start, node.end]) include(point[0], point[1], node.thickness ?? 0) + } else if (node.type === 'slab' || node.type === 'zone' || node.type === 'ceiling') { + for (const point of node.polygon) include(point[0], point[1]) + } else if (node.type === 'column') { + include(node.position[0], node.position[2], Math.max(node.width ?? 0, node.depth ?? 0)) + } else if (node.type === 'imported-mesh') { + for (const primitive of node.primitives) + for (let index = 0; index + 2 < primitive.positions.length; index += 3) + include(primitive.positions[index]!, primitive.positions[index + secondPlanAxis]!) + } + } + if (!(minX <= maxX)) return + const SITE_MARGIN = 5 + const extent: [number, number][] = [ + [minX - SITE_MARGIN, minZ - SITE_MARGIN], + [maxX + SITE_MARGIN, minZ - SITE_MARGIN], + [maxX + SITE_MARGIN, maxZ + SITE_MARGIN], + [minX - SITE_MARGIN, maxZ + SITE_MARGIN], + ] + for (const node of Object.values(nodes)) { + if (node.type !== 'site') continue + let footprint: [number, number][] | null = null + try { + const site = ifcApi.GetLine(modelID, Number(meta(node).expressID)) + footprint = extrudedPlan(site, 0)?.polygon ?? null + } catch { + footprint = null + } + const holds = + footprint !== null && + [ + [minX, minZ], + [maxX, minZ], + [maxX, maxZ], + [minX, maxZ], + ].every((point) => + containsPoint([{ outer: footprint!, holes: [] }], point as [number, number]), + ) + node.polygon = { type: 'polygon', points: holds ? footprint! : extent } + } + } + progress('Simplifying converted scene...', 94) - const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions) + const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions, { + wallConnections, + wallLinings, + }) if ( simplificationStats.removedTinyWalls > 0 || simplificationStats.removedMergedWalls > 0 || - simplificationStats.removedDuplicateOpenings > 0 + simplificationStats.removedDuplicateOpenings > 0 || + simplificationStats.joinedWallEnds > 0 ) { console.log('[IFC→Pascal] Simplification:', simplificationStats) } + const roomFirstStats = applyRoomFirstStructure(nodes, wallBodySides) + console.log('[IFC→Pascal] Room-first structure:', roomFirstStats) + + fitSitePolygons() + ifcApi.CloseModel(modelID) progress('Building scene graph...', 95) diff --git a/packages/ifc-converter/src/room-first.ts b/packages/ifc-converter/src/room-first.ts new file mode 100644 index 0000000000..9d2c38d9b7 --- /dev/null +++ b/packages/ifc-converter/src/room-first.ts @@ -0,0 +1,1011 @@ +import { + type AnyNode, + area, + type CeilingNode, + calculateLevelMiters, + difference, + FloorOpeningNode, + getWallPlanFootprint, + intersection, + type Polygon, + type Ring, + SeparatorNode, + type SlabNode, + union, + type WallNode, + type ZoneNode, +} from '@pascal-app/core' +import { migrateRoomZones } from '@pascal-app/core/scene-migrations' +import { nextId } from './ids' + +/** + * Maps IFC floors, finishes, ceilings and open-plan spaces onto Pascal's + * room-first structure. The importer only writes intent — room zones with + * seeds, separators, floor-plate templates, floor/ceiling links — and the + * scene load migrations (M3 room adoption, M4 plates) plus the structure + * kernel derive the actual rooms, plates and ceilings from it. Room faces are + * previewed with the same M3 migration the loader runs, so decisions here are + * made on the rooms the scene will actually have. + */ + +type SceneNodes = Record +type Point = [number, number] + +export type RoomFirstStats = { + separators: number + floorPlates: number + manualFloors: number + roomFinishes: number + linkedCeilings: number + floorOpenings: number +} + +/** Thickest slab still read as a finish layer on a structural floor. */ +const MAX_FINISH_THICKNESS = 0.06 +/** Largest top-height difference between a floor and the level's main floor. */ +const FLOOR_TOP_TOLERANCE = 0.02 +/** Floor area allowed past the rooms and walls before a floor is not a room floor. */ +const MAX_OVERHANG_AREA = 1 +const MAX_OVERHANG_SHARE = 0.05 +/** Clear room floor a floor may leave uncovered and still be the room's floor. */ +const MAX_UNCOVERED_FLOOR = 0.25 +const MAX_UNCOVERED_SHARE = 0.03 +/** Widest gap between two spaces' edges bridged by one separator (a missing partition). */ +const MAX_SEPARATOR_GAP = 0.35 +const MAX_SEPARATOR_REACH = 0.6 +const PARALLEL_SINE = Math.sin((3 * Math.PI) / 180) +/** Distance within which centreline wall ends count as one junction. */ +const JUNCTION_EPSILON = 1e-4 + +const cross = (a: Point, b: Point) => a[0] * b[1] - a[1] * b[0] +const dot = (a: Point, b: Point) => a[0] * b[0] + a[1] * b[1] +const sub = (a: Point, b: Point): Point => [a[0] - b[0], a[1] - b[1]] +const add = (a: Point, b: Point, scale = 1): Point => [a[0] + b[0] * scale, a[1] + b[1] * scale] + +function footprint(node: { polygon: Ring; holes?: Ring[] }): Polygon { + return { outer: node.polygon, holes: node.holes ?? [] } +} + +function safeArea(run: () => Polygon[]): number { + try { + return area(run()) + } catch { + return 0 + } +} + +function meta(node: AnyNode): Record { + return (node.metadata ?? {}) as Record +} + +const isIfcSpace = (node: AnyNode): node is ZoneNode => + node.type === 'zone' && meta(node).ifcType === 'IFCSPACE' + +function attach(nodes: SceneNodes, node: AnyNode) { + nodes[node.id] = node + const parent = node.parentId ? nodes[node.parentId] : undefined + if (parent && 'children' in parent && Array.isArray(parent.children)) + (parent.children as string[]).push(node.id) +} + +function detach(nodes: SceneNodes, id: string) { + const node = nodes[id] + const parent = node?.parentId ? nodes[node.parentId] : undefined + if (parent && 'children' in parent && Array.isArray(parent.children)) + (parent as { children: string[] }).children = parent.children.filter( + (childId: string) => childId !== id, + ) + delete nodes[id] +} + +/** + * Walls are processed on their body centrelines; an IFC reference line on a + * wall face comes back here as Pascal `justification` (a = body left of the + * directed line, b = right), with start/end on that face line. + * + * The wall graph joins walls where their reference endpoints meet, so each + * junction the centrelines made is rebuilt on the reference lines: an L at the + * two lines' intersection, a T where the stem's line meets the host's. A + * junction whose reference lines cannot meet in one point (three or more ends, + * or in-line walls offset differently) keeps its walls centred. + */ +export function applyWallReferenceLines(nodes: SceneNodes, bodySides: ReadonlyMap) { + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node.type === 'wall' && + !(node.curveOffset && Math.abs(node.curveOffset) > 1e-6) && + Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1]) > 1e-6, + ) + const sides = new Map() + for (const [id, side] of bodySides) if (walls.some((wall) => wall.id === id)) sides.set(id, side) + if (!sides.size) return + + const dirOf = (wall: WallNode): Point => { + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + return [(wall.end[0] - wall.start[0]) / length, (wall.end[1] - wall.start[1]) / length] + } + const offsetOf = (wall: WallNode): Point => { + const side = sides.get(wall.id) + const half = (wall.thickness ?? 0.1) / 2 + return side ? [-side[0] * half, -side[1] * half] : [0, 0] + } + type End = { wall: WallNode; atEnd: boolean; point: Point } + type Junction = { ends: End[]; host?: WallNode } + const junctions: Junction[] = [] + for (const wall of walls) + for (const atEnd of [false, true]) { + const point = atEnd ? wall.end : wall.start + const junction = junctions.find( + (candidate) => + candidate.ends[0]!.wall.parentId === wall.parentId && + Math.hypot(...sub(candidate.ends[0]!.point, point)) <= JUNCTION_EPSILON, + ) + if (junction) junction.ends.push({ wall, atEnd, point }) + else junctions.push({ ends: [{ wall, atEnd, point }] }) + } + for (const junction of junctions) { + const point = junction.ends[0]!.point + junction.host = walls.find((wall) => { + if (junction.ends.some((end) => end.wall === wall)) return false + if (wall.parentId !== junction.ends[0]!.wall.parentId) return false + const dir = dirOf(wall) + const offset = sub(point, wall.start) + const along = dot(offset, dir) + const length = Math.hypot(...sub(wall.end, wall.start)) + return ( + Math.abs(cross(offset, dir)) <= JUNCTION_EPSILON && + along > JUNCTION_EPSILON && + along < length - JUNCTION_EPSILON + ) + }) + } + const parallel = (a: WallNode, b: WallNode) => Math.abs(cross(dirOf(a), dirOf(b))) < 1e-6 + for (let changed = true; changed; ) { + changed = false + for (const { ends, host } of junctions) { + if (!ends.some((end) => sides.has(end.wall.id)) && !(host && sides.has(host.id))) continue + const [a, b] = ends + const closable = host + ? ends.every((end) => !parallel(end.wall, host)) + : ends.length === 1 || + (ends.length === 2 && + (!parallel(a!.wall, b!.wall) || + Math.hypot(...sub(offsetOf(a!.wall), offsetOf(b!.wall))) <= JUNCTION_EPSILON)) + if (closable) continue + for (const wall of [...ends.map((end) => end.wall), ...(host ? [host] : [])]) + if (sides.delete(wall.id)) changed = true + } + } + + const lineIntersection = (a: WallNode, b: WallNode): Point => { + const da = dirOf(a) + const db = dirOf(b) + const pa = add(a.start, offsetOf(a)) + const pb = add(b.start, offsetOf(b)) + return add(pa, da, cross(sub(pb, pa), db) / cross(da, db)) + } + const targets = new Map() + for (const { ends, host } of junctions) + for (const end of ends) { + const [other] = ends.filter((candidate) => candidate !== end) + targets.set( + end, + host + ? lineIntersection(end.wall, host) + : ends.length === 2 && other && !parallel(end.wall, other.wall) + ? lineIntersection(end.wall, other.wall) + : add(end.point, offsetOf(end.wall)), + ) + } + for (const { ends } of junctions) + for (const end of ends) { + const target = targets.get(end)! + const { wall } = end + if (!end.atEnd) { + // Openings are placed by their distance from the wall start. + const moved = dot(sub(target, add(wall.start, offsetOf(wall))), dirOf(wall)) + for (const id of wall.children) { + const child = nodes[id] + if (child?.type !== 'door' && child?.type !== 'window') continue + child.position = [child.position[0] - moved, child.position[1], child.position[2]] + } + } + } + for (const { ends } of junctions) + for (const end of ends) { + if (end.atEnd) end.wall.end = targets.get(end)! + else end.wall.start = targets.get(end)! + } + for (const [id, side] of sides) { + const wall = nodes[id] as WallNode + const dir = dirOf(wall) + wall.justification = dot(side, [-dir[1], dir[0]]) > 0 ? 'a' : 'b' + } +} + +type PreviewRoom = { + levelId: string + /** The zone that is this face's room: an adopted IFC space, else a wall-loop room. */ + zoneId?: string + preview: ZoneNode + polygon: Polygon + area: number +} + +/** The rooms the M3 load migration will make of the current walls and zones. */ +function previewRooms(nodes: SceneNodes): PreviewRoom[] { + const { nodes: migrated } = migrateRoomZones(nodes as Record) + return (Object.values(migrated) as AnyNode[]).flatMap((node) => { + if (node.type !== 'zone' || !node.autoFromWalls || !node.parentId) return [] + const polygon = footprint(node) + const room: PreviewRoom = { + levelId: node.parentId, + preview: node, + polygon, + area: safeArea(() => [polygon]), + } + if (nodes[node.id]) room.zoneId = node.id + return [room] + }) +} + +/** + * Wall loops no IFC space claims become rooms here, exactly as M3 would make + * them on load, so their floor and ceiling follow the same rules as the IFC + * rooms (a shaft or foundation cell has no floor the IFC never modelled). + */ +function materializeRooms(nodes: SceneNodes, rooms: PreviewRoom[]) { + for (const room of rooms) { + if (room.zoneId) continue + attach(nodes, { + ...room.preview, + metadata: { ...room.preview.metadata, ifcDerived: 'wall-loop' }, + }) + room.zoneId = room.preview.id + } +} + +type Line = { start: Point; end: Point } + +function wallLines(nodes: SceneNodes, levelId: string): (Line & { thickness: number })[] { + return Object.values(nodes).flatMap((node) => + node.type === 'wall' && + node.parentId === levelId && + !(node.curveOffset && Math.abs(node.curveOffset) > 1e-6) + ? [{ start: node.start, end: node.end, thickness: node.thickness ?? 0.1 }] + : [], + ) +} + +/** + * IFC separates open-plan spaces with virtual boundaries Pascal cannot read. + * Two spaces that fall in one wall face and border each other along an + * edge get a separator on the midline of that border, run on to the walls. + */ +function addSpaceSeparators(nodes: SceneNodes, rooms: PreviewRoom[]): number { + let added = 0 + const spaces = Object.values(nodes).filter(isIfcSpace) + const levelIds = new Set(spaces.flatMap((space) => (space.parentId ? [space.parentId] : []))) + for (const levelId of levelIds) { + const faces = rooms.filter((room) => room.levelId === levelId) + const groups = new Map() + const open: ZoneNode[] = [] + for (const space of spaces) { + if (space.parentId !== levelId || space.polygon.length < 3) continue + const spaceArea = safeArea(() => [footprint(space)]) + let best: { face: PreviewRoom; overlap: number } | undefined + for (const face of faces) { + const overlap = safeArea(() => intersection(footprint(space), face.polygon)) + if (!best || overlap > best.overlap) best = { face, overlap } + } + if (!best || best.overlap < spaceArea * 0.5) open.push(space) + else groups.set(best.face, [...(groups.get(best.face) ?? []), space]) + } + const walls = wallLines(nodes, levelId) + const alongWalls = walls + const borders: Line[] = [] + const members: ZoneNode[] = [] + for (const group of groups.values()) { + if (group.length < 2) continue + members.push(...group) + for (let i = 0; i < group.length; i++) + for (let j = i + 1; j < group.length; j++) + borders.push(...sharedBorders(group[i]!.polygon, group[j]!.polygon)) + } + const lines = mergeCollinear(borders).flatMap((line) => clipAlongWalls(line, walls)) + // A space edge running along neither a wall nor another space crosses a + // gap in the walls (a service void, a wall stopping short of its + // neighbour's face) that would join two spaces into one room. + const openEdges = (space: ZoneNode) => + space.polygon.flatMap((point, index) => + clipAlongWalls( + { start: point, end: space.polygon[(index + 1) % space.polygon.length]! }, + [ + ...alongWalls, + ...lines.map((line) => ({ ...line, thickness: MAX_SEPARATOR_GAP + 0.04 })), + ], + 0.05, + ), + ) + const gaps = mergeCollinear(members.flatMap(openEdges)) + // A space mostly bounded by walls but open on one side (a glazed facade + // exported as a curtain wall) is closed along its own open edges. + const closers = open.flatMap((space) => { + const edges = openEdges(space) + const perimeter = space.polygon.reduce( + (sum, point, index) => + sum + Math.hypot(...sub(space.polygon[(index + 1) % space.polygon.length]!, point)), + 0, + ) + const openLength = edges.reduce( + (sum, edge) => sum + Math.hypot(...sub(edge.end, edge.start)), + 0, + ) + return perimeter > 0 && openLength <= perimeter * 0.7 ? edges : [] + }) + const connected = connectSeparators([...lines, ...gaps, ...closers], walls) + for (const [index, entry] of connected.entries()) { + const { line } = entry + const length = Math.hypot(...sub(line.end, line.start)) + if (length < 0.1 || !entry.attached.every(Boolean)) continue + const gap = index >= lines.length && index < lines.length + gaps.length + if (gap && (length > MAX_SEPARATOR_REACH || !entry.onWall.every(Boolean))) continue + attach( + nodes, + SeparatorNode.parse({ + id: nextId('separator'), + type: 'separator', + name: 'Space boundary', + parentId: levelId, + start: line.start, + end: line.end, + metadata: { ifcDerived: 'space-boundary' }, + }), + ) + added++ + } + } + return added +} + +function sharedBorders(a: Ring, b: Ring): Line[] { + const out: Line[] = [] + for (let i = 0; i < a.length; i++) { + const a0 = a[i]! + const a1 = a[(i + 1) % a.length]! + const lengthA = Math.hypot(...sub(a1, a0)) + if (lengthA < 0.05) continue + const dir: Point = [(a1[0] - a0[0]) / lengthA, (a1[1] - a0[1]) / lengthA] + const normal: Point = [-dir[1], dir[0]] + for (let j = 0; j < b.length; j++) { + const b0 = b[j]! + const b1 = b[(j + 1) % b.length]! + const lengthB = Math.hypot(...sub(b1, b0)) + if (lengthB < 0.05) continue + if ( + Math.abs(cross(dir, [(b1[0] - b0[0]) / lengthB, (b1[1] - b0[1]) / lengthB])) > PARALLEL_SINE + ) + continue + const offset = dot(sub(b0, a0), normal) + if (Math.abs(offset) > MAX_SEPARATOR_GAP) continue + const t0 = dot(sub(b0, a0), dir) + const t1 = dot(sub(b1, a0), dir) + const lo = Math.max(0, Math.min(t0, t1)) + const hi = Math.min(lengthA, Math.max(t0, t1)) + if (hi - lo < 0.05) continue + const base = add(a0, normal, offset / 2) + out.push({ start: add(base, dir, lo), end: add(base, dir, hi) }) + } + } + return out +} + +/** Unions overlapping collinear pieces (the same border found from both spaces). */ +function mergeCollinear(pieces: Line[]): Line[] { + const merged: Line[] = [] + const used = new Set() + for (let i = 0; i < pieces.length; i++) { + if (used.has(i)) continue + const line = pieces[i]! + const length = Math.hypot(...sub(line.end, line.start)) + const dir: Point = [ + (line.end[0] - line.start[0]) / length, + (line.end[1] - line.start[1]) / length, + ] + let lo = 0 + let hi = length + for (let changed = true; changed; ) { + changed = false + for (let j = i + 1; j < pieces.length; j++) { + if (used.has(j)) continue + const other = pieces[j]! + const otherDir = sub(other.end, other.start) + const otherLength = Math.hypot(...otherDir) + if (Math.abs(cross(dir, otherDir)) > PARALLEL_SINE * otherLength) continue + if (Math.abs(cross(sub(other.start, line.start), dir)) > 0.03) continue + const t0 = dot(sub(other.start, line.start), dir) + const t1 = dot(sub(other.end, line.start), dir) + if (Math.min(t0, t1) > hi + 0.05 || Math.max(t0, t1) < lo - 0.05) continue + lo = Math.min(lo, t0, t1) + hi = Math.max(hi, t0, t1) + used.add(j) + changed = true + } + } + merged.push({ start: add(line.start, dir, lo), end: add(line.start, dir, hi) }) + } + return merged +} + +/** Drops the parts of a border that already run inside a wall body. */ +function clipAlongWalls( + line: Line, + walls: (Line & { thickness: number })[], + minLength = 0.1, +): Line[] { + const length = Math.hypot(...sub(line.end, line.start)) + const dir: Point = [ + (line.end[0] - line.start[0]) / length, + (line.end[1] - line.start[1]) / length, + ] + let ranges: [number, number][] = [[0, length]] + for (const wall of walls) { + const wallDir = sub(wall.end, wall.start) + const wallLength = Math.hypot(...wallDir) + if (wallLength < 1e-6 || Math.abs(cross(dir, wallDir)) > PARALLEL_SINE * wallLength) continue + if (Math.abs(cross(sub(wall.start, line.start), dir)) > wall.thickness / 2 + 0.08) continue + const t0 = dot(sub(wall.start, line.start), dir) + const t1 = dot(sub(wall.end, line.start), dir) + const lo = Math.min(t0, t1) + const hi = Math.max(t0, t1) + ranges = ranges.flatMap(([start, end]): [number, number][] => + hi <= start || lo >= end + ? [[start, end]] + : [ + ...(lo > start ? [[start, lo] as [number, number]] : []), + ...(hi < end ? [[hi, end] as [number, number]] : []), + ], + ) + } + return ranges + .filter(([start, end]) => end - start >= minLength) + .map(([start, end]) => ({ start: add(line.start, dir, start), end: add(line.start, dir, end) })) +} + +/** Farthest a separator end snaps onto a boundary end (a wall stub at a doorway). */ +const SEPARATOR_END_SNAP = 0.12 + +type Attached = { line: Line; attached: [boolean, boolean]; onWall: [boolean, boolean] } +type Target = Line & { thickness: number } + +/** + * Moves one separator end onto a target: a target end within the snap + * distance, or the target line it points at (just past a wall's end, within + * its body, lands on that end). The cheapest move wins. The room graph only + * joins a separator whose end lies exactly on a boundary. + */ +function reachTarget( + point: Point, + other: Point, + out: Point, + length: number, + targets: Target[], +): Point | null { + let best: { point: Point; cost: number } | undefined + const consider = (candidate: Point, cost: number) => { + if (Math.hypot(...sub(candidate, other)) < 0.1) return + if (!best || cost < best.cost) best = { point: candidate, cost } + } + for (const target of targets) { + for (const end of [target.start, target.end]) { + const distance = Math.hypot(...sub(end, point)) + if (distance <= SEPARATOR_END_SNAP) consider(end, distance) + } + const targetDir = sub(target.end, target.start) + const targetLength = Math.hypot(...targetDir) + if (targetLength < 1e-6) continue + const denominator = cross(out, targetDir) / targetLength + if (Math.abs(denominator) < 0.25) continue + const toTarget = sub(target.start, point) + const move = cross(toTarget, targetDir) / targetLength / denominator + const along = cross(toTarget, out) / denominator + if (Math.abs(move) > MAX_SEPARATOR_REACH || length + move < 0.1) continue + const past = Math.max(0, -along, along - targetLength) + if (past > target.thickness / 2 + 0.05) continue + const t = Math.max(0, Math.min(1, along / targetLength)) + consider(add(target.start, targetDir, t), Math.abs(move) + past) + } + return best?.point ?? null +} + +function reachTargets(entry: Attached, targets: Target[]): Attached { + const { line } = entry + const length = Math.hypot(...sub(line.end, line.start)) + if (length < 1e-6) return entry + const dir: Point = [ + (line.end[0] - line.start[0]) / length, + (line.end[1] - line.start[1]) / length, + ] + const ends = [line.start, line.end] + const attached: [boolean, boolean] = [...entry.attached] + for (const index of [0, 1] as const) { + if (attached[index]) continue + const out: Point = index === 0 ? [-dir[0], -dir[1]] : dir + const point = reachTarget(ends[index]!, ends[1 - index]!, out, length, targets) + if (!point) continue + ends[index] = point + attached[index] = true + } + return { ...entry, line: { start: ends[0]!, end: ends[1]! }, attached } +} + +/** Walls first; ends still loose then join the other separators as they now lie. */ +function connectSeparators(lines: Line[], walls: Target[]): Attached[] { + const entries = lines.map((line) => { + const entry = reachTargets({ line, attached: [false, false], onWall: [false, false] }, walls) + return { ...entry, onWall: [...entry.attached] as [boolean, boolean] } + }) + for (const [index, entry] of entries.entries()) + entries[index] = reachTargets( + entry, + entries + .filter((_, other) => other !== index) + .map((other) => ({ ...other.line, thickness: 0 })), + ) + return entries +} + +type SlabInfo = { + slab: SlabNode + top: number + bottom: number + area: number + polygon: Polygon + kind: 'structural' | 'finish' | 'other' + support?: SlabInfo +} + +function finishRef(name: string): string | undefined { + const text = name.toLowerCase() + if (/wood|timber|parquet|oak|plank|hardwood|laminate/.test(text)) + return 'library:wood-woodplank48' + if (/marble/.test(text)) return 'library:flooring-statuarettowhite' + if (/tile|ceramic|porcelain/.test(text)) return 'library:flooring-lightceramic24' + if (/terrazzo/.test(text)) return 'library:flooring-terrazzo19' + if (/stone|granite|slate/.test(text)) return 'library:flooring-wallstone1' + if (/concrete|screed|cement/.test(text)) return 'library:concrete-polished' + return undefined +} + +/** A room floor finish from the IFC material or type name, else its surface colour. */ +function floorFinish(slab: SlabNode): string | Record { + const metadata = meta(slab) + const names = [metadata.material, metadata.typeName, metadata.objectType, slab.name] + for (const name of names) { + const ref = typeof name === 'string' ? finishRef(name) : undefined + if (ref) return ref + } + const color = typeof metadata.sourceColor === 'string' ? metadata.sourceColor : '#c8c2b8' + return { preset: 'custom', properties: { color, roughness: 0.7, metalness: 0 } } +} + +function finishLabel(slab: SlabNode): string { + const metadata = meta(slab) + const name = + (typeof metadata.typeName === 'string' && metadata.typeName) || + (typeof metadata.material === 'string' && metadata.material) || + (slab.name ?? '').replace(/:\d+$/, '') + return name.slice(0, 120) +} + +function classifySlabs(slabs: SlabNode[], rooms: PreviewRoom[]): SlabInfo[] { + const roomArea = union(rooms.map((room) => room.polygon)) + const infos: SlabInfo[] = slabs.map((slab) => { + const polygon = footprint(slab) + return { + slab, + top: slab.elevation, + bottom: slab.elevation - slab.thickness, + area: safeArea(() => [polygon]), + polygon, + kind: 'other', + } + }) + for (const info of infos) { + const type = String(meta(info.slab).predefinedType ?? '').toUpperCase() + const covering = meta(info.slab).ifcType === 'IFCCOVERING' + if (!covering && (type === 'ROOF' || type === 'LANDING')) continue + // A flooring covering is a finish or nothing: it never carries a room. + info.kind = covering ? 'other' : 'structural' + if (!covering && info.slab.thickness > MAX_FINISH_THICKNESS + 1e-6) continue + const support = infos + .filter( + (other) => + other !== info && + other.slab.thickness > MAX_FINISH_THICKNESS + 1e-6 && + Math.abs(other.top - info.bottom) <= 0.01, + ) + .map((other) => ({ + other, + overlap: safeArea(() => intersection(info.polygon, other.polygon)), + })) + .sort((a, b) => b.overlap - a.overlap)[0] + if (!support || support.overlap < info.area * 0.8) continue + if (safeArea(() => intersection(info.polygon, roomArea)) < info.area * 0.5) continue + info.kind = 'finish' + info.support = support.other + } + return infos +} + +function insidePart(polygon: Polygon, occupied: Polygon[]): { polygon?: Ring } { + try { + const [largest] = intersection(polygon, occupied).sort((a, b) => area([b]) - area([a])) + return largest ? { polygon: largest.outer } : {} + } catch { + return {} + } +} + +function dominantTop(entries: { top: number; weight: number }[]): number | undefined { + const weights = new Map() + for (const { top, weight } of entries) { + const key = Math.round(top * 100) + weights.set(key, (weights.get(key) ?? 0) + weight) + } + const [best] = [...weights].sort((a, b) => b[1] - a[1] || b[0] - a[0]) + if (!best) return undefined + // The exact top of the heaviest entry in the winning centimetre. + return entries + .filter(({ top }) => Math.round(top * 100) === best[0]) + .sort((a, b) => b.weight - a.weight)[0]!.top +} + +/** + * Level floors: IFC floors that line up with the level's rooms become Pascal + * room floors (base plate templates the kernel re-derives from the rooms, + * carrying the IFC thickness and walking surface); thin finish slabs on them + * become the rooms' floor finish. Everything else stays a hand-drawn slab. + */ +function assignLevelFloors( + nodes: SceneNodes, + levelId: string, + rooms: PreviewRoom[], + lowestLevel: boolean, + stats: RoomFirstStats, +) { + const slabs = Object.values(nodes).filter( + (node): node is SlabNode => + node.type === 'slab' && node.parentId === levelId && !node.plateRole, + ) + const infos = classifySlabs(slabs, rooms) + const structural = infos.filter((info) => info.kind === 'structural') + const finishes = infos.filter((info) => info.kind === 'finish') + const walls = Object.values(nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === levelId, + ) + const miters = calculateLevelMiters(walls) + const wallArea = union( + walls.map( + (wall): Polygon => ({ + outer: getWallPlanFootprint(wall, miters).map(({ x, y }): Point => [x, y]), + holes: [], + }), + ), + ) + const occupied = union([...rooms.map((room) => room.polygon), ...wallArea]) + + type FinishPart = { info: SlabInfo; parts: Polygon[]; share: number } + type RoomFloor = { + room: PreviewRoom + slab: SlabInfo + finishes: FinishPart[] + top: number + /** The room's clear floor lies on floors at this top (openings aside). */ + covered: boolean + clear: Polygon[] + } + const floors: RoomFloor[] = [] + for (const room of rooms) { + if (!(room.area > 1e-6)) continue + // Holes in a floor are openings (they stay cut), not missing floor: a room + // counts on the slab's outline, but must stand on some of its surface. + const [slab] = structural + .map((info) => ({ + info, + cover: safeArea(() => intersection(room.polygon, info.polygon)) / room.area, + outline: + safeArea(() => intersection(room.polygon, { outer: info.polygon.outer, holes: [] })) / + room.area, + })) + .filter((entry) => entry.outline >= 0.5 && entry.cover >= 0.1) + .sort((a, b) => b.cover - a.cover || b.info.top - a.info.top) + if (!slab) continue + let clear: Polygon[] = [room.polygon] + try { + clear = difference(room.polygon, wallArea, { throwOnError: true }) + } catch { + clear = [room.polygon] + } + const clearArea = area(clear) + if (!(clearArea > 1e-6)) continue + const level = union( + structural + .filter((info) => Math.abs(info.top - slab.info.top) <= FLOOR_TOP_TOLERANCE) + .map((info) => ({ outer: info.polygon.outer, holes: [] })), + ) + const uncovered = safeArea(() => difference(clear, level)) + const finishParts = finishes + .filter((info) => info.support === slab.info) + .map((info) => { + const parts = intersection(clear, info.polygon) + return { info, parts, share: area(parts) / clearArea } + }) + .filter((entry) => entry.share >= 0.02) + .sort((a, b) => b.share - a.share) + const dominant = finishParts[0] + floors.push({ + room, + slab: slab.info, + finishes: finishParts, + top: dominant && dominant.share >= 0.5 ? dominant.info.top : slab.info.top, + covered: uncovered <= Math.max(MAX_UNCOVERED_FLOOR, clearArea * MAX_UNCOVERED_SHARE), + clear, + }) + } + const mainTop = dominantTop(floors.map((floor) => ({ top: floor.top, weight: floor.room.area }))) + + const plates = new Set() + for (const info of structural) { + // A slab under part of a room stays hand-drawn rather than grow into a + // plate under all of it; once it carries whole rooms, a room it only + // partly floors joins the plate with its missing floor left open. + const own = floors.filter((floor) => floor.slab === info) + if (!own.some((floor) => floor.covered) || mainTop === undefined) continue + const top = dominantTop(own.map((floor) => ({ top: floor.top, weight: floor.room.area })))! + if (Math.abs(top - mainTop) > FLOOR_TOP_TOLERANCE) continue + plates.add(info) + const slab = nodes[info.slab.id] as SlabNode + const bottom = info.bottom + // Floor reaching well past the rooms (a terrace or plinth around the + // house) stays a hand-drawn slab; the rooms' part becomes their floor. + let outside: Polygon[] = [] + try { + outside = difference(info.polygon, occupied, { throwOnError: true }) + } catch { + outside = [] + } + const split = area(outside) > Math.max(MAX_OVERHANG_AREA, info.area * MAX_OVERHANG_SHARE) + if (split) { + for (const [index, part] of outside.entries()) { + if (area([part]) < MAX_OVERHANG_AREA / 2) continue + attach(nodes, { + ...slab, + id: nextId('slab') as SlabNode['id'], + name: `${slab.name ?? 'Slab'} (outside rooms${index ? ` ${index + 1}` : ''})`, + polygon: part.outer, + holes: part.holes, + holeMetadata: part.holes.map(() => ({ source: 'manual' as const })), + metadata: { ...slab.metadata, ifcSplit: 'outside-rooms' }, + }) + stats.manualFloors++ + } + } + const anyFloor = union(infos.map((other) => ({ outer: other.polygon.outer, holes: [] }))) + for (const floor of own) { + if (floor.covered) continue + for (const part of difference(floor.clear, anyFloor)) { + if (area([part]) <= MAX_UNCOVERED_FLOOR) continue + attach( + nodes, + FloorOpeningNode.parse({ + id: nextId('floor-opening'), + type: 'floor-opening', + name: 'Floor opening', + parentId: levelId, + polygon: part.outer, + source: 'manual', + drawnOn: 'floor', + metadata: { ifcDerived: 'open-floor' }, + }), + ) + stats.floorOpenings++ + } + } + for (const [index, hole] of slab.holes.entries()) { + if (slab.holeMetadata[index]?.source !== 'manual' || hole.length < 3) continue + const holeArea = safeArea(() => [{ outer: hole, holes: [] }]) + if (safeArea(() => intersection({ outer: hole, holes: [] }, occupied)) < holeArea * 0.5) + continue + attach( + nodes, + FloorOpeningNode.parse({ + id: nextId('floor-opening'), + type: 'floor-opening', + name: 'Floor opening', + parentId: levelId, + polygon: hole, + source: 'manual', + drawnOn: 'floor', + metadata: { ifcHostExpressID: meta(slab).expressID }, + }), + ) + stats.floorOpenings++ + } + nodes[slab.id] = { + ...slab, + boundary: 'auto', + plateRole: 'base', + autoFromWalls: true, + zoneIds: own.flatMap((floor) => (floor.room.zoneId ? [floor.room.zoneId] : [])), + elevation: top, + thickness: top - bottom, + referenceFloorElevation: top, + // A ground floor raised off the grade keeps a solid base under it. + foundation: { type: lowestLevel && bottom > 0.05 ? 'solid' : 'none' }, + // The kernel re-derives the outline from the rooms; until then the + // plate covers only its rooms' part, beside the split-off remainder. + ...(split ? insidePart(info.polygon, occupied) : {}), + holes: [], + holeMetadata: [], + } as SlabNode + stats.floorPlates++ + } + + const represented = new Map() + for (const floor of floors) { + const zone = floor.room.zoneId ? nodes[floor.room.zoneId] : undefined + if (zone?.type !== 'zone') continue + if (plates.has(floor.slab)) { + if (!floor.finishes.length) continue + // The finish under most of the room is its floor finish; others (or all + // of them, when none dominates) are painted regions clipped to the room. + const [dominant, ...rest] = floor.finishes + const whole = dominant!.share >= 0.5 ? dominant! : undefined + const regions = (whole ? rest : floor.finishes).flatMap((entry) => + entry.parts.map((part, index) => ({ + id: `${entry.info.slab.id}_${zone.id}${index ? `_${index}` : ''}`, + polygon: part.outer, + finish: floorFinish(entry.info.slab), + })), + ) + zone.floor = { + ...zone.floor, + ...(whole ? { finish: floorFinish(whole.info.slab) } : {}), + ...(regions.length ? { regions: [...(zone.floor?.regions ?? []), ...regions] } : {}), + } + zone.floorFinish = finishLabel((whole ?? dominant!).info.slab) + for (const entry of floor.finishes) + represented.set(entry.info, (represented.get(entry.info) ?? 0) + area(entry.parts)) + stats.roomFinishes++ + continue + } + // The room stands on a hand-drawn slab: the topmost one under most of it. + const [finish] = floor.finishes + zone.floor = { + ...zone.floor, + sourceSlabId: (finish && finish.share >= 0.5 ? finish.info : floor.slab).slab.id, + } + stats.manualFloors++ + } + // A finish every part of which is now room finish no longer needs its slab. + for (const info of finishes) { + if (!info.support || !plates.has(info.support)) continue + const visible = safeArea(() => difference(info.polygon, wallArea)) + if ((represented.get(info) ?? 0) >= visible * 0.97) detach(nodes, info.slab.id) + } + + // Rooms and IFC spaces with no floor under them keep no floor. + const floorArea = union(infos.filter((info) => info.kind !== 'other').map((info) => info.polygon)) + for (const zone of Object.values(nodes)) { + if (zone.type !== 'zone' || zone.parentId !== levelId || zone.floor?.sourceSlabId) continue + if (!isIfcSpace(zone) && meta(zone).ifcDerived !== 'wall-loop') continue + const zoneArea = safeArea(() => [footprint(zone)]) + const covered = safeArea(() => intersection(footprint(zone), floorArea)) + if (zoneArea > 0 && covered < zoneArea * 0.1) zone.hasFloor = false + } +} + +function manualHolesWithin( + ceiling: CeilingNode, + polygon: Polygon, +): Pick { + const holes = ceiling.holes + .filter((_, index) => (ceiling.holeMetadata[index]?.source ?? 'manual') === 'manual') + .flatMap((hole) => { + try { + return intersection({ outer: hole, holes: [] }, polygon).map((part) => part.outer) + } catch { + return [] + } + }) + .filter((hole) => hole.length >= 3) + return { holes, holeMetadata: holes.map(() => ({ source: 'manual' as const })) } +} + +/** + * IFC ceiling coverings become the ceilings of the rooms they cover, at the + * covering's underside. When the file models ceilings, a room without one + * has none; a file without any keeps Pascal's default room ceilings, except + * for rooms the IFC gave no floor either (foundation cells, voids). + */ +function assignCeilings(nodes: SceneNodes, rooms: PreviewRoom[], stats: RoomFirstStats) { + const coverings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling' && meta(node).ifcType === 'IFCCOVERING', + ) + const used = new Set() + for (const room of rooms) { + const zone = room.zoneId ? nodes[room.zoneId] : undefined + if (zone?.type !== 'zone') continue + const [best] = coverings + .filter((ceiling) => ceiling.parentId === room.levelId) + .map((ceiling) => ({ + ceiling, + cover: safeArea(() => intersection(room.polygon, footprint(ceiling))) / room.area, + })) + .filter((entry) => entry.cover >= 0.4) + .sort((a, b) => b.cover - a.cover) + if (!best) { + if (coverings.length || zone.hasFloor === false) zone.hasCeiling = false + continue + } + const linked: CeilingNode = { + ...best.ceiling, + id: used.has(best.ceiling.id) ? (nextId('ceiling') as CeilingNode['id']) : best.ceiling.id, + name: `${zone.name} Ceiling`, + polygon: room.polygon.outer, + // The room redraws the outline; the covering's own cut-outs (hatches, + // voids) stay as manual holes where they fall inside this room. + ...manualHolesWithin(best.ceiling, room.polygon), + boundary: 'auto', + autoFromWalls: true, + zoneId: zone.id, + } + if (used.has(best.ceiling.id)) attach(nodes, linked) + else nodes[linked.id] = linked + used.add(best.ceiling.id) + zone.ceilingFinish = (best.ceiling.name ?? '').replace(/:\d+$/, '').slice(0, 120) + stats.linkedCeilings++ + } +} + +export function applyRoomFirstStructure( + nodes: SceneNodes, + wallBodySides: ReadonlyMap, +): RoomFirstStats { + const stats: RoomFirstStats = { + separators: 0, + floorPlates: 0, + manualFloors: 0, + roomFinishes: 0, + linkedCeilings: 0, + floorOpenings: 0, + } + applyWallReferenceLines(nodes, wallBodySides) + let rooms = previewRooms(nodes) + stats.separators = addSpaceSeparators(nodes, rooms) + if (stats.separators) rooms = previewRooms(nodes) + materializeRooms(nodes, rooms) + + const levels = Object.values(nodes).filter((node) => node.type === 'level') + for (const level of levels) { + const lowest = !levels.some( + (other) => other.parentId === level.parentId && other.level < level.level, + ) + assignLevelFloors( + nodes, + level.id, + rooms.filter((room) => room.levelId === level.id), + lowest, + stats, + ) + // The level's floors are decided: the load migration's guesses for legacy + // hand-drawn floors (adopting pieces, absorbing whole levels) stay off. + nodes[level.id] = { + ...level, + metadata: { ...(level.metadata ?? {}), floorOwnershipMigrated: true }, + } as AnyNode + } + assignCeilings(nodes, rooms, stats) + return stats +} diff --git a/packages/ifc-converter/src/wall-joins.ts b/packages/ifc-converter/src/wall-joins.ts new file mode 100644 index 0000000000..1ad5846f7a --- /dev/null +++ b/packages/ifc-converter/src/wall-joins.ts @@ -0,0 +1,392 @@ +import type { AnyNode, WallNode } from '@pascal-app/core' +import { isPascalAuthored } from './cleanup' +import { nextId } from './ids' + +type SceneNodes = Record +type Point = [number, number] + +/** + * Revit (and most IFC exporters) stop a wall axis at the neighbour's face, not + * its centreline, so Pascal's wall graph sees open corners and rooms never + * close. Each straight wall end moves along its own axis to the nearest + * neighbour centreline it clearly meant to meet (L and T joins). + */ + +/** Largest along-axis move for an end IfcRelConnectsPathElements joins. */ +const MAX_CONNECTED_GAP = 0.6 +/** Slack on top of the neighbour thickness for an unconnected end. */ +const GAP_TOLERANCE = 0.05 +/** Walls closer to parallel than this never join end-to-side. */ +const MIN_JOIN_SINE = Math.sin((15 * Math.PI) / 180) +const MIN_JOINED_LENGTH = 0.1 + +type Segment = { + wall: WallNode + start: Point + end: Point + dir: Point + length: number + thickness: number +} + +const cross = (a: Point, b: Point) => a[0] * b[1] - a[1] * b[0] + +function toSegment(wall: WallNode): Segment | null { + if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return null + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < MIN_JOINED_LENGTH) return null + return { + wall, + start: wall.start, + end: wall.end, + dir: [dx / length, dy / length], + length, + thickness: wall.thickness ?? 0.1, + } +} + +function wallExpressIds(wall: WallNode): number[] { + const metadata = (wall.metadata ?? {}) as { + expressID?: unknown + ifcSimplification?: { mergedExpressIDs?: unknown } + } + const merged = metadata.ifcSimplification?.mergedExpressIDs + return [metadata.expressID, ...(Array.isArray(merged) ? merged : [])].filter( + (id): id is number => typeof id === 'number', + ) +} + +/** Slack around a wall body within which a touching end counts as inside it. */ +const BODY_TOLERANCE = 0.03 + +function insideBody(point: Point, other: Segment, slack = 0) { + const offset = [point[0] - other.start[0], point[1] - other.start[1]] as Point + const along = offset[0] * other.dir[0] + offset[1] * other.dir[1] + return ( + Math.abs(cross(offset, other.dir)) <= other.thickness / 2 + BODY_TOLERANCE + slack && + along >= -BODY_TOLERANCE && + along <= other.length + BODY_TOLERANCE + ) +} + +/** Signed move of `segment`'s end along its outward axis onto `other`'s centreline. */ +function joinMove(segment: Segment, atEnd: boolean, other: Segment, reach: number) { + const point = atEnd ? segment.end : segment.start + const out: Point = atEnd ? segment.dir : [-segment.dir[0], -segment.dir[1]] + const denominator = cross(out, other.dir) + if (Math.abs(denominator) < MIN_JOIN_SINE) return null + const toOther: Point = [other.start[0] - point[0], other.start[1] - point[1]] + const move = cross(toOther, other.dir) / denominator + const along = cross(toOther, out) / denominator + // The meeting point must lie on the neighbour, or just past an end of it + // that is itself short of this wall's centreline. + if (along < -reach || along > other.length + reach) return null + if (segment.length + move < MIN_JOINED_LENGTH) return null + return move +} + +/** Thickest IFC wall still read as cladding (tiles, skirting) when it lines another wall. */ +const MAX_CLADDING_THICKNESS = 0.035 + +/** + * Walls that only line or hide inside another wall: tiles, skirtings and thin + * facings exported as IfcWall along a real wall's face, shorter walls laid + * face to face along or running through a longer one, and walls whose body + * lies entirely inside a thicker parallel wall. As Pascal walls they carve + * sliver rooms or keep corners from closing, so they stay exact meshes. + */ +export function redundantWallIds(walls: readonly WallNode[]): Map { + const hosts = new Map() + const segments = walls.map(toSegment) + for (const [index, segment] of segments.entries()) { + if (!segment) continue + const cladding = segment.thickness <= MAX_CLADDING_THICKNESS + let lined = 0 + let host: Segment | undefined + for (const [otherIndex, other] of segments.entries()) { + if (!other || otherIndex === index || hosts.has(other.wall.id)) continue + if (other.wall.parentId !== segment.wall.parentId) continue + if (Math.abs(cross(segment.dir, other.dir)) > 0.05) continue + const offset = Math.abs( + cross([segment.start[0] - other.start[0], segment.start[1] - other.start[1]], other.dir), + ) + const project = (point: Point) => + (point[0] - other.start[0]) * other.dir[0] + (point[1] - other.start[1]) * other.dir[1] + const [a, b] = [project(segment.start), project(segment.end)].sort((x, y) => x - y) + const overlap = Math.max(0, Math.min(b!, other.length) - Math.max(a!, 0)) + const embedded = + other.thickness > segment.thickness && + offset + segment.thickness / 2 <= other.thickness / 2 + 0.01 && + overlap >= segment.length - 0.05 + // A shorter wall laid face to face along a longer one (a double + // partition) would leave a zero-width room between the two bodies. + // A shorter wall running through another's body (a thickened stretch + // over a partition) makes two overlapping room boundaries. + const lining = + segment.length < other.length && + (Math.abs(offset - (other.thickness + segment.thickness) / 2) <= 0.01 || + offset < Math.max(other.thickness, segment.thickness) / 2) && + overlap >= segment.length * 0.9 + if (embedded || lining) { + lined = segment.length + host = other + break + } + if ( + cladding && + other.thickness > 2 * segment.thickness && + offset <= (other.thickness + segment.thickness) / 2 + 0.02 + ) { + lined += overlap + host ??= other + } + } + if (host && lined >= segment.length * 0.5) hosts.set(segment.wall.id, host.wall) + } + return hosts +} + +export function joinWallEnds( + nodes: SceneNodes, + connections: readonly (readonly [number, number])[] = [], + /** Host wall expressID → thickness of a lining removed from its face (see redundantWallIds). */ + linings: ReadonlyMap = new Map(), +): number { + const segments = Object.values(nodes) + .filter((node): node is WallNode => node.type === 'wall') + .map(toSegment) + .filter((segment): segment is Segment => segment !== null) + const wallByExpressId = new Map() + for (const segment of segments) + for (const id of wallExpressIds(segment.wall)) wallByExpressId.set(id, segment.wall.id) + const connected = new Set() + for (const [a, b] of connections) { + const wallA = wallByExpressId.get(a) + const wallB = wallByExpressId.get(b) + if (!wallA || !wallB || wallA === wallB) continue + connected.add(`${wallA}|${wallB}`) + connected.add(`${wallB}|${wallA}`) + } + + const byLevel = new Map() + for (const segment of segments) { + const key = segment.wall.parentId ?? null + byLevel.set(key, [...(byLevel.get(key) ?? []), segment]) + } + + // Moves are measured on the original geometry: a wall extending along its + // own axis never changes the centreline its neighbours join onto. + const moves: { segment: Segment; atEnd: boolean; move: number }[] = [] + for (const level of byLevel.values()) { + for (const segment of level) { + if (isPascalAuthored(segment.wall)) continue + for (const atEnd of [false, true]) { + const point = atEnd ? segment.end : segment.start + let best: { move: number; score: number } | null = null + // An end inside another wall's body belongs on that wall's centreline, + // even past a corner it already makes (a furring lining a party wall). + let embedded: { move: number } | null = null + for (const other of level) { + if (other === segment) continue + const linked = connected.has(`${segment.wall.id}|${other.wall.id}`) + const limit = linked + ? MAX_CONNECTED_GAP + : Math.min(MAX_CONNECTED_GAP, other.thickness + GAP_TOLERANCE) + const reach = linked + ? MAX_CONNECTED_GAP + : Math.min( + MAX_CONNECTED_GAP, + Math.max(segment.thickness, other.thickness) + GAP_TOLERANCE, + ) + const move = joinMove(segment, atEnd, other, reach) + if (move === null || Math.abs(move) > limit) continue + const slack = Math.max(0, ...wallExpressIds(other.wall).map((id) => linings.get(id) ?? 0)) + // Ends reach through to the farthest body they touch; an end + // overshooting every body it touches trims back to the nearest. + if ( + insideBody(point, other, slack) && + (!embedded || + (move > 0 ? move > embedded.move : embedded.move <= 0 && move > embedded.move)) + ) + embedded = { move } + // An end already on a centreline stays there; IFC-connected + // neighbours win ties. + const score = Math.abs(move) - (linked ? 1e-3 : 0) + if (!best || score < best.score) best = { move, score } + } + const chosen = embedded ?? best + if (chosen && Math.abs(chosen.move) > 1e-4) + moves.push({ segment, atEnd, move: chosen.move }) + } + } + } + + for (const { segment, atEnd, move } of moves) { + const wall = nodes[segment.wall.id] as WallNode + if (atEnd) { + wall.end = [wall.end[0] + segment.dir[0] * move, wall.end[1] + segment.dir[1] * move] + continue + } + wall.start = [wall.start[0] - segment.dir[0] * move, wall.start[1] - segment.dir[1] * move] + // Openings are placed by their distance from the wall start. + for (const childId of wall.children) { + const child = nodes[childId] + if (child?.type !== 'door' && child?.type !== 'window') continue + child.position = [child.position[0] + move, child.position[1], child.position[2]] + } + } + return moves.length + joinInLineEnds(nodes, segments) +} + +/** A wall's plan frame: x along start → end, z to its left (wall-local +z). */ +function wallFrame(wall: WallNode) { + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) || 1 + const dir: Point = [ + (wall.end[0] - wall.start[0]) / length, + (wall.end[1] - wall.start[1]) / length, + ] + const left: Point = [-dir[1], dir[0]] + const origin: Point = [wall.start[0], wall.start[1]] + return { + toWorld: (along: number, across: number): Point => [ + origin[0] + dir[0] * along + left[0] * across, + origin[1] + dir[1] * along + left[1] * across, + ], + toLocal: (point: Point): [number, number] => { + const offset: Point = [point[0] - origin[0], point[1] - origin[1]] + return [offset[0] * dir[0] + offset[1] * dir[1], offset[0] * left[0] + offset[1] * left[1]] + }, + } +} + +/** + * A thinner wall continuing a thicker one flush with one face leaves its end + * beside the other's centreline, where no L or T can close. The end moves + * sideways onto the other wall's end; the wall turns by a degree or two and + * its doors and windows keep their plan position. + */ +function joinInLineEnds(nodes: SceneNodes, original: readonly Segment[]): number { + const walls = original.map((segment) => nodes[segment.wall.id] as WallNode) + const current = walls.map(toSegment) + const onCentreline = (point: Point, self: Segment) => + current.some((other) => { + if (!other || other === self || other.wall.parentId !== self.wall.parentId) return false + const offset: Point = [point[0] - other.start[0], point[1] - other.start[1]] + const along = offset[0] * other.dir[0] + offset[1] * other.dir[1] + return ( + Math.abs(cross(offset, other.dir)) <= 1e-3 && along >= -1e-3 && along <= other.length + 1e-3 + ) + }) + let joined = 0 + for (const segment of current) { + // Turning a wall would carry its doors and windows off their IFC place. + if (!segment || isPascalAuthored(segment.wall)) continue + for (const atEnd of [false, true]) { + const point = atEnd ? segment.end : segment.start + if (onCentreline(point, segment)) continue + let best: { end: Point; distance: number } | undefined + for (const other of current) { + if (!other || other === segment || other.wall.parentId !== segment.wall.parentId) continue + if (Math.abs(cross(segment.dir, other.dir)) > 0.03) continue + const lateral = Math.abs( + cross([point[0] - other.start[0], point[1] - other.start[1]], other.dir), + ) + if (lateral > Math.max(segment.thickness, other.thickness) / 2 + 0.01) continue + for (const end of [other.start, other.end]) { + const distance = Math.hypot(end[0] - point[0], end[1] - point[1]) + const gap = Math.abs( + (end[0] - point[0]) * other.dir[0] + (end[1] - point[1]) * other.dir[1], + ) + if (gap > 0.1 || distance < 1e-4 || distance > segment.length * 0.1) continue + if (!best || distance < best.distance) best = { end, distance } + } + } + if (!best) continue + const wall = nodes[segment.wall.id] as WallNode + const before = wallFrame(wall) + if (atEnd) wall.end = [...best.end] + else wall.start = [...best.end] + const after = wallFrame(wall) + // Doors and windows keep their place: re-expressed in the turned wall. + for (const id of wall.children) { + const child = nodes[id] + if (child?.type !== 'door' && child?.type !== 'window') continue + const world = before.toWorld(child.position[0], child.position[2]) + const [along, across] = after.toLocal(world) + child.position = [along, child.position[1], across] + } + segment.start = wall.start + segment.end = wall.end + joined++ + } + } + return joined +} + +/** + * Room faces only close at wall junctions: a wall crossing another mid-span + * (an X, not a T) splits in two at the crossing so both halves meet the other + * wall's centreline. The thinner wall of the pair is split. + */ +export function splitCrossingWalls(nodes: SceneNodes): number { + let split = 0 + for (let changed = true; changed; ) { + changed = false + const segments = Object.values(nodes) + .filter((node): node is WallNode => node.type === 'wall') + .map(toSegment) + .filter((segment): segment is Segment => segment !== null) + outer: for (const a of segments) + for (const b of segments) { + if (a === b || a.wall.parentId !== b.wall.parentId || isPascalAuthored(a.wall)) continue + if (a.thickness > b.thickness || (a.thickness === b.thickness && a.wall.id > b.wall.id)) + continue + const denominator = cross(a.dir, b.dir) + if (Math.abs(denominator) < MIN_JOIN_SINE) continue + const toB: Point = [b.start[0] - a.start[0], b.start[1] - a.start[1]] + const alongA = cross(toB, b.dir) / denominator + const alongB = cross(toB, a.dir) / denominator + const margin = Math.max(a.thickness, b.thickness) / 2 + 0.05 + if (alongA < margin || alongA > a.length - margin) continue + if (alongB < margin || alongB > b.length - margin) continue + splitWallAt(nodes, a, alongA) + split++ + changed = true + break outer + } + } + return split +} + +function splitWallAt(nodes: SceneNodes, segment: Segment, along: number) { + const wall = nodes[segment.wall.id] as WallNode + const point: Point = [ + segment.start[0] + segment.dir[0] * along, + segment.start[1] + segment.dir[1] * along, + ] + const second: WallNode = { + ...wall, + id: nextId('wall') as WallNode['id'], + start: point, + end: [...wall.end], + children: [], + metadata: { ...(wall.metadata ?? {}) }, + } + wall.end = point + for (const childId of [...wall.children]) { + const child = nodes[childId] + if (child?.type !== 'door' && child?.type !== 'window') continue + if (child.position[0] <= along) continue + child.position = [child.position[0] - along, child.position[1], child.position[2]] + child.parentId = second.id + child.wallId = second.id + wall.children = wall.children.filter((id) => id !== childId) as WallNode['children'] + second.children.push(childId as WallNode['children'][number]) + } + nodes[second.id] = second + const parent = wall.parentId ? nodes[wall.parentId] : undefined + if (parent && 'children' in parent && Array.isArray(parent.children)) + (parent.children as string[]).push(second.id) +} diff --git a/packages/ifc-converter/tests/ifc-builder.ts b/packages/ifc-converter/tests/ifc-builder.ts new file mode 100644 index 0000000000..c0c34db8c7 --- /dev/null +++ b/packages/ifc-converter/tests/ifc-builder.ts @@ -0,0 +1,300 @@ +// Writes tiny IFC4 files for importer regression tests: one storey at z = 0, +// metres, elements placed in IFC plan coordinates (x east, y north). + +type Vec2 = [number, number] +type Vec3 = [number, number, number] + +const num = (value: number) => { + const text = String(Math.round(value * 1e9) / 1e9) + return text.includes('.') || text.includes('e') ? text : `${text}.` +} + +export class IfcBuilder { + private readonly lines: string[] = [] + private nextId = 100 + private nextGuid = 100 + private readonly contained: number[] = [] + private readonly spaces: number[] = [] + + private add(entity: string): number { + const id = this.nextId++ + this.lines.push(`#${id}=${entity};`) + return id + } + + private guid() { + return `'${String(this.nextGuid++).padStart(22, '0')}'` + } + + private point(values: number[]) { + return this.add(`IFCCARTESIANPOINT((${values.map(num).join(',')}))`) + } + + private direction(values: number[]) { + return this.add(`IFCDIRECTION((${values.map(num).join(',')}))`) + } + + /** A placement relative to the storey: origin, local x and local z. */ + private placement(origin: Vec3, xAxis: Vec3 = [1, 0, 0], zAxis: Vec3 = [0, 0, 1]) { + const axis2 = this.add( + `IFCAXIS2PLACEMENT3D(#${this.point(origin)},#${this.direction(zAxis)},#${this.direction(xAxis)})`, + ) + return this.add(`IFCLOCALPLACEMENT(#32,#${axis2})`) + } + + private polyline(points: Vec2[], closed: boolean) { + const ids = points.map((point) => this.point(point)) + if (closed) ids.push(ids[0]!) + return this.add(`IFCPOLYLINE((${ids.map((id) => `#${id}`).join(',')}))`) + } + + private profile(outline: Vec2[], holes: Vec2[][] = []) { + const outer = this.polyline(outline, true) + if (!holes.length) return this.add(`IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#${outer})`) + const inner = holes.map((hole) => `#${this.polyline(hole, true)}`).join(',') + return this.add(`IFCARBITRARYPROFILEDEFWITHVOIDS(.AREA.,$,#${outer},(${inner}))`) + } + + private body(outline: Vec2[], depth: number, options: { holes?: Vec2[][]; down?: boolean } = {}) { + const profile = this.profile(outline, options.holes) + const direction = this.direction([0, 0, options.down ? -1 : 1]) + const solid = this.add(`IFCEXTRUDEDAREASOLID(#${profile},#6,#${direction},${num(depth)})`) + return this.add(`IFCSHAPEREPRESENTATION(#8,'Body','SweptSolid',(#${solid}))`) + } + + private shape(...representations: number[]) { + return this.add( + `IFCPRODUCTDEFINITIONSHAPE($,$,(${representations.map((id) => `#${id}`).join(',')}))`, + ) + } + + /** + * A wall along start → end whose body spans [low, high] on its local Y + * (left of the axis). A layer set usage records that span when asked. + */ + wall(options: { + start: Vec2 + end: Vec2 + low: number + high: number + height?: number + layerUsage?: boolean + }) { + const dx = options.end[0] - options.start[0] + const dy = options.end[1] - options.start[1] + const length = Math.hypot(dx, dy) + const placement = this.placement( + [options.start[0], options.start[1], 0], + [dx / length, dy / length, 0], + ) + const axis = this.add( + `IFCSHAPEREPRESENTATION(#8,'Axis','Curve2D',(#${this.polyline( + [ + [0, 0], + [length, 0], + ], + false, + )}))`, + ) + const body = this.body( + [ + [0, options.low], + [length, options.low], + [length, options.high], + [0, options.high], + ], + options.height ?? 2.7, + ) + const wall = this.add( + `IFCWALL(${this.guid()},$,'Wall',$,$,#${placement},#${this.shape(axis, body)},$,.STANDARD.)`, + ) + this.contained.push(wall) + if (options.layerUsage) { + const material = this.add(`IFCMATERIAL('Block',$,$)`) + const layer = this.add( + `IFCMATERIALLAYER(#${material},${num(options.high - options.low)},$,$,$,$,$)`, + ) + const set = this.add(`IFCMATERIALLAYERSET((#${layer}),'Wall',$)`) + const usage = this.add( + `IFCMATERIALLAYERSETUSAGE(#${set},.AXIS2.,.POSITIVE.,${num(options.low)},$)`, + ) + this.add(`IFCRELASSOCIATESMATERIAL(${this.guid()},$,$,$,(#${wall}),#${usage})`) + } + return { wall, placement } + } + + /** A door centred `station` metres along the wall from its start. */ + door(host: { wall: number; placement: number }, station: number, width = 0.9, height = 2.1) { + const axis2 = this.add( + `IFCAXIS2PLACEMENT3D(#${this.point([station, 0, 0])},#${this.direction([0, 0, 1])},#${this.direction([1, 0, 0])})`, + ) + const placement = this.add(`IFCLOCALPLACEMENT(#${host.placement},#${axis2})`) + const body = this.body( + [ + [-width / 2, -0.5], + [width / 2, -0.5], + [width / 2, 0.5], + [-width / 2, 0.5], + ], + height, + ) + const opening = this.add( + `IFCOPENINGELEMENT(${this.guid()},$,'Opening',$,$,#${placement},#${this.shape(body)},$,.OPENING.)`, + ) + this.add(`IFCRELVOIDSELEMENT(${this.guid()},$,$,$,#${host.wall},#${opening})`) + const door = this.add( + `IFCDOOR(${this.guid()},$,'Door',$,$,#${placement},$,$,${num(height)},${num(width)},.DOOR.,.SINGLE_SWING_LEFT.,$)`, + ) + this.add(`IFCRELFILLSELEMENT(${this.guid()},$,$,$,#${opening},#${door})`) + this.contained.push(door) + return door + } + + space(name: string, outline: Vec2[], height = 2.7) { + const placement = this.placement([0, 0, 0]) + const space = this.add( + `IFCSPACE(${this.guid()},$,'${name}',$,$,#${placement},#${this.shape(this.body(outline, height))},'${name}',.ELEMENT.,.INTERNAL.,$)`, + ) + this.spaces.push(space) + return space + } + + /** A floor slab extruded down from `top`. */ + slab(name: string, outline: Vec2[], top: number, thickness: number, holes: Vec2[][] = []) { + const placement = this.placement([0, 0, top]) + const slab = this.add( + `IFCSLAB(${this.guid()},$,'${name}',$,$,#${placement},#${this.shape( + this.body(outline, thickness, { holes, down: true }), + )},$,.FLOOR.)`, + ) + this.contained.push(slab) + return slab + } + + /** A covering whose underside is at `bottom`, optionally tilted about IFC x. */ + covering( + type: 'CEILING' | 'FLOORING', + outline: Vec2[], + bottom: number, + thickness: number, + options: { holes?: Vec2[][]; tiltDegrees?: number } = {}, + ) { + const tilt = ((options.tiltDegrees ?? 0) * Math.PI) / 180 + const placement = this.placement( + [0, 0, bottom], + [1, 0, 0], + [0, -Math.sin(tilt), Math.cos(tilt)], + ) + const covering = this.add( + `IFCCOVERING(${this.guid()},$,'${type === 'CEILING' ? 'Ceiling' : 'Flooring'}',$,$,#${placement},#${this.shape( + this.body(outline, thickness, { holes: options.holes }), + )},$,.${type}.)`, + ) + this.contained.push(covering) + return covering + } + + /** An opening cut through `host` over `outline`, from below `bottom` up. */ + voidThrough(host: number, outline: Vec2[], bottom: number, depth: number) { + const placement = this.placement([0, 0, bottom]) + const opening = this.add( + `IFCOPENINGELEMENT(${this.guid()},$,'Opening',$,$,#${placement},#${this.shape(this.body(outline, depth))},$,.OPENING.)`, + ) + this.add(`IFCRELVOIDSELEMENT(${this.guid()},$,$,$,#${host},#${opening})`) + return opening + } + + furnishing(at: Vec2, size = 0.5) { + const placement = this.placement([at[0], at[1], 0]) + const half = size / 2 + const furnishing = this.add( + `IFCFURNISHINGELEMENT(${this.guid()},$,'Chair',$,$,#${placement},#${this.shape( + this.body( + [ + [-half, -half], + [half, -half], + [half, half], + [-half, half], + ], + size, + ), + )},$)`, + ) + this.contained.push(furnishing) + return furnishing + } + + /** The property set a Pascal export writes: the node's id and type. */ + pascalIdentity(element: number, nodeId: string, nodeType: string) { + const id = this.add(`IFCPROPERTYSINGLEVALUE('NodeId',$,IFCIDENTIFIER('${nodeId}'),$)`) + const type = this.add(`IFCPROPERTYSINGLEVALUE('NodeType',$,IFCLABEL('${nodeType}'),$)`) + const pset = this.add(`IFCPROPERTYSET(${this.guid()},$,'Pascal',$,(#${id},#${type}))`) + this.add(`IFCRELDEFINESBYPROPERTIES(${this.guid()},$,$,$,(#${element}),#${pset})`) + } + + toString() { + const refs = (ids: number[]) => ids.map((id) => `#${id}`).join(',') + return [ + 'ISO-10303-21;', + 'HEADER;', + "FILE_DESCRIPTION(('Importer regression fixture'),'2;1');", + "FILE_NAME('fixture.ifc','2026-09-30T00:00:00',('Pascal'),('Pascal'),'','','');", + "FILE_SCHEMA(('IFC4'));", + 'ENDSEC;', + 'DATA;', + "#1=IFCPROJECT('0000000000000000000001',$,'Fixture',$,$,$,$,(#8),#9);", + '#2=IFCCARTESIANPOINT((0.,0.,0.));', + '#3=IFCDIRECTION((0.,0.,1.));', + '#4=IFCDIRECTION((1.,0.,0.));', + '#6=IFCAXIS2PLACEMENT3D(#2,#3,#4);', + "#8=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.00001,#6,$);", + '#9=IFCUNITASSIGNMENT((#10));', + '#10=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);', + '#13=IFCLOCALPLACEMENT($,#6);', + "#14=IFCSITE('0000000000000000000002',$,'Site',$,$,#13,$,$,.ELEMENT.,$,$,$,$,$);", + "#15=IFCRELAGGREGATES('0000000000000000000003',$,$,$,#1,(#14));", + '#22=IFCLOCALPLACEMENT(#13,#6);', + "#23=IFCBUILDING('0000000000000000000004',$,'Building',$,$,#22,$,$,.ELEMENT.,$,$,$);", + "#24=IFCRELAGGREGATES('0000000000000000000005',$,$,$,#14,(#23));", + '#32=IFCLOCALPLACEMENT(#22,#6);', + "#33=IFCBUILDINGSTOREY('0000000000000000000006',$,'Ground',$,$,#32,$,$,.ELEMENT.,0.);", + "#34=IFCRELAGGREGATES('0000000000000000000007',$,$,$,#23,(#33));", + ...this.lines, + ...(this.contained.length + ? [ + `#${this.nextId++}=IFCRELCONTAINEDINSPATIALSTRUCTURE(${this.guid()},$,$,$,(${refs(this.contained)}),#33);`, + ] + : []), + ...(this.spaces.length + ? [`#${this.nextId++}=IFCRELAGGREGATES(${this.guid()},$,$,$,#33,(${refs(this.spaces)}));`] + : []), + 'ENDSEC;', + 'END-ISO-10303-21;', + ].join('\n') + } +} + +/** Four centred walls on the rectangle [0, width] × [0, depth] (centrelines). */ +export function boxWalls(builder: IfcBuilder, width: number, depth: number, thickness = 0.2) { + const corners: Vec2[] = [ + [0, 0], + [width, 0], + [width, depth], + [0, depth], + ] + return corners.map((start, index) => + builder.wall({ + start, + end: corners[(index + 1) % 4]!, + low: -thickness / 2, + high: thickness / 2, + }), + ) +} + +export const rectangle = (x0: number, y0: number, x1: number, y1: number): Vec2[] => [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], +] diff --git a/packages/ifc-converter/tests/imported-mesh-conversion.test.ts b/packages/ifc-converter/tests/imported-mesh-conversion.test.ts index d0eef08303..0a4fccef87 100644 --- a/packages/ifc-converter/tests/imported-mesh-conversion.test.ts +++ b/packages/ifc-converter/tests/imported-mesh-conversion.test.ts @@ -232,7 +232,10 @@ describe('IFC imported mesh conversion', () => { const scene = await duplexWithMissingSpaceNameScene() const nodes = Object.values(scene.nodes) - expect(nodes.filter((node) => node.type === 'zone')).toHaveLength(21) + // Wall loops no IfcSpace claims become rooms too; only the spaces are counted. + expect( + nodes.filter((node) => node.type === 'zone' && metadata(node).ifcType === 'IFCSPACE'), + ).toHaveLength(21) expect( nodes.some((node) => node.type === 'zone' && metadata(node).footprintApproximated === true), ).toBe(true) @@ -247,7 +250,7 @@ describe('IFC imported mesh conversion', () => { test('keeps a zone when its IFC room number exceeds the Pascal limit', async () => { const scene = await duplexWithLongRoomNumberScene() const zones = Object.values(scene.nodes).filter( - (node): node is ZoneNode => node.type === 'zone', + (node): node is ZoneNode => node.type === 'zone' && metadata(node).ifcType === 'IFCSPACE', ) const changedZone = zones.find((zone) => metadata(zone).expressID === 157) diff --git a/packages/ifc-converter/tests/load-scene.ts b/packages/ifc-converter/tests/load-scene.ts new file mode 100644 index 0000000000..f20a4aa204 --- /dev/null +++ b/packages/ifc-converter/tests/load-scene.ts @@ -0,0 +1,50 @@ +import type { AnyNode } from '@pascal-app/core' +import { + ensureSceneOpenings, + healSceneNodes, + materializeLegacyAutoOpenings, + materializeNodeDefaults, + migrateCeilingRoomLinks, + migrateFloorPlates, + migrateLegacyWallAssemblies, + migrateRoomZones, + migrateSlabSlots, + migrateStructuralMaterialSlots, + migrateVerticalSceneNodes, + migrateWallFaceBands, + migrateWallFaceKeys, + normalizeLegacyStructure, + reconcileStructureOnLoad, + removeRetiredDrawingSheetNodes, + STRUCTURE_NODE_KINDS, +} from '@pascal-app/core/scene-migrations' + +type Nodes = Record + +/** + * Loads a converted graph the way the editor and the scene authority do + * (normalize-authority-scene.ts / useScene.setScene). + */ +export function loadScene(source: Record): Nodes { + const legacyNodes = normalizeLegacyStructure(source) + const healed = healSceneNodes(legacyNodes) + const retained = removeRetiredDrawingSheetNodes(healed.nodes).nodes + const materials = migrateStructuralMaterialSlots(retained) + const walls = migrateLegacyWallAssemblies(materials.nodes) + const vertical = migrateVerticalSceneNodes(walls.nodes) + const rooms = migrateRoomZones(vertical.nodes) + const ceilings = migrateCeilingRoomLinks(rooms.nodes) + const ceilingNodes = Object.values(ceilings.nodes) as AnyNode[] + const legacyOpeningsPrepared = + ceilingNodes.some((node) => node.type === 'slab' && node.autoFromWalls && !node.plateRole) && + ceilingNodes.some((node) => node.type === 'stair' || node.type === 'elevator') + const plates = migrateFloorPlates(materializeLegacyAutoOpenings(ceilings.nodes, true)) + const slots = migrateSlabSlots(plates.nodes) + const openings = ensureSceneOpenings(slots.nodes) + const wallFaces = migrateWallFaceKeys(openings.nodes) + const wallBands = migrateWallFaceBands(wallFaces.nodes) + const structure = reconcileStructureOnLoad(wallBands.nodes, vertical.nodes, { + legacyOpeningsPrepared, + }) + return materializeNodeDefaults(structure.nodes, STRUCTURE_NODE_KINDS).nodes as Nodes +} diff --git a/packages/ifc-converter/tests/room-first-load.test.ts b/packages/ifc-converter/tests/room-first-load.test.ts new file mode 100644 index 0000000000..bd195bf4b2 --- /dev/null +++ b/packages/ifc-converter/tests/room-first-load.test.ts @@ -0,0 +1,376 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type AnyNode, + area, + type CeilingNode, + containsPoint, + type DoorNode, + intersection, + type Polygon, + type SlabNode, + type WallNode, + type WindowNode, + type ZoneNode, +} from '@pascal-app/core' +import { convertIfcToPascal, type PascalSceneGraph } from '../src' +import { loadScene } from './load-scene' + +// Converts the reference IFC files and loads them the way the editor and the +// scene authority do (normalize-authority-scene.ts / useScene.setScene), then +// checks the room-first structure the loader ends up with. + +const fixtures = fileURLToPath( + new URL('../../../apps/ifc-converter/public/test-ifc-files/', import.meta.url), +) +const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` +const quietLog = console.log + +type Nodes = Record + +async function convert(name: string, transform?: (source: string) => string) { + const source = await readFile(`${fixtures}${name}`) + const data = transform + ? new TextEncoder().encode(transform(new TextDecoder().decode(source))) + : source + return convertIfcToPascal(data, undefined, { wasmPath }) +} + +const meta = (node: AnyNode) => (node.metadata ?? {}) as Record +const ofType = (nodes: Nodes, type: T['type']) => + Object.values(nodes).filter((node): node is T => node.type === type) +const footprint = (node: { + polygon: [number, number][] + holes?: [number, number][][] +}): Polygon => ({ + outer: node.polygon, + holes: node.holes ?? [], +}) +const levelNamed = (nodes: Nodes, name: string) => + ofType>(nodes, 'level').find((level) => level.name === name)! +const isSpace = (zone: ZoneNode) => meta(zone).ifcType === 'IFCSPACE' + +function adoption(nodes: Nodes) { + const spaces = ofType(nodes, 'zone').filter(isSpace) + return { adopted: spaces.filter((zone) => zone.autoFromWalls).length, total: spaces.length } +} + +/** Rooms the IFC has no space for that cover > 30 % of a space (of the smaller one). */ +function overlappingRooms(nodes: Nodes) { + const zones = ofType(nodes, 'zone') + const spaces = zones.filter(isSpace) + const others = zones.filter((zone) => !isSpace(zone) && zone.polygon.length >= 3) + return others.flatMap((room) => + spaces.flatMap((space) => { + if (space.parentId !== room.parentId) return [] + const overlap = area(intersection(footprint(room), footprint(space))) + const smaller = Math.min(area([footprint(room)]), area([footprint(space)])) + return overlap > 0.3 * smaller ? [`${room.name}~${space.name}`] : [] + }), + ) +} + +/** Share of joined wall ends (another wall within 0.6 m) lying on a neighbour's reference line. */ +function wallJoinShare(nodes: Nodes) { + const walls = ofType(nodes, 'wall') + let joins = 0 + let exact = 0 + for (const wall of walls) + for (const [x, z] of [wall.start, wall.end]) { + let best = Number.POSITIVE_INFINITY + for (const other of walls) { + if (other === wall || other.parentId !== wall.parentId) continue + const [ax, az] = other.start + const dx = other.end[0] - ax + const dz = other.end[1] - az + const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz))) + best = Math.min(best, Math.hypot(x - ax - t * dx, z - az - t * dz)) + } + if (best > 0.6) continue + joins++ + if (best <= 0.015) exact++ + } + return exact / joins +} + +function plateOn(nodes: Nodes, levelName: string) { + const level = levelNamed(nodes, levelName) + return ofType(nodes, 'slab').filter( + (slab) => slab.parentId === level.id && slab.plateRole === 'base', + ) +} + +/** Hand-drawn slabs lying on a room floor at the same height (z-fighting finish layers). */ +function stackedOnPlates(nodes: Nodes) { + const slabs = ofType(nodes, 'slab') + const plates = slabs.filter((slab) => slab.plateRole) + return slabs.filter( + (slab) => + !slab.plateRole && + plates.some( + (plate) => + plate.parentId === slab.parentId && + Math.abs(plate.elevation - slab.elevation) <= 0.005 && + area(intersection(footprint(plate), footprint(slab))) > 0.5, + ), + ) +} + +function linkedCeilings(nodes: Nodes) { + return ofType(nodes, 'ceiling').filter( + (ceiling) => + ceiling.boundary === 'auto' && + ceiling.zoneId && + nodes[ceiling.zoneId]?.type === 'zone' && + isSpace(nodes[ceiling.zoneId] as ZoneNode) && + meta(ceiling).ifcType === 'IFCCOVERING', + ) +} + +function openingWorldPoint(opening: DoorNode | WindowNode, nodes: Nodes): [number, number] { + const wall = nodes[opening.parentId!] as WallNode + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const along = opening.position[0] / length + return [ + wall.start[0] + (wall.end[0] - wall.start[0]) * along, + wall.start[1] + (wall.end[1] - wall.start[1]) * along, + ] +} + +const scenes = new Map() +const FILES = [ + '01-duplex.ifc', + '04-ifc-open-house.ifc', + '05-paris-ground-floor.ifc', + '10-sample-house.ifc', +] + +beforeAll(async () => { + console.log = () => {} + for (const file of FILES) { + const raw = await convert(file) + scenes.set(file, { raw, loaded: loadScene(structuredClone(raw.nodes)) }) + } +}, 180_000) +afterAll(() => { + console.log = quietLog +}) + +const scene = (file: string) => scenes.get(file)! + +describe('IFC import loads as room-first structure', () => { + test('IFC spaces are adopted as the rooms of their wall loops', () => { + expect(adoption(scene('01-duplex.ifc').loaded)).toEqual({ adopted: 21, total: 21 }) + // ENTREE LOGEMENTS stays joined to FILIERE VENDANGES: the wall between them + // is a 250 mm stretch (holding the door) overlapping a 50 mm partition. + expect(adoption(scene('05-paris-ground-floor.ifc').loaded)).toEqual({ adopted: 28, total: 29 }) + // The attic "Roof" space sits on a storey without walls. + expect(adoption(scene('10-sample-house.ifc').loaded)).toEqual({ adopted: 3, total: 4 }) + }) + + test('no room the IFC has no space for overlaps a space', () => { + for (const file of FILES) + expect({ file, overlaps: overlappingRooms(scene(file).loaded) }).toEqual({ + file, + overlaps: [], + }) + }) + + test('IFC spaces keep a seed point inside their outline', () => { + for (const file of FILES) + for (const zone of ofType(scene(file).raw.nodes, 'zone').filter(isSpace)) { + expect(zone.seed).toBeDefined() + expect(containsPoint([footprint(zone)], zone.seed!)).toBe(true) + } + }) + + test('wall ends meet their neighbour centreline', () => { + for (const file of FILES) expect(wallJoinShare(scene(file).loaded)).toBeGreaterThanOrEqual(0.9) + }) + + test('floors that fit their rooms are room floors with the IFC build-up', () => { + const house = plateOn(scene('10-sample-house.ifc').loaded, 'Ground Floor') + expect(house).toHaveLength(1) + expect(house[0]!.thickness).toBeCloseTo(0.47, 3) + expect(house[0]!.elevation).toBeCloseTo(0, 3) + + // 127 mm slab on grade / 305 mm joists, each under a 19 mm wood finish. + const duplex = scene('01-duplex.ifc').loaded + for (const [level, structure] of [ + ['Level 1', 0.127], + ['Level 2', 0.305], + ] as const) { + const plates = plateOn(duplex, level) + expect(plates.length).toBeGreaterThan(0) + for (const plate of plates) { + expect(plate.elevation).toBeCloseTo(0.019, 3) + expect(plate.thickness).toBeCloseTo(structure + 0.019, 3) + expect(plate.elevation - plate.thickness).toBeCloseTo(-structure, 3) + } + } + + // 300 mm structure under 50 mm marble. + const paris = plateOn(scene('05-paris-ground-floor.ifc').loaded, 'Ground Floor') + expect(paris).toHaveLength(1) + expect(paris[0]!.elevation).toBeCloseTo(0.05, 3) + expect(paris[0]!.thickness).toBeCloseTo(0.35, 3) + }) + + test('floors that do not fit stay hand-drawn slabs at their IFC top and thickness', () => { + const duplex = ofType(scene('01-duplex.ifc').loaded, 'slab').filter( + (slab) => !slab.plateRole, + ) + expect(duplex.map((slab) => slab.name.replace(/:\d+$/, ''))).toEqual([ + 'Sol:150mm Exterior Slab on Grade', + 'Sol:150mm Exterior Slab on Grade', + ]) + for (const slab of duplex) { + expect(slab.elevation).toBeCloseTo(0.013, 3) + expect(slab.thickness).toBeCloseTo(0.15, 3) + } + const paris = ofType(scene('05-paris-ground-floor.ifc').loaded, 'slab') + const footpath = paris.find((slab) => slab.name === 'Sol:FootPath:421113')! + expect(footpath.plateRole).toBeUndefined() + expect(footpath.elevation).toBeCloseTo(-0.15, 3) + expect(footpath.thickness).toBeCloseTo(0.3, 3) + // The plinth around the sample house is the slab's part outside the rooms. + const plinth = ofType(scene('10-sample-house.ifc').loaded, 'slab').find( + (slab) => meta(slab).ifcSplit === 'outside-rooms', + )! + expect(plinth.plateRole).toBeUndefined() + expect(plinth.thickness).toBeCloseTo(0.47, 3) + expect(plinth.holes).toHaveLength(1) + }) + + test('slab holes become floor openings', () => { + const openings = ofType(scene('05-paris-ground-floor.ifc').loaded, 'floor-opening') + expect(openings).toHaveLength(7) + }) + + test('finish floors become room finishes, not stacked slabs', () => { + for (const file of FILES) + expect(stackedOnPlates(scene(file).loaded).map((slab) => slab.name)).toEqual([]) + const zones = ofType(scene('01-duplex.ifc').loaded, 'zone') + const finishOf = (name: string) => zones.find((zone) => zone.name === name)?.floor?.finish + expect(finishOf('Living Room')).toBe('library:wood-woodplank48') + expect(finishOf('Kitchen')).toBe('library:flooring-lightceramic24') + expect(finishOf('Bathroom 1')).toBe('library:flooring-lightceramic24') + }) + + test('IFC ceiling coverings become the ceilings of their rooms', () => { + const duplex = scene('01-duplex.ifc') + const linked = linkedCeilings(duplex.loaded) + expect(linked.length).toBeGreaterThanOrEqual(18) + for (const ceiling of linked) expect(ceiling.height).toBeCloseTo(2.6, 2) + expect(linkedCeilings(scene('10-sample-house.ifc').loaded)).toHaveLength(3) + // Paris's one ceiling covers ENTREE LOGEMENTS, which is no room of its own + // (see above): it stays a hand-drawn ceiling at the covering's underside. + const paris = ofType(scene('05-paris-ground-floor.ifc').loaded, 'ceiling').filter( + (ceiling) => meta(ceiling).ifcType === 'IFCCOVERING', + ) + expect(paris).toHaveLength(1) + expect(paris[0]!.height).toBeCloseTo(2.26, 2) + // Every room the importer gave a covering keeps a ceiling through the load. + for (const file of FILES) { + const { raw, loaded } = scene(file) + for (const ceiling of ofType(raw.nodes, 'ceiling')) { + if (ceiling.boundary !== 'auto' || !ceiling.zoneId) continue + expect( + ofType(loaded, 'ceiling').some( + (candidate) => + candidate.zoneId === ceiling.zoneId && candidate.height === ceiling.height, + ), + ).toBe(true) + } + } + for (const file of FILES) { + const meshes = ofType(scene(file).raw.nodes, 'imported-mesh') + expect( + meshes.filter( + (mesh) => meta(mesh).ifcType === 'IFCCOVERING' && meta(mesh).predefinedType === 'CEILING', + ), + ).toEqual([]) + } + // A room the file models no ceiling for has none. + const stair = ofType(duplex.loaded, 'zone').find((zone) => zone.name === 'Stair')! + expect(stair.hasCeiling).toBe(false) + }) + + test('openings are stamped for the current threshold model and keep their place', async () => { + const duplex = scene('01-duplex.ifc').raw + const openings = Object.values(duplex.nodes).filter( + (node): node is DoorNode | WindowNode => node.type === 'door' || node.type === 'window', + ) + for (const opening of openings) { + expect(opening.floorThresholdVersion).toBe(1) + expect(opening.wallId).toBe(opening.parentId!) + } + // Joining corners moves wall starts; openings keep their plan position. + const unjoined = await convertIfcToPascal( + await readFile(`${fixtures}01-duplex.ifc`), + undefined, + { + wasmPath, + simplify: false, + }, + ) + for (const opening of openings) { + const before = Object.values(unjoined.nodes).find( + (node) => meta(node).expressID === meta(opening).expressID, + ) as DoorNode | WindowNode + const [x0, z0] = openingWorldPoint(before, unjoined.nodes) + const [x1, z1] = openingWorldPoint(opening, duplex.nodes) + expect(Math.hypot(x1 - x0, z1 - z0)).toBeLessThan(0.01) + } + }, 60_000) + + test('the site covers the imported model', () => { + for (const file of FILES) { + const nodes = scene(file).raw.nodes + const [site] = ofType>(nodes, 'site') + const outline: Polygon = { outer: site!.polygon!.points, holes: [] } + for (const wall of ofType(nodes, 'wall')) + for (const point of [wall.start, wall.end]) + expect(containsPoint([outline], point)).toBe(true) + } + }) + + // Room detection breaks near-ties by node id, so ids must not be random. + test('the same file always imports to the same scene', async () => { + const again = await convert('01-duplex.ifc') + expect(JSON.stringify(again)).toBe(JSON.stringify(scene('01-duplex.ifc').raw)) + }) +}) + +describe('IFC wall reference lines', () => { + test('a layer set on a wall face becomes Pascal justification', async () => { + // The sample house partitions are centred (NEGATIVE, offset 47.5 of 95 mm); + // an offset of 0 puts the IFC reference line on one face instead. + const centred = await convert('10-sample-house.ifc') + const faced = await convert('10-sample-house.ifc', (source) => + source.replaceAll('.AXIS2.,.NEGATIVE.,47.5,', '.AXIS2.,.NEGATIVE.,0.,'), + ) + const partitions = (graph: PascalSceneGraph) => + ofType(graph.nodes, 'wall').filter((wall) => (wall.thickness ?? 0) < 0.1) + expect(partitions(centred).every((wall) => wall.justification === undefined)).toBe(true) + const justified = partitions(faced) + expect(justified.length).toBeGreaterThan(0) + for (const wall of justified) { + expect(wall.justification === 'a' || wall.justification === 'b').toBe(true) + const original = partitions(centred).find( + (candidate) => meta(candidate).expressID === meta(wall).expressID, + )! + // The reference line is the IFC axis; the body moved half its thickness + // off it, onto the side the layers run. + const dx = original.end[0] - original.start[0] + const dz = original.end[1] - original.start[1] + const length = Math.hypot(dx, dz) + const offset = + ((wall.start[0] - original.start[0]) * -dz + (wall.start[1] - original.start[1]) * dx) / + length + expect(Math.abs(offset)).toBeLessThan(0.002) + } + }, 60_000) +}) diff --git a/packages/ifc-converter/tests/room-first-regressions.test.ts b/packages/ifc-converter/tests/room-first-regressions.test.ts new file mode 100644 index 0000000000..82ba179d88 --- /dev/null +++ b/packages/ifc-converter/tests/room-first-regressions.test.ts @@ -0,0 +1,211 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type AnyNode, + area, + type CeilingNode, + type DoorNode, + type Polygon, + type SlabNode, + type WallNode, + type ZoneNode, +} from '@pascal-app/core' +import { migrateRoomZones } from '@pascal-app/core/scene-migrations' +import { convertIfcToPascal } from '../src' +import { boxWalls, IfcBuilder, rectangle } from './ifc-builder' +import { loadScene } from './load-scene' + +// Small synthetic IFC files for importer bugs found in review. IFC plan +// (x, y) becomes Pascal plan (x, -y). + +const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` +const quietLog = console.log +beforeAll(() => { + console.log = () => {} +}) +afterAll(() => { + console.log = quietLog +}) + +type Nodes = Record +const convert = async (builder: IfcBuilder) => + (await convertIfcToPascal(new TextEncoder().encode(builder.toString()), undefined, { wasmPath })) + .nodes as Nodes +const ofType = (nodes: Nodes, type: T['type']) => + Object.values(nodes).filter((node): node is T => node.type === type) +const meta = (node: AnyNode) => (node.metadata ?? {}) as Record +const footprint = (node: { + polygon: [number, number][] + holes?: [number, number][][] +}): Polygon => ({ + outer: node.polygon, + holes: node.holes ?? [], +}) +const ringArea = (ring: [number, number][]) => area([{ outer: ring, holes: [] }]) +const close = (a: [number, number], b: [number, number], tolerance = 1e-6) => + Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance + +function doorWorldPoint(door: DoorNode, nodes: Nodes): [number, number] { + const wall = nodes[door.parentId!] as WallNode + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const dir = [(wall.end[0] - wall.start[0]) / length, (wall.end[1] - wall.start[1]) / length] + const [along, , across] = door.position + return [ + wall.start[0] + dir[0]! * along - dir[1]! * across, + wall.start[1] + dir[1]! * along + dir[0]! * across, + ] +} + +/** A 5 × 4 m room (wall centrelines) with its space filling the clear floor. */ +function room(builder: IfcBuilder) { + const walls = boxWalls(builder, 5, 4) + builder.space('Room', rectangle(0.1, 0.1, 4.9, 3.9)) + return walls +} + +describe('IFC importer regressions', () => { + test('face-referenced walls keep their corners joined on the reference lines', async () => { + // Axes on the outer faces of a 5 × 4 m box, 200 mm bodies inward (left). + const builder = new IfcBuilder() + const corners: [number, number][] = [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ] + const walls = corners.map((start, index) => + builder.wall({ start, end: corners[(index + 1) % 4]!, low: 0, high: 0.2, layerUsage: true }), + ) + builder.door(walls[0]!, 2.5) + builder.space('Room', rectangle(0.2, 0.2, 4.8, 3.8)) + const nodes = await convert(builder) + + const imported = ofType(nodes, 'wall') + expect(imported).toHaveLength(4) + for (const wall of imported) expect(wall.justification).toBeDefined() + // Every reference corner is shared by two walls, at the IFC outer corners. + for (const [x, y] of corners) { + const point: [number, number] = [x, -y] + const ends = imported + .flatMap((wall) => [wall.start, wall.end]) + .filter((end) => close(end, point)) + expect(ends).toHaveLength(2) + } + const rooms = Object.values(migrateRoomZones(nodes).nodes as Nodes).filter( + (node): node is ZoneNode => node.type === 'zone' && node.autoFromWalls, + ) + expect(rooms).toHaveLength(1) + expect(meta(rooms[0]!).ifcType).toBe('IFCSPACE') + + const [door] = ofType(nodes, 'door') + const [x, z] = doorWorldPoint(door!, nodes) + expect(x).toBeCloseTo(2.5, 3) + expect(Math.abs(z)).toBeLessThan(0.11) + }, 30_000) + + test('a slab under part of a room stays hand-drawn', async () => { + const builder = new IfcBuilder() + room(builder) + const slab = builder.slab('Part floor', rectangle(0, 0, 3, 4), 0, 0.2) + const nodes = await convert(builder) + const imported = ofType(nodes, 'slab').find((node) => meta(node).expressID === slab)! + expect(imported.plateRole).toBeUndefined() + const space = ofType(nodes, 'zone').find((zone) => meta(zone).ifcType === 'IFCSPACE')! + expect(space.floor?.sourceSlabId).toBe(imported.id) + + const loaded = loadScene(structuredClone(nodes)) + for (const plate of ofType(loaded, 'slab').filter((node) => node.plateRole)) + expect(area([footprint(plate)])).toBeLessThanOrEqual(12.01) + }, 30_000) + + test('a room floored in two finishes keeps both', async () => { + const builder = new IfcBuilder() + room(builder) + builder.slab('Structure', rectangle(-0.1, -0.1, 5.1, 4.1), 0, 0.2) + builder.slab('Finish Floor - Wood', rectangle(0.1, 0.1, 3, 3.9), 0.02, 0.02) + builder.slab('Finish Floor - Ceramic Tile', rectangle(3, 0.1, 4.9, 3.9), 0.02, 0.02) + const nodes = await convert(builder) + + const space = ofType(nodes, 'zone').find((zone) => meta(zone).ifcType === 'IFCSPACE')! + expect(space.floor?.finish).toBe('library:wood-woodplank48') + const regions = space.floor?.regions ?? [] + expect(regions.map((region) => region.finish)).toEqual(['library:flooring-lightceramic24']) + expect(ringArea(regions[0]!.polygon)).toBeCloseTo(1.9 * 3.8, 2) + // Both finishes are wholly represented by the room: no slabs remain for them. + expect(ofType(nodes, 'slab').map((slab) => slab.name)).toEqual(['Structure']) + }, 30_000) + + test('a wall turned onto an in-line neighbour keeps its door in place', async () => { + const builder = new IfcBuilder() + const thin = builder.wall({ start: [0, 0], end: [5, 0], low: -0.025, high: 0.025 }) + builder.wall({ start: [5, 0.09], end: [9, 0.09], low: -0.1, high: 0.1 }) + builder.door(thin, 4) + const nodes = await convert(builder) + + const turned = ofType(nodes, 'wall').find((wall) => close(wall.start, [0, 0]))! + expect(close(turned.end, [5, -0.09])).toBe(true) + const [door] = ofType(nodes, 'door') + const [x, z] = doorWorldPoint(door!, nodes) + expect(x).toBeCloseTo(4, 3) + expect(z).toBeCloseTo(0, 3) + }, 30_000) + + test('ceiling covering holes survive import and room linking', async () => { + const builder = new IfcBuilder() + room(builder) + builder.slab('Structure', rectangle(-0.1, -0.1, 5.1, 4.1), 0, 0.2) + const ceiling = builder.covering('CEILING', rectangle(0.1, 0.1, 4.9, 3.9), 2.5, 0.02, { + holes: [rectangle(0.5, 0.5, 1.5, 1.5)], + }) + builder.voidThrough(ceiling, rectangle(3, 2, 4, 3), 2.4, 0.3) + const nodes = await convert(builder) + + const [imported] = ofType(nodes, 'ceiling') + expect(imported!.zoneId).toBeDefined() + expect(imported!.holes.map((hole) => Math.round(ringArea(hole) * 100) / 100)).toEqual([1, 1]) + expect(imported!.holeMetadata.every((entry) => entry.source === 'manual')).toBe(true) + + const loaded = loadScene(structuredClone(nodes)) + const linked = ofType(loaded, 'ceiling').filter( + (node) => node.zoneId === imported!.zoneId, + ) + expect(linked).toHaveLength(1) + const cut = linked[0]!.holes.filter( + (_, index) => linked[0]!.holeMetadata[index]?.source === 'manual', + ) + expect(cut.map((hole) => Math.round(ringArea(hole) * 100) / 100)).toEqual([1, 1]) + expect(linked[0]!.height).toBeCloseTo(2.5, 3) + }, 30_000) + + test('a sloped covering stays an imported mesh', async () => { + const builder = new IfcBuilder() + room(builder) + const covering = builder.covering('CEILING', rectangle(0.1, 0.1, 4.9, 3.9), 2.2, 0.02, { + tiltDegrees: 20, + }) + const nodes = await convert(builder) + expect(ofType(nodes, 'ceiling')).toHaveLength(0) + const mesh = ofType(nodes, 'imported-mesh').find( + (node) => meta(node).expressID === covering, + ) + expect(mesh).toBeDefined() + }, 30_000) + + test('imported meshes keep the ids a Pascal export gave them', async () => { + const builder = new IfcBuilder() + const kept = builder.furnishing([1, 1]) + const duplicate = builder.furnishing([2, 1]) + const otherType = builder.furnishing([3, 1]) + builder.pascalIdentity(kept, 'imesh_kept', 'imported-mesh') + builder.pascalIdentity(duplicate, 'imesh_kept', 'imported-mesh') + builder.pascalIdentity(otherType, 'item_chair', 'item') + const nodes = await convert(builder) + const meshes = ofType(nodes, 'imported-mesh') + const byExpress = (id: number) => meshes.find((mesh) => meta(mesh).expressID === id)! + expect(byExpress(kept).id).toBe('imesh_kept') + expect(byExpress(duplicate).id).not.toBe('imesh_kept') + expect(byExpress(duplicate).id.startsWith('imesh_')).toBe(true) + expect(byExpress(otherType).id.startsWith('imesh_')).toBe(true) + }, 30_000) +}) From 312f5c49f8da9367531b336aa9746c2e93783448 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 30 Sep 2026 14:17:41 -0400 Subject: [PATCH 2/3] feat(editor): export the scene to IFC4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@pascal-app/ifc-converter/export`, a pure TypeScript STEP writer (no web-ifc at runtime), and an "Export IFC" button in Settings → Export. - Walls, doors/windows (with voiding openings), slabs, rooms (IfcSpace), ceilings (IfcCovering), columns and units (IfcZone) are parametric; roofs, stairs, items, fences and vegetation are tessellated from the rendered export scene with their colours. - Wall bases follow the renderer's stepped supports; slab and space outlines are normalised so voids are valid IFC profiles. - GlobalIds derive from node ids (or the imported IFC GlobalId) and every element carries a `Pascal` property set, so re-importing keeps identity. - Export waits (bounded) for collectively rendered plants to mount their export geometry; this also fixes plants missing from GLB/USDZ exports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L8CFFhDVTArznXMZn48Xu3 --- packages/editor/package.json | 1 + .../src/components/editor/export-manager.tsx | 32 +- .../sidebar/panels/settings-panel/index.tsx | 4 +- .../src/lib/export-geometry-wait.test.ts | 90 + packages/editor/src/lib/glb-export.ts | 137 +- packages/editor/src/lib/ifc-export.test.ts | 141 ++ packages/editor/src/lib/ifc-export.ts | 160 ++ packages/editor/src/lib/model-export.ts | 4 +- packages/ifc-converter/package.json | 6 +- .../src/export/door-operation.ts | 48 + packages/ifc-converter/src/export/guid.ts | 90 + .../ifc-converter/src/export/ifc-model.ts | 290 ++++ packages/ifc-converter/src/export/index.ts | 10 + .../ifc-converter/src/export/scene-to-ifc.ts | 1482 +++++++++++++++++ packages/ifc-converter/src/export/step.ts | 166 ++ .../ifc-converter/src/export/wall-base.ts | 127 ++ .../tests/export-elements.test.ts | 184 ++ .../ifc-converter/tests/export-review.test.ts | 353 ++++ .../tests/export-roundtrip.test.ts | 375 +++++ packages/ifc-converter/tests/export-scenes.ts | 346 ++++ .../ifc-converter/tests/export-step-check.ts | 136 ++ 21 files changed, 4149 insertions(+), 33 deletions(-) create mode 100644 packages/editor/src/lib/export-geometry-wait.test.ts create mode 100644 packages/editor/src/lib/ifc-export.test.ts create mode 100644 packages/editor/src/lib/ifc-export.ts create mode 100644 packages/ifc-converter/src/export/door-operation.ts create mode 100644 packages/ifc-converter/src/export/guid.ts create mode 100644 packages/ifc-converter/src/export/ifc-model.ts create mode 100644 packages/ifc-converter/src/export/index.ts create mode 100644 packages/ifc-converter/src/export/scene-to-ifc.ts create mode 100644 packages/ifc-converter/src/export/step.ts create mode 100644 packages/ifc-converter/src/export/wall-base.ts create mode 100644 packages/ifc-converter/tests/export-elements.test.ts create mode 100644 packages/ifc-converter/tests/export-review.test.ts create mode 100644 packages/ifc-converter/tests/export-roundtrip.test.ts create mode 100644 packages/ifc-converter/tests/export-scenes.ts create mode 100644 packages/ifc-converter/tests/export-step-check.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index ab82127b13..14c7926847 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -29,6 +29,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/ifc-converter": "^1.0.3", "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 7adb77d8d2..44c0f5fd5b 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -7,7 +7,12 @@ import { useEffect } from 'react' import * as THREE from 'three' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' -import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' +import { + exportSceneToGlb, + prepareSceneForExport, + waitForExportGeometry, +} from '../../lib/glb-export' +import { exportPreparedSceneToIfc, ifcFileName } from '../../lib/ifc-export' import { exportSceneLevelsForPrint } from '../../lib/level-print-export' import type { ModelExport, ModelExportArtifact } from '../../lib/model-export' import { @@ -51,7 +56,10 @@ export function ExportManager() { const setModelExport = useEditor((state) => state.setModelExport) useEffect(() => { - const exportFn: ModelExport = async (format = 'glb', options = {}) => { + const exportFn: ModelExport = async (format = 'glb', requested = {}) => { + // Resolve once: scene preparation prunes hidden nodes by default, and the + // IFC writer must drop the same nodes it rebuilds parametrically. + const options = { ...requested, onlyVisible: requested.onlyVisible ?? true } // Find the scene renderer group by name const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) { @@ -67,7 +75,7 @@ export function ExportManager() { // without it every plant exports as its raycast collider, a white box). useViewer.getState().setExporting(true) try { - await nextFrames() + await waitForExportGeometry(useScene.getState().nodes, options) if (format === 'glb') { const warnings: string[] = [] @@ -100,7 +108,7 @@ export function ExportManager() { try { prepared = prepareSceneForExport(sceneGroup, nodes, { ...options, - requireSynchronousBake: format === 'stl' || format === 'obj', + requireSynchronousBake: format === 'stl' || format === 'obj' || format === 'ifc', }) } finally { restoreLevels() @@ -206,6 +214,22 @@ export function ExportManager() { return finishArtifact(blob, `model_${date}.stl`, options.download) } + if (format === 'ifc') { + const { data, warnings } = exportPreparedSceneToIfc(exportScene, nodes, { + projectName: options.projectName, + onlyVisible: options.onlyVisible, + excludedNodeTypes: options.excludedNodeTypes, + }) + const blob = new Blob([data], { type: 'application/x-step' }) + return finishArtifact( + blob, + ifcFileName(options.projectName, `model_${date}`), + options.download, + undefined, + warnings, + ) + } + if (format === 'obj') { const exporter = new OBJExporter() const result = exporter.parse(exportScene) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 4df4a80c46..216d41380d 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -101,6 +101,7 @@ const MODEL_EXPORT_FORMATS = [ { format: 'usdz', label: 'USDZ' }, { format: 'stl', label: 'STL' }, { format: 'obj', label: 'OBJ' }, + { format: 'ifc', label: 'IFC' }, ] as const type ModelExportFormat = (typeof MODEL_EXPORT_FORMATS)[number]['format'] @@ -688,6 +689,7 @@ export function SettingsPanel({ setModelExportWarning(null) try { const artifact = await modelExport(format, { + projectName, onlyVisible: exportOnlyVisible, excludedNodeTypes, includedPresentationIds: @@ -903,7 +905,7 @@ export function SettingsPanel({ Include in file

Choose which procedural content is baked into model files. GLB and USDZ use the - textured portable path; STL and OBJ remain geometry-only. + textured portable path; STL, OBJ and IFC remain geometry-only.

{exportableNodeTypes.length > 0 ? (
diff --git a/packages/editor/src/lib/export-geometry-wait.test.ts b/packages/editor/src/lib/export-geometry-wait.test.ts new file mode 100644 index 0000000000..b1d8f66399 --- /dev/null +++ b/packages/editor/src/lib/export-geometry-wait.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + DoorNode, + nodeRegistry, + registerNode, + sceneRegistry, +} from '@pascal-app/core' +import * as THREE from 'three' +import { nodesAwaitingExportGeometry, waitForExportGeometry } from './glb-export' + +const node = (id: string, type: string, fields: Record = {}) => + ({ + object: 'node', + id, + type, + parentId: 'level_a', + visible: true, + metadata: {}, + ...fields, + }) as unknown as AnyNode + +const definition = (kind: string, extra: Record = {}) => + ({ + kind, + schemaVersion: 1, + schema: DoorNode, + category: 'utility', + defaults: () => ({}) as never, + capabilities: {}, + bake: 'replace', + ...extra, + }) as AnyNodeDefinition + +let restoreRegistry: () => void + +beforeEach(() => { + restoreRegistry = nodeRegistry._snapshot() + registerNode(definition('test:plant')) + registerNode(definition('test:baked', { bakeGeometry: () => new THREE.Group() })) +}) + +afterEach(() => { + restoreRegistry() + sceneRegistry.clear() +}) + +// Live objects as the editor registers them: still empty, i.e. the export +// geometry has not mounted. +function mount(ids: string[]) { + for (const id of ids) sceneRegistry.nodes.set(id, new THREE.Group()) +} + +describe('waitForExportGeometry', () => { + const nodes: Record = { + level_a: node('level_a', 'level', { parentId: null }), + plant_a: node('plant_a', 'test:plant'), + plant_hidden: node('plant_hidden', 'test:plant', { visible: false }), + baked_a: node('baked_a', 'test:baked'), + } + + test('waits only for included collective kinds that mount their own geometry', () => { + mount(['plant_a', 'plant_hidden', 'baked_a']) + expect(nodesAwaitingExportGeometry(nodes)).toEqual(['plant_a']) + expect(nodesAwaitingExportGeometry(nodes, { onlyVisible: false })).toEqual([ + 'plant_a', + 'plant_hidden', + ]) + expect(nodesAwaitingExportGeometry(nodes, { excludedNodeTypes: ['test:plant'] })).toEqual([]) + sceneRegistry.nodes.get('plant_a')!.add(new THREE.Mesh()) + expect(nodesAwaitingExportGeometry(nodes)).toEqual([]) + }) + + test('times out on its own timer when geometry never mounts and frames never run', async () => { + mount(['plant_a']) + const started = Date.now() + await waitForExportGeometry(nodes, {}, 300) + const elapsed = Date.now() - started + expect(elapsed).toBeGreaterThanOrEqual(280) + expect(elapsed).toBeLessThan(1500) + }) + + test('returns as soon as nothing is pending', async () => { + mount(['plant_a', 'baked_a']) + const started = Date.now() + await waitForExportGeometry(nodes, { excludedNodeTypes: ['test:plant'] }, 5_000) + expect(Date.now() - started).toBeLessThan(1_000) + }) +}) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 577b19f4d8..cd63ee7d6f 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -143,6 +143,115 @@ export function nextFrames(): Promise { }) } +/** + * Export visibility, shared with the clone pruning: a hidden node hides its + * subtree, except a Site, which hides only its own ground (`hidesDescendants`). + */ +export function createExportVisibility(nodes: Record) { + const visibility = new Map() + const isVisible = (id: string, path: Set = new Set()): boolean => { + const cached = visibility.get(id) + if (cached !== undefined) return cached + const node = nodes[id] + if (!node) return true + if (node.visible === false) { + visibility.set(id, false) + return false + } + const parentId = node.parentId + const parent = parentId ? nodes[parentId] : undefined + if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) { + visibility.set(id, true) + return true + } + path.add(id) + const visible = isVisible(parentId, path) + path.delete(id) + visibility.set(id, visible) + return visible + } + return isVisible +} + +export type ExportGeometryScope = Pick + +/** + * Included `bake: 'replace'` nodes whose live object holds no mesh yet. These + * kinds render collectively in the editor and mount their own geometry only + * while `isExporting` is set; kinds with bake hooks are rebuilt after the + * clone instead, and excluded or hidden nodes never reach the clone. + */ +export function nodesAwaitingExportGeometry( + nodes: Record, + scope: ExportGeometryScope = {}, +): string[] { + const sceneState = useScene.getState() + const installedPlugins = sceneState.hasExplicitPluginInstallState + ? sceneState.installedPlugins + : undefined + const excluded = new Set(scope.excludedNodeTypes) + const isVisible = createExportVisibility(nodes) + const typeExcluded = (node: AnyNode): boolean => { + for (let current: AnyNode | undefined = node, guard = 0; current && guard < 64; guard++) { + if (excluded.has(current.type)) return true + current = current.parentId ? nodes[current.parentId] : undefined + } + return false + } + const pending: string[] = [] + for (const [id, object] of sceneRegistry.nodes) { + const node = nodes[id] + if (!node) continue + const definition = nodeRegistry.get(node.type) + if (definition?.bake !== 'replace' || definition.bakeGeometry || definition.bakeGeometryAsync) { + continue + } + if (!isNodeKindEnabled(node.type, installedPlugins) || typeExcluded(node)) continue + if ((scope.onlyVisible ?? true) && !isVisible(id)) continue + let hasMesh = false + object.traverse((child) => { + if ((child as THREE.Mesh).isMesh) hasMesh = true + }) + if (!hasMesh) pending.push(id) + } + return pending +} + +/** Two animation frames, or `ms` if frames have stopped (hidden tab, test runner). */ +function nextFramesOrTimeout(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof requestAnimationFrame !== 'function') return + requestAnimationFrame(() => + requestAnimationFrame(() => { + clearTimeout(timer) + resolve() + }), + ) + }) +} + +/** + * After `setExporting(true)`, let one commit land, then wait until every + * included replace-kind node has mounted its export geometry. Two frames are + * not enough: on a large scene with a busy main thread the proxies' re-render + * lands several frames later, and the clone would silently miss every plant. + * The deadline runs on its own timer, so stalled frames cannot hang an export. + */ +export async function waitForExportGeometry( + nodes: Record, + scope: ExportGeometryScope = {}, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs + await nextFramesOrTimeout(Math.min(500, timeoutMs)) + while (nodesAwaitingExportGeometry(nodes, scope).length > 0) { + const remaining = deadline - Date.now() + if (remaining <= 0) return + await nextFramesOrTimeout(Math.min(50, remaining)) + } +} + type GltfExtrasDef = { extras?: Record } @@ -732,31 +841,7 @@ function pruneHiddenSceneNodes( nodes: Record, registryEntries: readonly RegistryEntry[], ) { - const visibility = new Map() - - const isVisible = (id: string, path: Set): boolean => { - const cached = visibility.get(id) - if (cached !== undefined) return cached - - const node = nodes[id] - if (!node) return true - if (node.visible === false) { - visibility.set(id, false) - return false - } - const parentId = node.parentId - const parent = parentId ? nodes[parentId] : undefined - if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) { - visibility.set(id, true) - return true - } - - path.add(id) - const visible = isVisible(parentId, path) - path.delete(id) - visibility.set(id, visible) - return visible - } + const isVisible = createExportVisibility(nodes) const nodeClones = new Set() for (const [, original] of registryEntries) { @@ -765,7 +850,7 @@ function pruneHiddenSceneNodes( } for (const [id, original] of registryEntries) { - if (isVisible(id, new Set())) continue + if (isVisible(id)) continue const clone = cloneByOriginal.get(original) if (!clone) continue const node = nodes[id] diff --git a/packages/editor/src/lib/ifc-export.test.ts b/packages/editor/src/lib/ifc-export.test.ts new file mode 100644 index 0000000000..33ab44f285 --- /dev/null +++ b/packages/editor/src/lib/ifc-export.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { collectIfcMeshes, exportPreparedSceneToIfc, ifcFileName } from './ifc-export' + +const node = (id: string, type: string, parentId: string | null = null) => + ({ + object: 'node', + id, + type, + parentId, + visible: true, + metadata: {}, + children: [], + }) as unknown as AnyNode + +function identity(object: THREE.Object3D, id: string) { + object.userData = { pascalId: id } + return object +} + +describe('collectIfcMeshes', () => { + const nodes: Record = { + level_a: node('level_a', 'level'), + wall_a: { ...node('wall_a', 'wall', 'level_a'), start: [0, 0], end: [1, 0] } as AnyNode, + item_a: node('item_a', 'item', 'level_a'), + } + + function scene() { + const root = new THREE.Group() + const level = identity(new THREE.Group(), 'level_a') + level.position.y = 3 + root.add(level) + + const wall = identity( + new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()), + 'wall_a', + ) + level.add(wall) + + const item = identity(new THREE.Group(), 'item_a') + item.position.set(2, 0, 0) + level.add(item) + const geometry = new THREE.BoxGeometry(1, 1, 1) + // BoxGeometry has six material groups (one per face). + const red = new THREE.MeshStandardMaterial({ color: new THREE.Color('#ff0000') }) + const glass = new THREE.MeshStandardMaterial({ transparent: true, opacity: 0.25 }) + const materials = [red, red, red, red, glass, glass] + const child = new THREE.Mesh(geometry, materials) + item.add(child) + const hidden = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial()) + hidden.visible = false + item.add(hidden) + return root + } + + test('bakes world transforms per owning node and skips parametric kinds', () => { + const { meshes: parts, renderedNodeIds } = collectIfcMeshes(scene(), nodes) + expect([...parts.keys()]).toEqual(['item_a']) + expect([...renderedNodeIds].sort()).toEqual(['item_a', 'wall_a']) + const itemParts = parts.get('item_a')! + expect(itemParts).toHaveLength(6) + const xs: number[] = [] + const ys: number[] = [] + for (const part of itemParts) { + expect(part.indices!.length % 3).toBe(0) + expect(part.positions.length).toBe(4 * 3) + for (let i = 0; i < part.positions.length; i += 3) { + xs.push(part.positions[i]!) + ys.push(part.positions[i + 1]!) + } + } + expect(Math.min(...xs)).toBeCloseTo(1.5) + expect(Math.max(...xs)).toBeCloseTo(2.5) + expect(Math.min(...ys)).toBeCloseTo(2.5) + expect(Math.max(...ys)).toBeCloseTo(3.5) + const [r, g, b] = itemParts[0]!.color! + expect([r, g, b].map((channel) => Math.round(channel * 1000) / 1000)).toEqual([1, 0, 0]) + expect(itemParts[5]!.opacity).toBe(0.25) + }) + + test('feeds the IFC writer', () => { + const { data, warnings } = exportPreparedSceneToIfc(scene(), nodes, { projectName: 'Demo' }) + expect(data).toContain("FILE_SCHEMA(('IFC4'));") + expect(data).toContain('IFCFURNISHINGELEMENT(') + expect(data).toContain('IFCTRIANGULATEDFACESET(') + expect(warnings).toEqual([]) + }) +}) + +describe('export warnings', () => { + test('flag nodes drawn in the editor that reached the file without geometry', () => { + const nodes: Record = { + level_a: node('level_a', 'level'), + item_ghost: node('item_ghost', 'item', 'level_a'), + marker_a: node('marker_a', 'item', 'level_a'), + } + // Live editor objects: the item draws on the scene layer; the marker only + // on an overlay layer, like the spawn point. + const drawn = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial()) + const overlay = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial()) + overlay.layers.set(5) + sceneRegistry.nodes.set('item_ghost', drawn) + sceneRegistry.nodes.set('marker_a', overlay) + try { + const { warnings } = exportPreparedSceneToIfc(new THREE.Group(), nodes, {}) + expect(warnings).toEqual([ + '1 object has no exportable geometry and was left out of the IFC file.', + ]) + } finally { + sceneRegistry.nodes.delete('item_ghost') + sceneRegistry.nodes.delete('marker_a') + } + }) +}) + +describe('visibility default', () => { + test('a hidden wall is left out when no option is given, as scene preparation does', () => { + const nodes: Record = { + level_a: node('level_a', 'level'), + wall_hidden: { + ...node('wall_hidden', 'wall', 'level_a'), + visible: false, + start: [0, 0], + end: [3, 0], + } as AnyNode, + } + expect(exportPreparedSceneToIfc(new THREE.Group(), nodes, {}).data).not.toContain('IFCWALL(') + expect( + exportPreparedSceneToIfc(new THREE.Group(), nodes, { onlyVisible: false }).data, + ).toContain('IFCWALL(') + }) +}) + +describe('ifcFileName', () => { + test('uses a filesystem-safe project name', () => { + expect(ifcFileName('House: v2/final', 'model')).toBe('House- v2-final.ifc') + expect(ifcFileName(' ', 'model_2026-09-30')).toBe('model_2026-09-30.ifc') + expect(ifcFileName(undefined, 'model')).toBe('model.ifc') + }) +}) diff --git a/packages/editor/src/lib/ifc-export.ts b/packages/editor/src/lib/ifc-export.ts new file mode 100644 index 0000000000..4c5dcb9b1f --- /dev/null +++ b/packages/editor/src/lib/ifc-export.ts @@ -0,0 +1,160 @@ +import { type AnyNode, bakePolicyOf, sceneRegistry } from '@pascal-app/core' +import { buildIfcExport, type IfcMeshPart } from '@pascal-app/ifc-converter/export' +import { SCENE_LAYER } from '@pascal-app/viewer' +import * as THREE from 'three' + +// Kinds the IFC writer always rebuilds parametrically from node data; their +// rendered triangles are never read, so skip copying them. (Slabs are copied: +// pools and legacy zero-thickness slabs export their rendered shape.) +const PARAMETRIC_KINDS = new Set(['site', 'building', 'level', 'wall', 'ceiling', 'zone']) + +function owningNodeId(object: THREE.Object3D): string | null { + for (let current: THREE.Object3D | null = object; current; current = current.parent) { + const id = current.userData?.pascalId + if (typeof id === 'string') return id + } + return null +} + +function materialColor( + material: THREE.Material | undefined, +): Pick { + const color = (material as THREE.MeshStandardMaterial | undefined)?.color + const rgb = color ? color.getRGB(new THREE.Color(), THREE.SRGBColorSpace) : null + return { + color: rgb ? [rgb.r, rgb.g, rgb.b] : undefined, + opacity: material?.transparent ? material.opacity : 1, + } +} + +/** + * World-space triangles per node from a prepared export scene (identity + * stamped, instancing expanded, deformation frozen, reflected winding fixed). + * Each material group becomes one part with only the vertices it uses. + * `renderedNodeIds` lists every node that drew at least one triangle. + */ +export function collectIfcMeshes( + root: THREE.Object3D, + nodes: Record, +): { meshes: Map; renderedNodeIds: Set } { + root.updateMatrixWorld(true) + const parts = new Map() + const renderedNodeIds = new Set() + const vertex = new THREE.Vector3() + + root.traverseVisible((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count < 3) return + const nodeId = owningNodeId(mesh) + const node = nodeId ? nodes[nodeId] : undefined + if (!node) return + renderedNodeIds.add(node.id) + if (PARAMETRIC_KINDS.has(node.type)) return + + const geometry = mesh.geometry + const drawStart = geometry.drawRange.start + const drawEnd = Math.min( + geometry.index ? geometry.index.count : position.count, + drawStart + geometry.drawRange.count, + ) + const indexAt = (i: number) => (geometry.index ? geometry.index.getX(i) : i) + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const groups = + Array.isArray(mesh.material) && geometry.groups.length > 0 + ? geometry.groups + : [{ start: drawStart, count: drawEnd - drawStart, materialIndex: 0 }] + + for (const group of groups) { + const material = materials[group.materialIndex ?? 0] + if (!material || material.visible === false) continue + const start = Math.max(group.start, drawStart) + const end = Math.min(group.start + group.count, drawEnd) + const remap = new Map() + const positions: number[] = [] + const indices: number[] = [] + for (let i = start; i + 2 < end; i += 3) { + for (let corner = 0; corner < 3; corner++) { + const source = indexAt(i + corner) + let target = remap.get(source) + if (target === undefined) { + target = remap.size + remap.set(source, target) + vertex.fromBufferAttribute(position, source).applyMatrix4(mesh.matrixWorld) + positions.push(vertex.x, vertex.y, vertex.z) + } + indices.push(target) + } + } + if (indices.length === 0) continue + const list = parts.get(node.id) ?? [] + list.push({ + positions: new Float32Array(positions), + indices: new Uint32Array(indices), + ...materialColor(material), + }) + parts.set(node.id, list) + } + }) + return { meshes: parts, renderedNodeIds } +} + +/** + * Whether the live editor draws `nodeId`: a visible scene-layer mesh under its + * registered object, or a collectively rendered (`bake: 'replace'`) kind whose + * instances live outside that object. Overlay-only markers (spawn point, + * camera shots) draw nothing here. + */ +function drawnInEditor(nodeId: string, nodes: Record): boolean { + const node = nodes[nodeId] + const live = sceneRegistry.nodes.get(nodeId) + if (!node || !live) return false + if (bakePolicyOf(node.type) === 'replace') return true + let drawn = false + live.traverseVisible((object) => { + const mesh = object as THREE.Mesh + if (drawn || !mesh.isMesh || !object.layers.isEnabled(SCENE_LAYER)) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + const count = mesh.geometry?.getAttribute('position')?.count ?? 0 + if (count >= 3 && materials.some((material) => material?.visible !== false)) drawn = true + }) + return drawn +} + +export function ifcFileName(projectName: string | undefined, fallback: string): string { + const base = projectName?.replace(/[\\/:*?"<>|]+/g, '-').trim() + return `${base || fallback}.ifc` +} + +export function exportPreparedSceneToIfc( + root: THREE.Object3D, + nodes: Record, + options: { + projectName?: string + onlyVisible?: boolean + excludedNodeTypes?: readonly string[] + }, +): { data: string; warnings: string[] } { + const { meshes, renderedNodeIds } = collectIfcMeshes(root, nodes) + const { ifc, summary } = buildIfcExport({ + nodes, + meshes, + projectName: options.projectName, + // Same default as scene preparation, which already pruned hidden meshes. + onlyVisible: options.onlyVisible ?? true, + excludedNodeTypes: options.excludedNodeTypes, + }) + // Report everything that is on screen yet did not reach the file; markers + // that draw nothing (spawn point, camera shots) are skipped silently. + const missing = summary.skipped.filter( + (skip) => renderedNodeIds.has(skip.nodeId) || drawnInEditor(skip.nodeId, nodes), + ).length + const warnings = + missing > 0 + ? [ + `${missing} ${missing === 1 ? 'object has' : 'objects have'} no exportable geometry and ${missing === 1 ? 'was' : 'were'} left out of the IFC file.`, + ] + : [] + return { data: ifc, warnings } +} diff --git a/packages/editor/src/lib/model-export.ts b/packages/editor/src/lib/model-export.ts index 7ddcc980e3..6c06ba5189 100644 --- a/packages/editor/src/lib/model-export.ts +++ b/packages/editor/src/lib/model-export.ts @@ -1,12 +1,14 @@ import type { GlbExportOptions } from './glb-export' -export type ModelExportFormat = 'glb' | 'usdz' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' +export type ModelExportFormat = 'glb' | 'usdz' | 'stl' | 'obj' | 'ifc' | 'print-stl' | 'print-3mf' export type ModelExportOptions = Pick< GlbExportOptions, 'onlyVisible' | 'excludedNodeTypes' | 'includedPresentationIds' > & { download?: boolean + /** Names the IFC project and its file. */ + projectName?: string printScale?: number printScope?: 'whole' | 'levels' printContent?: 'structure' | 'everything' diff --git a/packages/ifc-converter/package.json b/packages/ifc-converter/package.json index d031a72d1c..1f7a92ab4c 100644 --- a/packages/ifc-converter/package.json +++ b/packages/ifc-converter/package.json @@ -10,6 +10,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./export": { + "types": "./dist/export/index.d.ts", + "import": "./dist/export/index.js", + "default": "./dist/export/index.js" } }, "files": [ @@ -24,7 +29,6 @@ }, "dependencies": { "@pascal-app/core": "^1.0.3", - "nanoid": "^5.1.6", "web-ifc": "^0.0.77" }, "devDependencies": { diff --git a/packages/ifc-converter/src/export/door-operation.ts b/packages/ifc-converter/src/export/door-operation.ts new file mode 100644 index 0000000000..ac489e2f1c --- /dev/null +++ b/packages/ifc-converter/src/export/door-operation.ts @@ -0,0 +1,48 @@ +import type { DoorNode } from '@pascal-app/core' + +/** + * Pascal door family → IfcDoorTypeOperationEnum. The inverse of + * `doorStyleFromIfcOperation` (door-semantics.ts), so an exported door + * re-imports as the same family. + */ +export function doorOperationForIfc(door: DoorNode): { + operationType: string + userDefined?: string +} { + const doubleLeaf = (door.leafCount ?? 1) >= 2 + switch (door.doorType) { + case 'double': + case 'french': + return { operationType: 'DOUBLE_DOOR_SINGLE_SWING' } + case 'sliding': + case 'pocket': + case 'barn': + if (doubleLeaf) return { operationType: 'DOUBLE_DOOR_SLIDING' } + return { + operationType: door.slideDirection === 'right' ? 'SLIDING_TO_RIGHT' : 'SLIDING_TO_LEFT', + } + case 'folding': + return { operationType: doubleLeaf ? 'DOUBLE_DOOR_FOLDING' : 'FOLDING_TO_LEFT' } + case 'garage-rollup': + return { operationType: 'ROLLINGUP' } + case 'garage-sectional': + case 'garage-tiltup': + return { operationType: 'USERDEFINED', userDefined: door.doorType } + default: + if (doubleLeaf) return { operationType: 'DOUBLE_DOOR_SINGLE_SWING' } + return { + operationType: door.hingesSide === 'right' ? 'SINGLE_SWING_RIGHT' : 'SINGLE_SWING_LEFT', + } + } +} + +/** Share of the leaf height that is glazed, for Pset_DoorCommon.GlazingAreaFraction. */ +export function doorGlazingFraction(door: DoorNode): number { + const segments = door.segments ?? [] + const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.heightRatio), 0) + const glass = segments + .filter((segment) => segment.type === 'glass') + .reduce((sum, segment) => sum + Math.max(0, segment.heightRatio), 0) + if (total > 0 && glass > 0) return Math.min(1, glass / total) + return door.doorType === 'french' ? 1 : 0 +} diff --git a/packages/ifc-converter/src/export/guid.ts b/packages/ifc-converter/src/export/guid.ts new file mode 100644 index 0000000000..3bdaff5ff6 --- /dev/null +++ b/packages/ifc-converter/src/export/guid.ts @@ -0,0 +1,90 @@ +const IFC_GUID_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$' +const IFC_GUID_PATTERN = /^[0-3][0-9A-Za-z_$]{21}$/ + +export function isIfcGuid(value: unknown): value is string { + return typeof value === 'string' && IFC_GUID_PATTERN.test(value) +} + +// cyrb128: a fast synchronous 128-bit string hash. Web Crypto digests are +// async-only in browsers, and the GUID only needs to be stable and +// collision-resistant across one model, not cryptographically strong. +function hash128(input: string): Uint8Array { + let h1 = 1779033703 + let h2 = 3144134277 + let h3 = 1013904242 + let h4 = 2773480762 + for (let i = 0; i < input.length; i++) { + const k = input.charCodeAt(i) + h1 = h2 ^ Math.imul(h1 ^ k, 597399067) + h2 = h3 ^ Math.imul(h2 ^ k, 2869860233) + h3 = h4 ^ Math.imul(h3 ^ k, 951274213) + h4 = h1 ^ Math.imul(h4 ^ k, 2716044179) + } + h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067) + h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233) + h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213) + h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179) + h1 ^= h2 ^ h3 ^ h4 + h2 ^= h1 + h3 ^= h1 + h4 ^= h1 + const bytes = new Uint8Array(16) + const view = new DataView(bytes.buffer) + view.setUint32(0, h1 >>> 0) + view.setUint32(4, h2 >>> 0) + view.setUint32(8, h3 >>> 0) + view.setUint32(12, h4 >>> 0) + // Mark as an RFC 4122 name-based (v5-style) UUID so tools that validate + // the underlying GUID accept it. + bytes[6] = (bytes[6]! & 0x0f) | 0x50 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + return bytes +} + +function toBase64Digits(value: number, digits: number): string { + let out = '' + for (let i = 0; i < digits; i++) { + out = IFC_GUID_ALPHABET[value % 64] + out + value = Math.floor(value / 64) + } + return out +} + +/** Compress 16 GUID bytes into IFC's 22-character base-64 form. */ +export function compressGuidBytes(bytes: Uint8Array): string { + let out = toBase64Digits(bytes[0]!, 2) + for (let offset = 1; offset < 16; offset += 3) { + out += toBase64Digits( + (bytes[offset]! << 16) | (bytes[offset + 1]! << 8) | bytes[offset + 2]!, + 4, + ) + } + return out +} + +/** Deterministic IFC GlobalId for a seed (a Pascal node id plus an optional role suffix). */ +export function ifcGuidFromSeed(seed: string): string { + return compressGuidBytes(hash128(`pascal:${seed}`)) +} + +/** + * Hands out GlobalIds, reusing an imported element's original GUID the first + * time it is seen. Duplicated nodes copy metadata, so a second claim on the + * same imported GUID falls back to the node-derived one to stay unique. + */ +export class GuidRegistry { + private readonly used = new Set() + + claim(seed: string, preferred?: unknown): string { + if (isIfcGuid(preferred) && !this.used.has(preferred)) { + this.used.add(preferred) + return preferred + } + let guid = ifcGuidFromSeed(seed) + for (let attempt = 1; this.used.has(guid); attempt++) { + guid = ifcGuidFromSeed(`${seed}#${attempt}`) + } + this.used.add(guid) + return guid + } +} diff --git a/packages/ifc-converter/src/export/ifc-model.ts b/packages/ifc-converter/src/export/ifc-model.ts new file mode 100644 index 0000000000..821ff6483a --- /dev/null +++ b/packages/ifc-converter/src/export/ifc-model.ts @@ -0,0 +1,290 @@ +import { GuidRegistry } from './guid' +import { + DERIVED, + enumValue, + formatReal, + int, + raw, + type StepRef, + type StepValue, + StepWriter, + typed, +} from './step' + +export type Vec2 = [number, number] +export type Vec3 = [number, number, number] + +/** A right-handed IFC frame: origin plus a rotation about +Z (plan angle, radians). */ +export interface PlanFrame { + origin: Vec3 + angle: number +} + +export const IDENTITY_FRAME: PlanFrame = { origin: [0, 0, 0], angle: 0 } + +export function frameToLocal(frame: PlanFrame, point: Vec3): Vec3 { + const dx = point[0] - frame.origin[0] + const dy = point[1] - frame.origin[1] + const c = Math.cos(frame.angle) + const s = Math.sin(frame.angle) + return [dx * c + dy * s, -dx * s + dy * c, point[2] - frame.origin[2]] +} + +/** `child` expressed relative to `parent` (both in the same parent space). */ +export function relativeFrame(parent: PlanFrame, child: PlanFrame): PlanFrame { + return { origin: frameToLocal(parent, child.origin), angle: child.angle - parent.angle } +} + +export function signedArea(points: readonly Vec2[]): number { + let area = 0 + for (let i = 0; i < points.length; i++) { + const a = points[i]! + const b = points[(i + 1) % points.length]! + area += a[0] * b[1] - b[0] * a[1] + } + return area / 2 +} + +/** Drop repeated and closing vertices; returns [] for degenerate rings. */ +export function cleanRing(points: readonly Vec2[]): Vec2[] { + const out: Vec2[] = [] + for (const point of points) { + const last = out[out.length - 1] + if (last && Math.hypot(point[0] - last[0], point[1] - last[1]) < 1e-6) continue + out.push(point) + } + while (out.length > 1) { + const first = out[0]! + const last = out[out.length - 1]! + if (Math.hypot(first[0] - last[0], first[1] - last[1]) >= 1e-6) break + out.pop() + } + return out.length >= 3 && Math.abs(signedArea(out)) > 1e-9 ? out : [] +} + +export function orientRing(points: Vec2[], counterClockwise: boolean): Vec2[] { + return signedArea(points) > 0 === counterClockwise ? points : [...points].reverse() +} + +export interface IfcColor { + rgb: Vec3 + opacity: number +} + +export interface TriangleSet { + /** Local coordinates, flat XYZ. */ + positions: ArrayLike + indices: ArrayLike + color?: IfcColor +} + +const MESH_DECIMALS = 5 + +/** + * IFC4 entity builders shared by the scene mapper. Keeps attribute order for + * each entity in one place; the mapper only deals with Pascal semantics. + */ +export class IfcModel { + readonly step = new StepWriter() + readonly guids = new GuidRegistry() + ownerHistory: StepRef | null = null + bodyContext!: StepRef + axisContext!: StepRef + private readonly styleCache = new Map() + + guid(seed: string, preferred?: unknown): string { + return this.guids.claim(seed, preferred) + } + + point2(p: Vec2): StepRef { + return this.step.addShared('IFCCARTESIANPOINT', [p[0], p[1]]) + } + + point3(p: Vec3): StepRef { + return this.step.addShared('IFCCARTESIANPOINT', [p[0], p[1], p[2]]) + } + + direction(d: readonly number[]): StepRef { + return this.step.addShared( + 'IFCDIRECTION', + d.map((v) => Math.round(v * 1e12) / 1e12), + ) + } + + axis2Placement3D(frame: PlanFrame): StepRef { + const identityRotation = Math.abs(frame.angle) < 1e-12 + return this.step.add( + 'IFCAXIS2PLACEMENT3D', + this.point3(frame.origin), + identityRotation ? null : this.direction([0, 0, 1]), + identityRotation ? null : this.direction([Math.cos(frame.angle), Math.sin(frame.angle), 0]), + ) + } + + localPlacement(relativeTo: StepRef | null, frame: PlanFrame): StepRef { + return this.step.add('IFCLOCALPLACEMENT', relativeTo, this.axis2Placement3D(frame)) + } + + polyline2(points: readonly Vec2[], closed: boolean): StepRef { + const refs = points.map((p) => this.point2(p)) + if (closed && refs.length > 0) refs.push(refs[0]!) + return this.step.add('IFCPOLYLINE', refs) + } + + /** Outer ring CCW, holes CW; rings must already be cleaned. */ + polygonProfile(outer: Vec2[], holes: Vec2[][] = []): StepRef { + const outerCurve = this.polyline2(orientRing(outer, true), true) + if (holes.length === 0) { + return this.step.add('IFCARBITRARYCLOSEDPROFILEDEF', enumValue('AREA'), null, outerCurve) + } + return this.step.add( + 'IFCARBITRARYPROFILEDEFWITHVOIDS', + enumValue('AREA'), + null, + outerCurve, + holes.map((hole) => this.polyline2(orientRing(hole, false), true)), + ) + } + + rectangleProfile(xDim: number, yDim: number, center: Vec2 = [0, 0]): StepRef { + const position = this.step.add('IFCAXIS2PLACEMENT2D', this.point2(center), null) + return this.step.add('IFCRECTANGLEPROFILEDEF', enumValue('AREA'), null, position, xDim, yDim) + } + + circleProfile(radius: number): StepRef { + const position = this.step.add('IFCAXIS2PLACEMENT2D', this.point2([0, 0]), null) + return this.step.add('IFCCIRCLEPROFILEDEF', enumValue('AREA'), null, position, radius) + } + + /** Vertical prism of `profile`, from local z = `bottom` upwards by `depth`. */ + extrusion(profile: StepRef, bottom: number, depth: number, color?: IfcColor): StepRef { + const solid = this.step.add( + 'IFCEXTRUDEDAREASOLID', + profile, + this.axis2Placement3D({ origin: [0, 0, bottom], angle: 0 }), + this.direction([0, 0, 1]), + depth, + ) + if (color) this.styleItem(solid, color) + return solid + } + + triangulatedFaceSet(set: TriangleSet): StepRef | null { + const { positions, indices } = set + const vertexCount = Math.floor(positions.length / 3) + if (vertexCount < 3) return null + const triangles: string[] = [] + const indexCount = indices.length > 0 ? indices.length : vertexCount + for (let i = 0; i + 2 < indexCount; i += 3) { + const a = indices.length > 0 ? indices[i]! : i + const b = indices.length > 0 ? indices[i + 1]! : i + 1 + const c = indices.length > 0 ? indices[i + 2]! : i + 2 + if (a === b || b === c || a === c) continue + if (a >= vertexCount || b >= vertexCount || c >= vertexCount) continue + triangles.push(`(${a + 1},${b + 1},${c + 1})`) + } + if (triangles.length === 0) return null + const coords: string[] = [] + for (let i = 0; i < vertexCount; i++) { + coords.push( + `(${formatReal(positions[i * 3]!, MESH_DECIMALS)},${formatReal(positions[i * 3 + 1]!, MESH_DECIMALS)},${formatReal(positions[i * 3 + 2]!, MESH_DECIMALS)})`, + ) + } + const pointList = this.step.add('IFCCARTESIANPOINTLIST3D', raw(`(${coords.join(',')})`)) + const faceSet = this.step.add( + 'IFCTRIANGULATEDFACESET', + pointList, + null, + null, + raw(`(${triangles.join(',')})`), + null, + ) + if (set.color) this.styleItem(faceSet, set.color) + return faceSet + } + + private surfaceStyle(color: IfcColor): StepRef { + const [r, g, b] = color.rgb.map((c) => Math.round(Math.max(0, Math.min(1, c)) * 1000) / 1000) + const transparency = Math.round((1 - Math.max(0, Math.min(1, color.opacity))) * 1000) / 1000 + const key = `${r},${g},${b},${transparency}` + const cached = this.styleCache.get(key) + if (cached) return cached + const rgb = this.step.add('IFCCOLOURRGB', null, r!, g!, b!) + const shading = this.step.add('IFCSURFACESTYLESHADING', rgb, transparency) + const style = this.step.add('IFCSURFACESTYLE', null, enumValue('BOTH'), [shading]) + this.styleCache.set(key, style) + return style + } + + styleItem(item: StepRef, color: IfcColor): void { + this.step.add('IFCSTYLEDITEM', item, [this.surfaceStyle(color)], null) + } + + shape(representations: StepRef[]): StepRef { + return this.step.add('IFCPRODUCTDEFINITIONSHAPE', null, null, representations) + } + + bodyRepresentation(type: 'SweptSolid' | 'Tessellation', items: StepRef[]): StepRef { + return this.step.add('IFCSHAPEREPRESENTATION', this.bodyContext, 'Body', type, items) + } + + axisRepresentation(curve: StepRef): StepRef { + return this.step.add('IFCSHAPEREPRESENTATION', this.axisContext, 'Axis', 'Curve2D', [curve]) + } + + rel(type: string, seed: string, ...args: StepValue[]): StepRef { + return this.step.add(type, this.guid(seed), this.ownerHistory, null, null, ...args) + } + + propertySet( + seed: string, + name: string, + properties: Array<[string, StepValue]>, + relatedObjects: StepRef[], + ): void { + const props = properties + .filter(([, value]) => value !== undefined && value !== null) + .map(([propName, value]) => + this.step.add('IFCPROPERTYSINGLEVALUE', propName, null, value, null), + ) + if (props.length === 0 || relatedObjects.length === 0) return + const pset = this.step.add( + 'IFCPROPERTYSET', + this.guid(`${seed}:pset:${name}`), + this.ownerHistory, + name, + null, + props, + ) + this.rel('IFCRELDEFINESBYPROPERTIES', `${seed}:rel:${name}`, relatedObjects, pset) + } + + lengthQuantities( + seed: string, + name: string, + quantities: Array<[string, number]>, + relatedObjects: StepRef[], + ): void { + const refs = quantities.map(([qName, value]) => + this.step.add('IFCQUANTITYLENGTH', qName, null, null, value, null), + ) + const set = this.step.add( + 'IFCELEMENTQUANTITY', + this.guid(`${seed}:qto:${name}`), + this.ownerHistory, + name, + null, + null, + refs, + ) + this.rel('IFCRELDEFINESBYPROPERTIES', `${seed}:rel:${name}`, relatedObjects, set) + } +} + +export const label = (value: string) => typed('IFCLABEL', value) +export const identifier = (value: string) => typed('IFCIDENTIFIER', value) +export const text = (value: string) => typed('IFCTEXT', value) +export const bool = (value: boolean) => typed('IFCBOOLEAN', value) +export const length = (value: number) => typed('IFCLENGTHMEASURE', value) +export const ratio = (value: number) => typed('IFCPOSITIVERATIOMEASURE', value) +export { DERIVED, enumValue, int } diff --git a/packages/ifc-converter/src/export/index.ts b/packages/ifc-converter/src/export/index.ts new file mode 100644 index 0000000000..bf1f53a23a --- /dev/null +++ b/packages/ifc-converter/src/export/index.ts @@ -0,0 +1,10 @@ +export { ifcGuidFromSeed, isIfcGuid } from './guid' +export { + buildIfcExport, + exportSceneToIfc, + type IfcExportInput, + type IfcExportResult, + type IfcExportSkip, + type IfcExportSummary, + type IfcMeshPart, +} from './scene-to-ifc' diff --git a/packages/ifc-converter/src/export/scene-to-ifc.ts b/packages/ifc-converter/src/export/scene-to-ifc.ts new file mode 100644 index 0000000000..2cd9f6dae4 --- /dev/null +++ b/packages/ifc-converter/src/export/scene-to-ifc.ts @@ -0,0 +1,1482 @@ +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + type CeilingNode, + type ColumnNode, + calculateLevelMiters, + DEFAULT_WALL_THICKNESS, + type DoorNode, + difference, + getLevelElevations, + getOpeningWallCut, + getRenderableSlabPolygon, + getWallBodyCenterOffset, + getWallCurveFrameAt, + getWallCurveLength, + getWallFaceOffsets, + getWallPlaneTop, + getWallPlanFootprint, + hidesDescendants, + type ImportedMeshNode, + intersection, + isCurvedWall, + type LevelNode, + liftedManualSlab, + prepareSlabPolygonContext, + resolveCeilingHeight, + resolveWallTop, + type SlabNode, + sampleWallCenterline, + type UnitNode, + union, + type WallNode, + type WindowNode, + wallSupportForNodes, + type ZoneNode, +} from '@pascal-app/core' +import { doorGlazingFraction, doorOperationForIfc } from './door-operation' +import { + bool, + cleanRing, + DERIVED, + enumValue, + frameToLocal, + IDENTITY_FRAME, + type IfcColor, + IfcModel, + identifier, + int, + label, + type PlanFrame, + ratio, + relativeFrame, + type TriangleSet, + type Vec2, + type Vec3, +} from './ifc-model' +import type { StepRef, StepValue } from './step' +import { wallBaseCells, wallCellBand } from './wall-base' + +/** Triangle geometry for one node, in the rendered scene's world frame (metres, Y-up). */ +export interface IfcMeshPart { + positions: ArrayLike + indices?: ArrayLike + /** sRGB components in 0..1. */ + color?: [number, number, number] + opacity?: number +} + +export interface IfcExportInput { + nodes: Record + /** Rendered geometry keyed by node id; required for everything that is not a native IFC element. */ + meshes?: ReadonlyMap + projectName?: string + author?: string + organization?: string + /** Header and owner-history time; defaults to now. */ + timestamp?: Date + /** Skip nodes hidden in the editor (`visible: false` on the node or an ancestor). */ + onlyVisible?: boolean + /** Node kinds left out entirely, with their descendants. */ + excludedNodeTypes?: readonly string[] +} + +export interface IfcExportSkip { + nodeId: string + type: string + reason: 'no-geometry' | 'degenerate' | 'no-level' +} + +export interface IfcExportSummary { + /** Exported building elements and spaces, by IFC class. */ + elements: Record + skipped: IfcExportSkip[] +} + +export interface IfcExportResult { + ifc: string + summary: IfcExportSummary +} + +const NON_PHYSICAL_TYPES = new Set([ + 'site', + 'building', + 'level', + 'zone', + 'unit', + 'separator', + 'guide', + 'measurement', + 'construction-dimension', + 'spawn', + 'scan', + 'structural-grid', + 'floor-opening', +]) + +const NATIVE_TYPES = new Set(['wall', 'slab', 'ceiling', 'column', 'door', 'window']) + +const FURNISHING_TYPES = new Set(['item', 'procedural-item', 'cabinet', 'cabinet-module', 'shelf']) + +// Planting from the first-party trees plugin. IFC4 has no vegetation enum +// (IFC4.3 adds VEGETATION), so it is a user-defined geographic element. +const VEGETATION_TYPES = new Set(['trees:tree', 'trees:grass', 'trees:flower']) + +type ElementClass = { + entity: string + /** Required by IFC when PredefinedType is USERDEFINED. */ + objectType?: string + /** Attribute values after Representation, given the element's Tag. */ + tail: (tag: string, node: AnyNode | undefined) => StepValue[] +} + +const withPredefined = + (entity: string, predefined = 'NOTDEFINED'): ElementClass['tail'] => + (tag, node) => { + const source = node ? (node.metadata as Record | undefined) : undefined + const sourceType = source?.predefinedType + // Keep an imported element's own IFC enum when it round-trips to the same class. + const value = + typeof sourceType === 'string' && + /^[A-Z_]+$/.test(sourceType) && + sourceType !== 'USERDEFINED' && + String(source?.ifcType ?? '').startsWith(entity) + ? sourceType + : predefined + return [tag, enumValue(value)] + } + +const PROXY: ElementClass = { + entity: 'IFCBUILDINGELEMENTPROXY', + tail: withPredefined('IFCBUILDINGELEMENTPROXY'), +} +const FURNISHING: ElementClass = { entity: 'IFCFURNISHINGELEMENT', tail: (tag) => [tag] } +const VEGETATION: ElementClass = { + entity: 'IFCGEOGRAPHICELEMENT', + tail: (tag) => [tag, enumValue('USERDEFINED')], + objectType: 'Vegetation', +} +const ROOF_SLAB: ElementClass = { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue('ROOF')] } +const STAIR_FLIGHT: ElementClass = { + entity: 'IFCSTAIRFLIGHT', + tail: (tag) => [tag, null, null, null, null, enumValue('NOTDEFINED')], +} + +/** IFC classes an imported mesh may keep on export; others become proxies. */ +const IMPORTED_CLASSES: Record = { + IFCBEAM: { entity: 'IFCBEAM', tail: withPredefined('IFCBEAM') }, + IFCBEAMSTANDARDCASE: { entity: 'IFCBEAM', tail: withPredefined('IFCBEAM') }, + IFCRAILING: { entity: 'IFCRAILING', tail: withPredefined('IFCRAILING') }, + IFCCOVERING: { entity: 'IFCCOVERING', tail: withPredefined('IFCCOVERING') }, + IFCCURTAINWALL: { entity: 'IFCCURTAINWALL', tail: withPredefined('IFCCURTAINWALL') }, + IFCPLATE: { entity: 'IFCPLATE', tail: withPredefined('IFCPLATE') }, + IFCMEMBER: { entity: 'IFCMEMBER', tail: withPredefined('IFCMEMBER') }, + IFCFOOTING: { entity: 'IFCFOOTING', tail: withPredefined('IFCFOOTING') }, + IFCSTAIRFLIGHT: STAIR_FLIGHT, + IFCFURNISHINGELEMENT: FURNISHING, + IFCBUILDINGELEMENTPROXY: PROXY, + IFCWALL: { entity: 'IFCWALL', tail: withPredefined('IFCWALL') }, + IFCWALLSTANDARDCASE: { entity: 'IFCWALL', tail: withPredefined('IFCWALL') }, + IFCSLAB: { entity: 'IFCSLAB', tail: withPredefined('IFCSLAB') }, +} + +function meshClassFor(node: AnyNode): ElementClass { + if (node.type === 'imported-mesh' || node.type === 'block') { + const ifcType = (node.metadata as Record | undefined)?.ifcType + const known = typeof ifcType === 'string' ? IMPORTED_CLASSES[ifcType] : undefined + if (known) return known + } + if (FURNISHING_TYPES.has(node.type)) return FURNISHING + if (VEGETATION_TYPES.has(node.type)) return VEGETATION + switch (node.type as string) { + case 'fence': + return { entity: 'IFCRAILING', tail: (tag) => [tag, enumValue('NOTDEFINED')] } + case 'curtain-wall': + return { entity: 'IFCCURTAINWALL', tail: (tag) => [tag, enumValue('NOTDEFINED')] } + case 'skylight': + return { + entity: 'IFCWINDOW', + tail: (tag) => [tag, null, null, enumValue('SKYLIGHT'), enumValue('NOTDEFINED'), null], + } + case 'column': + return { entity: 'IFCCOLUMN', tail: (tag) => [tag, enumValue('COLUMN')] } + case 'slab': + return { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue('FLOOR')] } + case 'door': + return { + entity: 'IFCDOOR', + tail: (tag, node) => { + const door = node as DoorNode + const operation = doorOperationForIfc(door) + return [ + tag, + door.height, + door.width, + enumValue('DOOR'), + enumValue(operation.operationType), + operation.userDefined ?? null, + ] + }, + } + case 'window': + return { + entity: 'IFCWINDOW', + tail: (tag, node) => [ + tag, + (node as WindowNode).height, + (node as WindowNode).width, + enumValue('WINDOW'), + enumValue('NOTDEFINED'), + null, + ], + } + default: + return PROXY + } +} + +// Pascal is Y-up with plan axes (x, z); IFC is Z-up. Seen from above, Pascal +// +z is IFC -Y — the exact inverse of the importer's `worldToScene`. +const planToIfc = (x: number, z: number): Vec2 => [x, -z] +const localToIfc = (p: readonly number[]): Vec3 => [p[0]!, -p[2]!, p[1]!] + +function hexToRgb(hex: string): [number, number, number] | undefined { + const match = /^#?([0-9a-f]{6})$/i.exec(hex) + if (!match) return undefined + const value = Number.parseInt(match[1]!, 16) + return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255] +} + +function eulerXYZ(rotation: readonly number[]): (p: Vec3) => Vec3 { + const [a, b, c] = rotation + const ca = Math.cos(a ?? 0) + const sa = Math.sin(a ?? 0) + const cb = Math.cos(b ?? 0) + const sb = Math.sin(b ?? 0) + const cc = Math.cos(c ?? 0) + const sc = Math.sin(c ?? 0) + // three.js 'XYZ' order: v' = Rx · Ry · Rz · v + return ([x, y, z]) => { + const x1 = x * cc - y * sc + const y1 = x * sc + y * cc + const x2 = x1 * cb + z * sb + const z2 = -x1 * sb + z * cb + return [x2, y1 * ca - z2 * sa, y1 * sa + z2 * ca] + } +} + +interface SpatialContext { + /** Stable GUID seed for relationships owned by this structure. */ + seed: string + ref: StepRef + placement: StepRef + /** Rendered world point → this structure's local IFC frame. */ + worldToLocal: (p: Vec3) => Vec3 + contained: StepRef[] + levelId?: string +} + +interface BuildingTransform { + position: Vec3 + yaw: number +} + +function buildingWorldToLocal(transform: BuildingTransform, baseY: number): (p: Vec3) => Vec3 { + const c = Math.cos(transform.yaw) + const s = Math.sin(transform.yaw) + const [bx, by, bz] = transform.position + return ([x, y, z]) => { + const qx = x - bx + const qz = z - bz + return localToIfc([c * qx - s * qz, y - by - baseY, s * qx + c * qz]) + } +} + +/** Serialize a Pascal scene graph as an IFC4 STEP file plus an export report. */ +export function buildIfcExport(input: IfcExportInput): IfcExportResult { + const nodes = input.nodes + const meshes = input.meshes ?? new Map() + const model = new IfcModel() + const step = model.step + const skipped: IfcExportSkip[] = [] + const timestamp = input.timestamp ?? new Date() + const projectName = input.projectName?.trim() || 'Pascal project' + const excludedTypes = new Set(input.excludedNodeTypes ?? []) + + const parentOf = (node: AnyNode): AnyNode | undefined => + node.parentId ? nodes[node.parentId] : undefined + + // Core's shared rule: a hidden node hides its subtree, except a Site, which + // only hides its own ground and boundary (`hidesDescendants`). + const hiddenCache = new Map() + const isHidden = (node: AnyNode): boolean => { + const cached = hiddenCache.get(node.id) + if (cached !== undefined) return cached + const parent = parentOf(node) + const result = + node.visible === false || (parent && hidesDescendants(parent) ? isHidden(parent) : false) + hiddenCache.set(node.id, result) + return result + } + const typeExcludedCache = new Map() + const isTypeExcluded = (node: AnyNode): boolean => { + const cached = typeExcludedCache.get(node.id) + if (cached !== undefined) return cached + const parent = parentOf(node) + const result = excludedTypes.has(node.type) || (parent ? isTypeExcluded(parent) : false) + typeExcludedCache.set(node.id, result) + return result + } + const isExcluded = (node: AnyNode) => + isTypeExcluded(node) || (input.onlyVisible === true && isHidden(node)) + + const ancestorOfType = (node: AnyNode, type: T['type']): T | undefined => { + let current: AnyNode | undefined = node + for (let guard = 0; current && guard < 64; guard++) { + if (current.type === type) return current as T + current = parentOf(current) + } + return undefined + } + + const sortedNodes = Object.values(nodes).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const nodesByType = new Map() + for (const node of sortedNodes) pushTo(nodesByType, node.type, node) + const ofType = (type: T['type']) => (nodesByType.get(type) ?? []) as T[] + + // ── Project, units, contexts ────────────────────────────────────────── + // Sites are spatial containers only (their ground is not exported), so a + // hidden Site still anchors the buildings on it. + const sites = ofType('site') + const buildings = ofType('building').filter((building) => !isExcluded(building)) + const levels = ofType('level').filter((level) => !isExcluded(level)) + const projectSeed = sites[0]?.id ?? buildings[0]?.id ?? levels[0]?.id ?? projectName + + // IfcPerson needs an identification or a name (IdentifiablePerson rule). + const person = step.add( + 'IFCPERSON', + 'Pascal user', + input.author?.trim() || null, + null, + null, + null, + null, + null, + null, + ) + const organization = step.add( + 'IFCORGANIZATION', + null, + input.organization?.trim() || 'Pascal', + null, + null, + null, + ) + const personOrg = step.add('IFCPERSONANDORGANIZATION', person, organization, null) + const application = step.add( + 'IFCAPPLICATION', + organization, + '1.0', + 'Pascal Editor', + 'Pascal Editor', + ) + model.ownerHistory = step.add( + 'IFCOWNERHISTORY', + personOrg, + application, + null, + enumValue('NOCHANGE'), + null, + null, + null, + int(Math.floor(timestamp.getTime() / 1000)), + ) + const ownerHistory = model.ownerHistory + + const units = step.add('IFCUNITASSIGNMENT', [ + step.add('IFCSIUNIT', DERIVED, enumValue('LENGTHUNIT'), null, enumValue('METRE')), + step.add('IFCSIUNIT', DERIVED, enumValue('AREAUNIT'), null, enumValue('SQUARE_METRE')), + step.add('IFCSIUNIT', DERIVED, enumValue('VOLUMEUNIT'), null, enumValue('CUBIC_METRE')), + step.add('IFCSIUNIT', DERIVED, enumValue('PLANEANGLEUNIT'), null, enumValue('RADIAN')), + ]) + const modelContext = step.add( + 'IFCGEOMETRICREPRESENTATIONCONTEXT', + null, + 'Model', + int(3), + 1e-5, + model.axis2Placement3D(IDENTITY_FRAME), + model.direction([0, 1]), + ) + model.bodyContext = step.add( + 'IFCGEOMETRICREPRESENTATIONSUBCONTEXT', + 'Body', + 'Model', + DERIVED, + DERIVED, + DERIVED, + DERIVED, + modelContext, + null, + enumValue('MODEL_VIEW'), + null, + ) + model.axisContext = step.add( + 'IFCGEOMETRICREPRESENTATIONSUBCONTEXT', + 'Axis', + 'Model', + DERIVED, + DERIVED, + DERIVED, + DERIVED, + modelContext, + null, + enumValue('GRAPH_VIEW'), + null, + ) + const project = step.add( + 'IFCPROJECT', + model.guid(`project:${projectSeed}`), + ownerHistory, + projectName, + null, + null, + null, + null, + [modelContext], + units, + ) + + const pascalIdentity = (node: AnyNode, refs: StepRef[]) => + model.propertySet( + node.id, + 'Pascal', + [ + ['NodeId', identifier(node.id)], + ['NodeType', label(node.type)], + ], + refs, + ) + + const nodeName = (node: AnyNode, fallback: string) => { + const name = (node as { name?: unknown }).name + return typeof name === 'string' && name.trim() ? name : fallback + } + + const elementCounts: Record = {} + const countElement = (entity: string) => { + elementCounts[entity] = (elementCounts[entity] ?? 0) + 1 + } + + // ── Spatial structure ──────────────────────────────────────────────── + const siteContexts = new Map() + const siteNodes = sites.length > 0 ? sites : [undefined] + for (const site of siteNodes) { + const seed = site?.id ?? `site:${projectSeed}` + const placement = model.localPlacement(null, IDENTITY_FRAME) + const ref = step.add( + 'IFCSITE', + model.guid(seed, site?.metadata?.globalId), + ownerHistory, + site ? nodeName(site, 'Site') : 'Site', + null, + null, + placement, + null, + null, + enumValue('ELEMENT'), + null, + null, + null, + null, + null, + ) + if (site) pascalIdentity(site, [ref]) + siteContexts.set(seed, { + seed, + ref, + placement, + worldToLocal: (p) => localToIfc(p), + contained: [], + }) + } + const defaultSite = siteContexts.values().next().value! + model.rel('IFCRELAGGREGATES', `project-sites:${projectSeed}`, project, [ + ...[...siteContexts.values()].map((context) => context.ref), + ]) + + const elevations = getLevelElevations(nodes as Record) + const buildingContexts = new Map() + const levelsByBuilding = new Map() + for (const level of levels) { + const buildingId = elevations.get(level.id)?.buildingId ?? level.parentId + const key = + buildingId && buildings.some((building) => building.id === buildingId) + ? buildingId + : `building:${projectSeed}` + pushTo(levelsByBuilding, key, level) + } + const buildingEntries: Array<{ key: string; node?: BuildingNode }> = buildings.map( + (building) => ({ key: building.id, node: building }), + ) + if (levelsByBuilding.has(`building:${projectSeed}`) || buildingEntries.length === 0) { + buildingEntries.push({ key: `building:${projectSeed}` }) + } + const buildingsBySite = new Map() + for (const { key, node } of buildingEntries) { + const site = + (node?.parentId ? siteContexts.get(node.parentId) : undefined) ?? + (node ? siteContexts.get(ancestorOfType(node, 'site')?.id ?? '') : undefined) ?? + defaultSite + const transform: BuildingTransform = { + position: node ? [node.position[0], node.position[1], node.position[2]] : [0, 0, 0], + yaw: node ? (node.rotation[1] ?? 0) : 0, + } + const frame: PlanFrame = { origin: localToIfc(transform.position), angle: transform.yaw } + const placement = model.localPlacement(site.placement, frame) + const ref = step.add( + 'IFCBUILDING', + model.guid(key, node?.metadata?.globalId), + ownerHistory, + node ? nodeName(node, 'Building') : 'Building', + null, + null, + placement, + null, + null, + enumValue('ELEMENT'), + null, + null, + null, + ) + if (node) pascalIdentity(node, [ref]) + pushTo(buildingsBySite, site, ref) + buildingContexts.set(key, { + seed: key, + ref, + placement, + transform, + worldToLocal: buildingWorldToLocal(transform, 0), + contained: [], + }) + } + for (const [site, refs] of buildingsBySite) { + model.rel('IFCRELAGGREGATES', `aggregates:${site.seed}`, site.ref, refs) + } + + const storeyContexts = new Map() + const lowestLevelByBuilding = new Map() + for (const [buildingKey, buildingLevels] of levelsByBuilding) { + const building = buildingContexts.get(buildingKey)! + const ordered = [...buildingLevels].sort( + (a, b) => + (elevations.get(a.id)?.baseY ?? 0) - (elevations.get(b.id)?.baseY ?? 0) || + a.level - b.level, + ) + if (ordered[0]) lowestLevelByBuilding.set(buildingKey, ordered[0].id) + const storeyRefs: StepRef[] = [] + for (const level of ordered) { + const elevation = elevations.get(level.id) + const baseY = elevation?.baseY ?? 0 + const placement = model.localPlacement(building.placement, { + origin: [0, 0, baseY], + angle: 0, + }) + const ref = step.add( + 'IFCBUILDINGSTOREY', + model.guid(level.id, level.metadata?.globalId), + ownerHistory, + nodeName(level, `Level ${level.level}`), + null, + null, + placement, + null, + null, + enumValue('ELEMENT'), + baseY, + ) + storeyRefs.push(ref) + pascalIdentity(level, [ref]) + if (elevation) { + model.lengthQuantities( + level.id, + 'Qto_BuildingStoreyBaseQuantities', + [['GrossHeight', elevation.height]], + [ref], + ) + } + storeyContexts.set(level.id, { + seed: level.id, + ref, + placement, + worldToLocal: buildingWorldToLocal(building.transform, baseY), + contained: [], + levelId: level.id, + }) + } + if (storeyRefs.length > 0) { + model.rel('IFCRELAGGREGATES', `aggregates:${buildingKey}`, building.ref, storeyRefs) + } + } + + const contextFor = (node: AnyNode): SpatialContext => { + let current: AnyNode | undefined = parentOf(node) + for (let guard = 0; current && guard < 64; guard++) { + if (current.type === 'level') { + const storey = storeyContexts.get(current.id) + if (storey) return storey + } + if (current.type === 'building') { + const building = buildingContexts.get(current.id) + if (building) return building + } + if (current.type === 'site') { + const site = siteContexts.get(current.id) + if (site) return site + } + current = parentOf(current) + } + return defaultSite + } + + // ── Element helpers ────────────────────────────────────────────────── + const emitElement = ( + cls: ElementClass, + seed: string, + node: AnyNode | undefined, + name: string, + placement: StepRef, + representation: StepRef | null, + ): StepRef => { + const ref = step.add( + cls.entity, + model.guid(seed, node?.metadata?.globalId), + ownerHistory, + name, + null, + cls.objectType ?? null, + placement, + representation, + ...cls.tail(node?.id ?? seed, node), + ) + countElement(cls.entity) + if (node) pascalIdentity(node, [ref]) + return ref + } + + const colorOf = (part: IfcMeshPart): IfcColor | undefined => + part.color ? { rgb: part.color, opacity: part.opacity ?? 1 } : undefined + + /** Mesh parts mapped into `toLocal`'s frame, dropping parts with fewer than three vertices. */ + const localTriangleSets = ( + parts: readonly IfcMeshPart[], + toLocal: (p: Vec3) => Vec3, + ): TriangleSet[] => + parts.flatMap((part) => { + const count = Math.floor(part.positions.length / 3) + if (count < 3) return [] + const positions = new Float64Array(count * 3) + for (let i = 0; i < count; i++) { + const local = toLocal([ + part.positions[i * 3]!, + part.positions[i * 3 + 1]!, + part.positions[i * 3 + 2]!, + ]) + positions[i * 3] = local[0] + positions[i * 3 + 1] = local[1] + positions[i * 3 + 2] = local[2] + } + return [{ positions, indices: part.indices ?? [], color: colorOf(part) }] + }) + + /** Mesh parts for a node: rendered geometry, else an imported mesh's own primitives. */ + const partsFor = (node: AnyNode, context: SpatialContext): TriangleSet[] => { + const rendered = meshes.get(node.id) + if (rendered && rendered.length > 0) return localTriangleSets(rendered, context.worldToLocal) + if (node.type !== 'imported-mesh') return [] + const imported = node as ImportedMeshNode + const rotate = eulerXYZ(imported.rotation ?? [0, 0, 0]) + const [px, py, pz] = imported.position ?? [0, 0, 0] + return localTriangleSets( + imported.primitives.map((primitive) => ({ + positions: primitive.positions, + indices: primitive.indices, + color: hexToRgb(primitive.color), + opacity: primitive.opacity, + })), + (p) => { + const r = rotate(p) + return localToIfc([r[0] + px, r[1] + py, r[2] + pz]) + }, + ) + } + + /** Re-base triangle sets on their footprint centre so each element's origin sits at its geometry. */ + const recentre = (sets: TriangleSet[]): { frame: PlanFrame; sets: TriangleSet[] } => { + let minX = Infinity + let minY = Infinity + let minZ = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const set of sets) { + for (let i = 0; i + 2 < set.positions.length; i += 3) { + minX = Math.min(minX, set.positions[i]!) + maxX = Math.max(maxX, set.positions[i]!) + minY = Math.min(minY, set.positions[i + 1]!) + maxY = Math.max(maxY, set.positions[i + 1]!) + minZ = Math.min(minZ, set.positions[i + 2]!) + } + } + const origin: Vec3 = [(minX + maxX) / 2, (minY + maxY) / 2, minZ] + return { + frame: { origin, angle: 0 }, + sets: sets.map((set) => { + const positions = Float64Array.from(set.positions) + for (let i = 0; i + 2 < positions.length; i += 3) { + positions[i] = positions[i]! - origin[0] + positions[i + 1] = positions[i + 1]! - origin[1] + positions[i + 2] = positions[i + 2]! - origin[2] + } + return { ...set, positions } + }), + } + } + + const tessellatedShape = (sets: TriangleSet[]): StepRef | null => { + const items = sets.flatMap((set) => { + const item = model.triangulatedFaceSet(set) + return item ? [item] : [] + }) + return items.length > 0 ? model.shape([model.bodyRepresentation('Tessellation', items)]) : null + } + + /** Emit a tessellated element placed in `relativeTo`; returns null if it has no triangles. */ + const emitMeshElement = ( + cls: ElementClass, + node: AnyNode, + sets: TriangleSet[], + relativeTo: StepRef, + ): StepRef | null => { + if (sets.length === 0) return null + const { frame, sets: local } = recentre(sets) + const shape = tessellatedShape(local) + if (!shape) return null + return emitElement( + cls, + node.id, + node, + nodeName(node, cls.entity), + model.localPlacement(relativeTo, frame), + shape, + ) + } + + /** + * Plan area of `outer` minus its cutouts, as IFC plan regions. Pascal + * cutouts may cross the boundary or overlap one another; IFC inner curves + * must be enclosed and disjoint, so the area is normalised with booleans. + */ + const areaRegions = ( + outer: readonly (readonly [number, number])[], + holes: readonly (readonly (readonly [number, number])[])[], + ): Array<{ outer: Vec2[]; holes: Vec2[][] }> => { + const toIfc = (ring: readonly (readonly [number, number])[]) => + cleanRing(ring.map(([x, z]) => planToIfc(x, z))) + const cutouts = holes.filter((hole) => hole.length >= 3) + if (cutouts.length === 0) { + const ring = toIfc(outer) + return ring.length > 0 ? [{ outer: ring, holes: [] }] : [] + } + const asRing = (ring: readonly (readonly [number, number])[]) => + ring.map(([x, z]) => [x, z] as [number, number]) + return difference(asRing(outer), union(cutouts.map(asRing))).flatMap((region) => { + const ring = toIfc(region.outer) + if (ring.length === 0) return [] + return [{ outer: ring, holes: region.holes.map(toIfc).filter((hole) => hole.length > 0) }] + }) + } + + const regionSolids = ( + regions: Array<{ outer: Vec2[]; holes: Vec2[][] }>, + bottom: number, + depth: number, + ) => + regions.map((region) => + model.extrusion(model.polygonProfile(region.outer, region.holes), bottom, depth), + ) + + const hostWallIsExternal = (wall: WallNode | undefined) => { + if (!wall) return undefined + const sides = [wall.frontSide, wall.backSide] + if (sides.includes('exterior')) return true + if (sides.every((side) => side === 'interior')) return false + return undefined + } + + const spaceByZoneId = new Map() + const handled = new Set() + + // ── Walls, openings, doors, windows ────────────────────────────────── + const openingsByWall = new Map>() + for (const opening of [...ofType('door'), ...ofType('window')]) { + if (isExcluded(opening)) continue + if (opening.roofSegmentId) continue + const hostId = opening.wallId ?? opening.parentId + const host = hostId ? nodes[hostId] : undefined + if (host?.type !== 'wall' || isExcluded(host)) continue + pushTo(openingsByWall, host.id, opening) + } + + const layerUsages = new Map() + const wallMaterial = step.add('IFCMATERIAL', 'Wall', null, null) + + const wallsByLevel = new Map() + for (const wall of ofType('wall')) + if (wall.parentId) pushTo(wallsByLevel, wall.parentId, wall) + const slabsByLevel = new Map() + for (const slab of ofType('slab')) + if (slab.parentId) pushTo(slabsByLevel, slab.parentId, slab) + + for (const [levelId, storey] of storeyContexts) { + const levelWalls = wallsByLevel.get(levelId) ?? [] + if (levelWalls.length === 0) continue + const miters = calculateLevelMiters(levelWalls) + for (const wall of levelWalls) { + if (isExcluded(wall)) continue + handled.add(wall.id) + const startPlan = planToIfc(wall.start[0], wall.start[1]) + const endPlan = planToIfc(wall.end[0], wall.end[1]) + const chord = Math.hypot(endPlan[0] - startPlan[0], endPlan[1] - startPlan[1]) + const support = wallSupportForNodes(wall, nodes) + const base = support.elevation + const top = resolveWallTop( + wall, + getWallPlaneTop(wall, levelId, nodes as Record), + base, + ) + const cells = wallBaseCells(wall, support, top) + if (chord < 1e-6 || cells.every((cell) => top - cell.bottom <= 1e-4)) { + skipped.push({ nodeId: wall.id, type: wall.type, reason: 'degenerate' }) + continue + } + const frame: PlanFrame = { + origin: [startPlan[0], startPlan[1], base], + angle: Math.atan2(endPlan[1] - startPlan[1], endPlan[0] - startPlan[0]), + } + const toWallLocal = (x: number, z: number): Vec2 => { + const [ix, iy] = planToIfc(x, z) + const local = frameToLocal(frame, [ix, iy, 0]) + return [local[0], local[1]] + } + const axisPoints = isCurvedWall(wall) + ? sampleWallCenterline(wall, 24).map((p) => toWallLocal(p.x, p.y)) + : [[0, 0] as Vec2, [chord, 0] as Vec2] + const thickness = wall.thickness ?? DEFAULT_WALL_THICKNESS + const faces = getWallFaceOffsets(wall) + const planFootprint = getWallPlanFootprint(wall, miters).map((p): [number, number] => [ + p.x, + p.y, + ]) + let footprint = cleanRing(planFootprint.map(([x, z]) => toWallLocal(x, z))) + if (footprint.length === 0) { + footprint = [ + [0, -faces.a], + [chord, -faces.a], + [chord, -faces.b], + [0, -faces.b], + ] + } + // A stepped base (runs on different supports, or faces on different + // floors) becomes one prism per run, each clipped from the footprint. + const solids = + cells.length === 1 + ? [ + model.extrusion( + model.polygonProfile(footprint), + cells[0]!.bottom - base, + top - cells[0]!.bottom, + ), + ] + : cells.flatMap((cell) => + top - cell.bottom <= 1e-4 + ? [] + : intersection(planFootprint, wallCellBand(wall, cell)).flatMap((region) => { + const ring = cleanRing(region.outer.map(([x, z]) => toWallLocal(x, z))) + return ring.length > 0 + ? [ + model.extrusion( + model.polygonProfile(ring), + cell.bottom - base, + top - cell.bottom, + ), + ] + : [] + }), + ) + const shape = model.shape([ + model.axisRepresentation(model.polyline2(axisPoints, false)), + model.bodyRepresentation('SweptSolid', solids), + ]) + const wallPlacement = model.localPlacement(storey.placement, frame) + const wallRef = emitElement( + { entity: 'IFCWALL', tail: (tag) => [tag, enumValue('STANDARD')] }, + wall.id, + wall, + nodeName(wall, 'Wall'), + wallPlacement, + shape, + ) + storey.contained.push(wallRef) + const isExternal = hostWallIsExternal(wall) + model.propertySet( + wall.id, + 'Pset_WallCommon', + [['IsExternal', isExternal === undefined ? null : bool(isExternal)]], + [wallRef], + ) + + // Pascal's body spans [b, a] along its left normal; that normal is + // IFC -Y in the wall frame, so the first layer starts at -a. + const offset = -faces.a + const usageKey = `${thickness.toFixed(6)}|${offset.toFixed(6)}` + let usage = layerUsages.get(usageKey) + if (!usage) { + const layer = step.add( + 'IFCMATERIALLAYER', + wallMaterial, + thickness, + null, + 'Core', + null, + null, + null, + ) + const layerSet = step.add( + 'IFCMATERIALLAYERSET', + [layer], + `Wall ${Math.round(thickness * 1000)} mm`, + null, + ) + usage = { + usage: step.add( + 'IFCMATERIALLAYERSETUSAGE', + layerSet, + enumValue('AXIS2'), + enumValue('POSITIVE'), + offset, + null, + ), + walls: [], + } + layerUsages.set(usageKey, usage) + } + usage.walls.push(wallRef) + + const wallLength = getWallCurveLength(wall) + const bodyOffset = getWallBodyCenterOffset(wall) + for (const opening of openingsByWall.get(wall.id) ?? []) { + handled.add(opening.id) + let cut: ReturnType | null = null + try { + cut = getOpeningWallCut(wall, opening, nodes, support) + } catch { + cut = null + } + const datum = cut?.datum ?? base + const nominalBottom = datum + opening.position[1] - opening.height / 2 + const t = wallLength > 0 ? Math.max(0, Math.min(1, opening.position[0] / wallLength)) : 0 + const curve = getWallCurveFrameAt(wall, t) + const center = planToIfc( + curve.point.x + curve.normal.x * bodyOffset, + curve.point.y + curve.normal.y * bodyOffset, + ) + const openingFrame: PlanFrame = { + origin: [center[0], center[1], nominalBottom], + angle: Math.atan2(-curve.tangent.y, curve.tangent.x), + } + const cutBottom = (cut?.bottom ?? nominalBottom) - nominalBottom + const cutDepth = + (cut?.top ?? nominalBottom + opening.height) - (cut?.bottom ?? nominalBottom) + let openingRef: StepRef | null = null + let openingPlacement: StepRef | null = null + if (cutDepth > 1e-4 && opening.width > 1e-4) { + let profile: StepRef + const band = + isCurvedWall(wall) && cut + ? cleanRing( + cut.band.map(([x, z]) => { + const [ix, iy] = planToIfc(x, z) + const local = frameToLocal(openingFrame, [ix, iy, 0]) + return [local[0], local[1]] as Vec2 + }), + ) + : [] + if (band.length > 0) profile = model.polygonProfile(band) + else profile = model.rectangleProfile(opening.width, thickness + 0.2) + openingPlacement = model.localPlacement(wallPlacement, relativeFrame(frame, openingFrame)) + openingRef = step.add( + 'IFCOPENINGELEMENT', + model.guid(`${opening.id}:opening`), + ownerHistory, + `${nodeName(opening, opening.type === 'door' ? 'Door' : 'Window')} opening`, + null, + null, + openingPlacement, + model.shape([ + model.bodyRepresentation('SweptSolid', [ + model.extrusion(profile, cutBottom, cutDepth), + ]), + ]), + null, + enumValue('OPENING'), + ) + model.rel('IFCRELVOIDSELEMENT', `${opening.id}:voids`, wallRef, openingRef) + } + + const rendered = meshes.get(opening.id) + const bodySets = rendered?.length + ? localTriangleSets(rendered, (p) => frameToLocal(openingFrame, storey.worldToLocal(p))) + : [] + let body = tessellatedShape(bodySets) + if (!body) { + const panel = Math.min(0.05, thickness) + body = model.shape([ + model.bodyRepresentation('SweptSolid', [ + model.extrusion( + model.rectangleProfile(opening.width, panel), + 0, + opening.height, + opening.type === 'window' ? { rgb: [0.62, 0.8, 0.9], opacity: 0.4 } : undefined, + ), + ]), + ]) + } + const elementPlacement = openingPlacement + ? model.localPlacement(openingPlacement, IDENTITY_FRAME) + : model.localPlacement(storey.placement, openingFrame) + const fill = emitElement( + meshClassFor(opening), + opening.id, + opening, + nodeName(opening, opening.type === 'door' ? 'Door' : 'Window'), + elementPlacement, + body, + ) + storey.contained.push(fill) + if (openingRef) { + model.rel('IFCRELFILLSELEMENT', `${opening.id}:fills`, openingRef, fill) + } + const external = isExternal === undefined ? null : bool(isExternal) + if (opening.type === 'door') { + const glazing = doorGlazingFraction(opening as DoorNode) + model.propertySet( + opening.id, + 'Pset_DoorCommon', + [ + ['IsExternal', external], + ['GlazingAreaFraction', glazing > 0 ? ratio(glazing) : null], + ], + [fill], + ) + } else { + model.propertySet(opening.id, 'Pset_WindowCommon', [['IsExternal', external]], [fill]) + } + } + } + } + for (const [key, { usage, walls }] of layerUsages) { + model.rel('IFCRELASSOCIATESMATERIAL', `material:${key}:${projectSeed}`, walls, usage) + } + + // ── Slabs ──────────────────────────────────────────────────────────── + const polygonContexts = new Map>() + const slabPolygonContext = (levelId: string | null | undefined) => { + const key = levelId ?? '' + let context = polygonContexts.get(key) + if (!context) { + context = prepareSlabPolygonContext({ + walls: levelId ? (wallsByLevel.get(levelId) ?? []) : [], + siblingSlabs: levelId ? (slabsByLevel.get(levelId) ?? []) : [], + }) + polygonContexts.set(key, context) + } + return context + } + for (const slab of ofType('slab')) { + if (isExcluded(slab)) continue + const context = contextFor(slab) + handled.add(slab.id) + // Same body the slab renderer builds: construction-lifted manual slabs, + // wall-face-adopted outline. + const body = liftedManualSlab(nodes, slab) + const polygon = getRenderableSlabPolygon(body, slabPolygonContext(slab.parentId)) + const regions = areaRegions(polygon, slab.holes ?? []) + const thickness = body.thickness ?? 0.05 + const top = body.elevation ?? 0.05 + const rendered = partsFor(slab, context) + // Pools are open shells, legacy zero-thickness slabs have no valid + // extrusion, and terrain fill, foundations or platform fill reach below + // the [top − thickness, top] band: all keep their rendered shape. + if ( + slab.recessed || + thickness <= 1e-4 || + regions.length === 0 || + !withinBand(rendered, top - thickness, top) + ) { + const ref = emitMeshElement(meshClassFor(slab), slab, rendered, context.placement) + if (ref) context.contained.push(ref) + else { + skipped.push({ + nodeId: slab.id, + type: slab.type, + reason: slab.recessed ? 'no-geometry' : 'degenerate', + }) + } + continue + } + const levelId = context.levelId + const isGround = + slab.plateRole === 'base' && + levelId !== undefined && + [...lowestLevelByBuilding.values()].includes(levelId) + const ref = emitElement( + { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue(isGround ? 'BASESLAB' : 'FLOOR')] }, + slab.id, + slab, + nodeName(slab, 'Slab'), + model.localPlacement(context.placement, { origin: [0, 0, top], angle: 0 }), + model.shape([ + model.bodyRepresentation('SweptSolid', regionSolids(regions, -thickness, thickness)), + ]), + ) + context.contained.push(ref) + } + + // ── Spaces (rooms) ─────────────────────────────────────────────────── + const ceilings = ofType('ceiling') + const ceilingByZone = new Map() + for (const ceiling of ceilings) { + if (ceiling.zoneId && !ceilingByZone.has(ceiling.zoneId)) + ceilingByZone.set(ceiling.zoneId, ceiling) + } + const plateTopByZone = new Map() + for (const slab of ofType('slab')) { + if (slab.recessed) continue + for (const zoneId of slab.zoneIds ?? []) { + const top = slab.elevation ?? 0 + plateTopByZone.set(zoneId, Math.max(top, plateTopByZone.get(zoneId) ?? -Infinity)) + } + } + const spacesByStorey = new Map() + for (const zone of ofType('zone')) { + if (isExcluded(zone)) continue + const context = contextFor(zone) + if (!context.levelId) { + skipped.push({ nodeId: zone.id, type: zone.type, reason: 'no-level' }) + continue + } + const regions = areaRegions(zone.polygon, zone.holes ?? []) + if (regions.length === 0) { + skipped.push({ nodeId: zone.id, type: zone.type, reason: 'degenerate' }) + continue + } + const plateTop = plateTopByZone.get(zone.id) + const floor = zone.floor?.elevation ?? plateTop ?? 0 + const ceiling = ceilingByZone.get(zone.id) + const clearHeight = ceiling + ? resolveCeilingHeight(ceiling, nodes as Record) - floor + : zone.ceilingHeight + const height = Math.max(0.1, clearHeight) + const roomNumber = zone.roomNumber?.trim() ?? '' + const ref = step.add( + 'IFCSPACE', + model.guid(zone.id, zone.metadata?.globalId), + ownerHistory, + roomNumber || zone.name, + null, + null, + model.localPlacement(context.placement, { origin: [0, 0, floor], angle: 0 }), + model.shape([model.bodyRepresentation('SweptSolid', regionSolids(regions, 0, height))]), + zone.name, + enumValue('ELEMENT'), + enumValue(zone.spaceRole === 'room' ? 'SPACE' : 'NOTDEFINED'), + null, + ) + countElement('IFCSPACE') + pascalIdentity(zone, [ref]) + spaceByZoneId.set(zone.id, ref) + pushTo(spacesByStorey, context, ref) + } + for (const [storey, spaces] of spacesByStorey) { + model.rel('IFCRELAGGREGATES', `spaces:${storey.seed}`, storey.ref, spaces) + } + + // ── Ceilings ───────────────────────────────────────────────────────── + for (const ceiling of ceilings) { + if (isExcluded(ceiling)) continue + handled.add(ceiling.id) + const context = contextFor(ceiling) + const regions = areaRegions(ceiling.polygon, ceiling.holes ?? []) + if (regions.length === 0) { + skipped.push({ nodeId: ceiling.id, type: ceiling.type, reason: 'degenerate' }) + continue + } + const height = resolveCeilingHeight(ceiling, nodes as Record) + const ref = emitElement( + { entity: 'IFCCOVERING', tail: (tag) => [tag, enumValue('CEILING')] }, + ceiling.id, + ceiling, + nodeName(ceiling, 'Ceiling'), + model.localPlacement(context.placement, { origin: [0, 0, height], angle: 0 }), + model.shape([ + // The ceiling surface is the underside; the covering bound keeps a + // 1 cm margin below the next floor, which this thickness fills. + model.bodyRepresentation('SweptSolid', regionSolids(regions, 0, CEILING_THICKNESS)), + ]), + ) + context.contained.push(ref) + const space = ceiling.zoneId ? spaceByZoneId.get(ceiling.zoneId) : undefined + if (space) model.rel('IFCRELCOVERSSPACES', `${ceiling.id}:covers`, space, [ref]) + } + + // ── Columns ────────────────────────────────────────────────────────── + for (const column of ofType('column')) { + if (isExcluded(column)) continue + handled.add(column.id) + const context = contextFor(column) + const sets = partsFor(column, context) + const cls = meshClassFor(column) + if (!isPlainColumn(column) && sets.length > 0) { + const ref = emitMeshElement(cls, column, sets, context.placement) + if (ref) context.contained.push(ref) + continue + } + let baseZ = column.position[1] + if (sets.length > 0) { + baseZ = Infinity + for (const set of sets) { + for (let i = 2; i < set.positions.length; i += 3) { + baseZ = Math.min(baseZ, set.positions[i]!) + } + } + } + const [x, y] = planToIfc(column.position[0], column.position[2]) + const profile = + column.crossSection === 'rectangular' || column.crossSection === 'square' + ? model.rectangleProfile( + column.width, + column.crossSection === 'square' ? column.width : column.depth, + ) + : model.circleProfile(column.radius) + const ref = emitElement( + cls, + column.id, + column, + nodeName(column, 'Column'), + model.localPlacement(context.placement, { + origin: [x, y, baseZ], + angle: column.rotation ?? 0, + }), + model.shape([ + model.bodyRepresentation('SweptSolid', [model.extrusion(profile, 0, column.height)]), + ]), + ) + context.contained.push(ref) + } + + // ── Everything else: tessellated elements ──────────────────────────── + const hasOwnGeometry = (node: AnyNode) => + (meshes.get(node.id)?.length ?? 0) > 0 || + (node.type === 'imported-mesh' && (node as ImportedMeshNode).primitives.length > 0) + const geometryInSubtree = new Set() + for (const node of sortedNodes) { + if (!hasOwnGeometry(node)) continue + let current: AnyNode | undefined = node + for (let guard = 0; current && guard < 64; guard++) { + if (geometryInSubtree.has(current.id)) break + geometryInSubtree.add(current.id) + current = parentOf(current) + } + } + + const partIndex = new Map>() + const aggregateParts = ( + container: AnyNode, + containerClass: ElementClass, + partType: string, + partClass: ElementClass, + ) => { + const context = contextFor(container) + let partsByContainer = partIndex.get(partType) + if (!partsByContainer) { + partsByContainer = new Map() + for (const part of ofType(partType as AnyNode['type'])) { + const owner = ancestorOfType(part, container.type) + if (owner && !isExcluded(part)) pushTo(partsByContainer, owner.id, part) + } + partIndex.set(partType, partsByContainer) + } + const members = [container, ...(partsByContainer.get(container.id) ?? [])] + const parts: StepRef[] = [] + const placement = model.localPlacement(context.placement, IDENTITY_FRAME) + for (const member of members) { + if (member !== container) handled.add(member.id) + const ref = emitMeshElement(partClass, member, partsFor(member, context), placement) + if (ref) parts.push(ref) + } + if (parts.length === 0) { + skipped.push({ nodeId: container.id, type: container.type, reason: 'no-geometry' }) + return + } + const ref = step.add( + containerClass.entity, + model.guid(`${container.id}:assembly`, container.metadata?.globalId), + ownerHistory, + nodeName(container, containerClass.entity), + null, + null, + placement, + null, + ...containerClass.tail(container.id, undefined), + ) + countElement(containerClass.entity) + pascalIdentity(container, [ref]) + model.rel('IFCRELAGGREGATES', `${container.id}:parts`, ref, parts) + context.contained.push(ref) + } + + for (const roof of ofType('roof')) { + if (isExcluded(roof)) continue + handled.add(roof.id) + aggregateParts( + roof, + { entity: 'IFCROOF', tail: (tag) => [tag, enumValue('NOTDEFINED')] }, + 'roof-segment', + ROOF_SLAB, + ) + } + for (const stair of ofType('stair')) { + if (isExcluded(stair)) continue + handled.add(stair.id) + aggregateParts( + stair, + { entity: 'IFCSTAIR', tail: (tag) => [tag, enumValue('NOTDEFINED')] }, + 'stair-segment', + STAIR_FLIGHT, + ) + } + + for (const node of sortedNodes) { + if (handled.has(node.id) || isExcluded(node)) continue + if (NON_PHYSICAL_TYPES.has(node.type)) continue + if (NATIVE_TYPES.has(node.type) && node.type !== 'door' && node.type !== 'window') continue + const context = contextFor(node) + const ref = emitMeshElement( + meshClassFor(node), + node, + partsFor(node, context), + context.placement, + ) + if (ref) { + context.contained.push(ref) + } else if (!geometryInSubtree.has(node.id)) { + skipped.push({ nodeId: node.id, type: node.type, reason: 'no-geometry' }) + } + } + + // ── Units as IfcZone groups ────────────────────────────────────────── + for (const unit of ofType('unit')) { + if (isExcluded(unit)) continue + const members = unit.members.flatMap((zoneId) => { + const space = spaceByZoneId.get(zoneId) + return space ? [space] : [] + }) + if (members.length === 0) continue + const zone = step.add( + 'IFCZONE', + model.guid(unit.id, unit.metadata?.globalId), + ownerHistory, + nodeName(unit, 'Unit'), + null, + unit.kind, + null, + ) + pascalIdentity(unit, [zone]) + step.add( + 'IFCRELASSIGNSTOGROUP', + model.guid(`${unit.id}:members`), + ownerHistory, + null, + null, + members, + null, + zone, + ) + } + + // ── Spatial containment ────────────────────────────────────────────── + const allContexts: SpatialContext[] = [ + ...siteContexts.values(), + ...buildingContexts.values(), + ...storeyContexts.values(), + ] + for (const context of allContexts) { + if (context.contained.length === 0) continue + model.rel( + 'IFCRELCONTAINEDINSPATIALSTRUCTURE', + `contains:${context.seed}`, + context.contained, + context.ref, + ) + } + + const ifc = step.serialize({ + description: 'ViewDefinition [DesignTransferView_V1.0]', + fileName: `${projectName}.ifc`, + timestamp: timestamp.toISOString().replace(/\.\d{3}Z$/, ''), + author: input.author?.trim() || '', + organization: input.organization?.trim() || '', + preprocessor: 'Pascal IFC writer', + originatingSystem: 'Pascal Editor', + schema: 'IFC4', + }) + + return { ifc, summary: { elements: elementCounts, skipped } } +} + +const CEILING_THICKNESS = 0.01 + +/** Whether every vertex lies in [bottom, top] (storey-local Z); true without geometry. */ +function withinBand(sets: readonly TriangleSet[], bottom: number, top: number): boolean { + for (const set of sets) { + for (let i = 2; i < set.positions.length; i += 3) { + const z = set.positions[i]! + if (z < bottom - 0.005 || z > top + 0.005) return false + } + } + return true +} + +function pushTo(map: Map, key: K, value: V) { + const list = map.get(key) + if (list) list.push(value) + else map.set(key, [value]) +} + +/** Columns whose rendered shape is a plain prism; ornate ones export their mesh. */ +function isPlainColumn(column: ColumnNode): boolean { + return ( + column.style === 'plain' && + column.shaftProfile === 'straight' && + column.baseStyle === 'none' && + column.capitalStyle === 'none' && + (column.supportStyle ?? 'vertical') === 'vertical' && + ['round', 'square', 'rectangular'].includes(column.crossSection) + ) +} + +/** Serialize a Pascal scene graph as IFC4 STEP text. */ +export function exportSceneToIfc(input: IfcExportInput): string { + return buildIfcExport(input).ifc +} diff --git a/packages/ifc-converter/src/export/step.ts b/packages/ifc-converter/src/export/step.ts new file mode 100644 index 0000000000..c54283c2fa --- /dev/null +++ b/packages/ifc-converter/src/export/step.ts @@ -0,0 +1,166 @@ +/** + * Minimal ISO-10303-21 (STEP physical file) writer for IFC4. + * + * Entities are serialized the moment they are added, so the file order is + * the insertion order and entity numbers are deterministic for a given + * input. Attribute values are plain TS values; the wrappers below cover the + * STEP-specific literals (enums, integers, typed selects, `$` and `*`). + */ + +export class StepRef { + constructor(readonly id: number) {} +} + +class StepEnum { + constructor(readonly value: string) {} +} + +class StepInt { + constructor(readonly value: number) {} +} + +class StepTyped { + constructor( + readonly type: string, + readonly value: StepValue, + ) {} +} + +class StepRaw { + constructor(readonly text: string) {} +} + +const DERIVED_TOKEN = Symbol('derived') + +export type StepValue = + | null + | undefined + | boolean + | number + | string + | StepRef + | StepEnum + | StepInt + | StepTyped + | StepRaw + | typeof DERIVED_TOKEN + | readonly StepValue[] + +/** `*` — attribute re-declared as derived in a subtype. */ +export const DERIVED: typeof DERIVED_TOKEN = DERIVED_TOKEN +export const enumValue = (value: string) => new StepEnum(value) +export const int = (value: number) => new StepInt(Math.round(value)) +export const typed = (type: string, value: StepValue) => new StepTyped(type, value) +/** Pre-serialized attribute text, for large aggregates built in a tight loop. */ +export const raw = (text: string) => new StepRaw(text) + +export function formatReal(value: number, decimals = 9): string { + if (!Number.isFinite(value)) return '0.' + const scale = 10 ** decimals + let rounded = Math.round(value * scale) / scale + if (Object.is(rounded, -0)) rounded = 0 + let text = String(rounded) + const exponent = text.indexOf('e') + if (exponent >= 0) { + const mantissa = text.slice(0, exponent) + text = `${mantissa.includes('.') ? mantissa : `${mantissa}.`}E${text.slice(exponent + 1)}` + } else if (!text.includes('.')) { + text += '.' + } + return text +} + +/** STEP string literal with ISO 10303-21 escapes for quotes, backslashes and non-ASCII. */ +export function formatString(value: string): string { + let out = "'" + let wide = '' + const flushWide = () => { + if (!wide) return + out += `\\X2\\${wide}\\X0\\` + wide = '' + } + for (const char of value) { + const code = char.codePointAt(0)! + if (code >= 0x20 && code <= 0x7e) { + flushWide() + out += char === "'" ? "''" : char === '\\' ? '\\\\' : char + } else if (code > 0xffff) { + flushWide() + out += `\\X4\\${code.toString(16).toUpperCase().padStart(8, '0')}\\X0\\` + } else { + wide += code.toString(16).toUpperCase().padStart(4, '0') + } + } + flushWide() + return `${out}'` +} + +export function formatValue(value: StepValue): string { + if (value === null || value === undefined) return '$' + if (value === DERIVED_TOKEN) return '*' + if (typeof value === 'boolean') return value ? '.T.' : '.F.' + if (typeof value === 'number') return formatReal(value) + if (typeof value === 'string') return formatString(value) + if (value instanceof StepRef) return `#${value.id}` + if (value instanceof StepEnum) return `.${value.value}.` + if (value instanceof StepInt) return String(value.value) + if (value instanceof StepRaw) return value.text + if (value instanceof StepTyped) return `${value.type}(${formatValue(value.value)})` + return `(${(value as readonly StepValue[]).map(formatValue).join(',')})` +} + +export interface StepHeader { + description: string + fileName: string + timestamp: string + author: string + organization: string + preprocessor: string + originatingSystem: string + schema: string +} + +export class StepWriter { + private readonly lines: string[] = [] + private readonly shared = new Map() + private nextId = 1 + readonly counts = new Map() + + add(type: string, ...args: StepValue[]): StepRef { + const ref = new StepRef(this.nextId++) + this.lines.push(`#${ref.id}=${type}(${args.map(formatValue).join(',')});`) + this.counts.set(type, (this.counts.get(type) ?? 0) + 1) + return ref + } + + /** Reuse one entity for identical simple values (points, directions, styles). */ + addShared(type: string, ...args: StepValue[]): StepRef { + const key = `${type}(${args.map(formatValue).join(',')})` + const existing = this.shared.get(key) + if (existing) return existing + const ref = this.add(type, ...args) + this.shared.set(key, ref) + return ref + } + + serialize(header: StepHeader): string { + const head = [ + 'ISO-10303-21;', + 'HEADER;', + `FILE_DESCRIPTION((${formatString(header.description)}),'2;1');`, + `FILE_NAME(${[ + formatString(header.fileName), + formatString(header.timestamp), + `(${formatString(header.author)})`, + `(${formatString(header.organization)})`, + formatString(header.preprocessor), + formatString(header.originatingSystem), + "''", + ].join(',')});`, + `FILE_SCHEMA((${formatString(header.schema)}));`, + 'ENDSEC;', + 'DATA;', + ] + return `${[...head, ...this.lines, 'ENDSEC;', 'END-ISO-10303-21;'].join('\n')}\n` + } +} diff --git a/packages/ifc-converter/src/export/wall-base.ts b/packages/ifc-converter/src/export/wall-base.ts new file mode 100644 index 0000000000..1903de6ecd --- /dev/null +++ b/packages/ifc-converter/src/export/wall-base.ts @@ -0,0 +1,127 @@ +import { + DEFAULT_WALL_THICKNESS, + getWallBodyCenterOffset, + getWallCurveFrameAt, + isCurvedWall, + resolveWallFaceBottom, + type WallNode, + type wallSupportForNodes, +} from '@pascal-app/core' + +type WallSupport = ReturnType +type Segment = WallSupport['baseSegments'][number] + +/** A run of the wall (normalised arc length) on one side, with its bottom elevation. */ +export interface WallBaseCell { + t0: number + t1: number + /** Which half of the thickness: both, or the `a` / `b` face half. */ + side: 'both' | 'a' | 'b' + bottom: number +} + +/** + * The wall renderer's stepped bottom (`generateExtrudedWall`), as additive + * cells: it extrudes from the lowest exposed support, then cuts away the + * volume below every higher-supported run — per face when the two faces sit + * on different floors. Returned cells cover the whole wall. + */ +export function wallBaseCells(wall: WallNode, support: WallSupport, top: number): WallBaseCell[] { + const slabElevation = support.elevation + const baseElevation = support.baseElevation ?? slabElevation + const baseSegments: readonly Segment[] = support.baseSegments?.length + ? support.baseSegments + : [{ start: 0, end: 1, elevation: baseElevation }] + const faceBase = support.faceDatum + ? { + a: resolveWallFaceBottom(support.faceDatum.a, baseSegments, slabElevation), + b: resolveWallFaceBottom(support.faceDatum.b, baseSegments, slabElevation), + } + : undefined + const useFaceBase = + faceBase !== undefined && + ![faceBase.a, faceBase.b].every( + (segments) => JSON.stringify(segments) === JSON.stringify(baseSegments), + ) + const lowest = useFaceBase + ? Math.min( + ...[...faceBase!.a, ...faceBase!.b].flatMap((segment) => [ + segment.elevation, + segment.endElevation ?? segment.elevation, + ]), + slabElevation, + ) + : Math.min(baseElevation, slabElevation) + + const profiles = useFaceBase + ? [ + ...faceBase!.a.map((segment) => ({ segment, face: 'a' as const })), + ...faceBase!.b.map((segment) => ({ segment, face: 'b' as const })), + ] + : baseSegments.map((segment) => ({ segment, face: undefined })) + const raised = profiles.flatMap(({ segment, face }) => { + const elevation = face + ? Math.min(Math.max(segment.elevation, segment.endElevation ?? segment.elevation), top) + : Math.min(segment.elevation, slabElevation) + const t0 = Math.max(0, Math.min(1, segment.start)) + const t1 = Math.max(0, Math.min(1, segment.end)) + return elevation - lowest > 1e-6 && t1 - t0 > 1e-7 ? [{ t0, t1, face, elevation }] : [] + }) + if (raised.length === 0) return [{ t0: 0, t1: 1, side: 'both', bottom: lowest }] + + const cuts = [...new Set([0, 1, ...raised.flatMap((run) => [run.t0, run.t1])])].sort( + (a, b) => a - b, + ) + const sides = useFaceBase ? (['a', 'b'] as const) : (['both'] as const) + const cells: WallBaseCell[] = [] + for (const side of sides) { + for (let i = 1; i < cuts.length; i++) { + const t0 = cuts[i - 1]! + const t1 = cuts[i]! + if (t1 - t0 <= 1e-9) continue + const mid = (t0 + t1) / 2 + const bottom = Math.max( + lowest, + ...raised + .filter( + (run) => + run.t0 <= mid && run.t1 >= mid && (run.face === undefined || run.face === side), + ) + .map((run) => run.elevation), + ) + const previous = cells[cells.length - 1] + if (previous && previous.side === side && previous.t1 === t0 && previous.bottom === bottom) { + previous.t1 = t1 + } else { + cells.push({ t0, t1, side, bottom }) + } + } + } + return cells +} + +/** + * Plan band (Pascal x/z) covering a cell's run and side, generously past the + * faces and past the wall ends so mitred corners are included — the same + * band the renderer cuts with. + */ +export function wallCellBand(wall: WallNode, cell: WallBaseCell): [number, number][] { + const reach = Math.max((wall.thickness ?? DEFAULT_WALL_THICKNESS) * 2, 0.2) + const center = getWallBodyCenterOffset(wall) + const left = cell.side === 'b' ? center : reach + const right = cell.side === 'a' ? center : -reach + const samples = isCurvedWall(wall) ? Math.max(2, Math.ceil((cell.t1 - cell.t0) * 24)) : 1 + const leftSide: [number, number][] = [] + const rightSide: [number, number][] = [] + for (let index = 0; index <= samples; index++) { + const t = cell.t0 + ((cell.t1 - cell.t0) * index) / samples + const frame = getWallCurveFrameAt(wall, t) + const extension = + index === 0 && cell.t0 <= 1e-7 ? -reach : index === samples && cell.t1 >= 1 - 1e-7 ? reach : 0 + const x = frame.point.x + frame.tangent.x * extension + const z = frame.point.y + frame.tangent.y * extension + leftSide.push([x + frame.normal.x * left, z + frame.normal.y * left]) + rightSide.push([x + frame.normal.x * right, z + frame.normal.y * right]) + } + return [...leftSide, ...rightSide.reverse()] +} diff --git a/packages/ifc-converter/tests/export-elements.test.ts b/packages/ifc-converter/tests/export-elements.test.ts new file mode 100644 index 0000000000..89d3789f47 --- /dev/null +++ b/packages/ifc-converter/tests/export-elements.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type AnyNode, + type ImportedMeshNode, + LevelNode, + type WallNode, + WallNode as WallSchema, +} from '@pascal-app/core' +import * as WebIFC from 'web-ifc' +import { convertIfcToPascal } from '../src' +import { buildIfcExport, ifcGuidFromSeed, isIfcGuid } from '../src/export' +import { groupedElementsScene } from './export-scenes' +import { expectWellFormedStep } from './export-step-check' + +const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` +const EPOCH = new Date(Date.UTC(2026, 0, 1)) +const MM = 3 +const api = new WebIFC.IfcAPI() +const quietLog = console.log + +beforeAll(async () => { + api.SetWasmPath(wasmPath, true) + await api.Init() + console.log = () => {} +}) +afterAll(() => { + console.log = quietLog +}) + +function idsOf(modelID: number, type: number): number[] { + const vector = api.GetLineIDsWithType(modelID, type) + return Array.from({ length: vector.size() }, (_, i) => vector.get(i)) +} + +function withModel(ifc: string, run: (modelID: number) => T): T { + const modelID = api.OpenModel(new TextEncoder().encode(ifc)) + try { + return run(modelID) + } finally { + api.CloseModel(modelID) + } +} + +function worldBounds(mesh: ImportedMeshNode) { + const min = [Infinity, Infinity, Infinity] + const max = [-Infinity, -Infinity, -Infinity] + for (const primitive of mesh.primitives) { + for (let i = 0; i < primitive.positions.length; i += 3) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis]!, primitive.positions[i + axis]!) + max[axis] = Math.max(max[axis]!, primitive.positions[i + axis]!) + } + } + } + return { min, max } +} + +describe('GlobalIds', () => { + test('are deterministic, valid and distinct per seed', () => { + const a = ifcGuidFromSeed('wall_abc') + expect(a).toBe(ifcGuidFromSeed('wall_abc')) + expect(isIfcGuid(a)).toBe(true) + expect(ifcGuidFromSeed('wall_abd')).not.toBe(a) + }) + + test('reuse an imported GUID once, then fall back to the node id', () => { + const imported = '2O2Fr$t4X7Zf8NOew3FLOH' + const level = LevelNode.parse({ id: 'level_g', height: 3 }) + const wall = (id: string) => + WallSchema.parse({ + id, + parentId: level.id, + start: [0, 0], + end: [2, 0], + metadata: { globalId: imported }, + }) + const { ifc } = buildIfcExport({ + nodes: { [level.id]: level, wall_a: wall('wall_a'), wall_b: wall('wall_b') }, + timestamp: EPOCH, + }) + const guids = [...ifc.matchAll(/IFCWALL\('([^']+)'/g)].map((match) => match[1]) + expect(guids).toEqual([imported, ifcGuidFromSeed('wall_b')]) + }) +}) + +describe('Tessellated and grouped elements', () => { + const { nodes, meshes, wall, curved } = groupedElementsScene() + const { ifc, summary } = buildIfcExport({ + nodes, + meshes, + onlyVisible: true, + excludedNodeTypes: ['fence'], + timestamp: EPOCH, + }) + + test('maps kinds to IFC classes and reports what it skipped', () => { + expectWellFormedStep(ifc) + expect(summary.elements).toEqual({ + IFCWALL: 2, + // Two roof parts, the zero-thickness slab and the pool, from their meshes. + IFCSLAB: 4, + IFCSPACE: 2, + IFCROOF: 1, + IFCSTAIRFLIGHT: 1, + IFCSTAIR: 1, + IFCFURNISHINGELEMENT: 1, + IFCGEOGRAPHICELEMENT: 1, + }) + expect(summary.skipped).toEqual([ + { nodeId: 'slab_flat_bare', type: 'slab', reason: 'degenerate' }, + { nodeId: 'item_bare', type: 'item', reason: 'no-geometry' }, + ]) + expect(ifc).toMatch(/IFCGEOGRAPHICELEMENT\('[^']+',#\d+,'Oak',\$,'Vegetation',/) + }) + + test('aggregates roof and stair parts and groups unit spaces', () => { + withModel(ifc, (modelID) => { + const aggregates = idsOf(modelID, WebIFC.IFCRELAGGREGATES).map((id) => + api.GetLine(modelID, id), + ) + const partsOf = (type: number) => { + const [container] = idsOf(modelID, type) + const rel = aggregates.find((candidate) => candidate.RelatingObject.value === container) + return rel?.RelatedObjects.map((ref: { value: number }) => + api.GetLineType(modelID, ref.value), + ) + } + expect(partsOf(WebIFC.IFCROOF)).toEqual([WebIFC.IFCSLAB, WebIFC.IFCSLAB]) + expect(partsOf(WebIFC.IFCSTAIR)).toEqual([WebIFC.IFCSTAIRFLIGHT]) + const roofSlabs = idsOf(modelID, WebIFC.IFCSLAB).map( + (id) => api.GetLine(modelID, id).PredefinedType.value, + ) + expect(roofSlabs.sort()).toEqual(['FLOOR', 'FLOOR', 'ROOF', 'ROOF']) + + const [zone] = idsOf(modelID, WebIFC.IFCZONE) + expect(api.GetLine(modelID, zone!).Name.value).toBe('Flat A') + const [group] = idsOf(modelID, WebIFC.IFCRELASSIGNSTOGROUP) + expect(api.GetLine(modelID, group!).RelatedObjects).toHaveLength(2) + }) + }) + + test('re-imports in the same world position through the rotated building', async () => { + const scene = await convertIfcToPascal(new TextEncoder().encode(ifc), undefined, { + simplify: false, + wasmPath, + }) + const nodesOf = (type: T['type']) => + Object.values(scene.nodes).filter((candidate): candidate is T => candidate.type === type) + + // Building yaw +90° maps local (x, z) to world (x·cos + z·sin, −x·sin + z·cos) + position. + const toWorld = ([x, z]: readonly number[]) => [10 + z!, 5 - x!] + const walls = nodesOf('wall') + const straight = walls.find((candidate) => Math.abs((candidate.height ?? 0) - 2.5) < 1e-6) + expect(straight).toBeDefined() + for (const [actual, expected] of [ + [straight!.start, toWorld(wall.start)], + [straight!.end, toWorld(wall.end)], + ]) { + expect(actual[0]).toBeCloseTo(expected[0]!, MM) + expect(actual[1]).toBeCloseTo(expected[1]!, MM) + } + expect(straight!.thickness).toBeCloseTo(0.2, MM) + expect(straight!.height!).toBeCloseTo(2.5, MM) + + const arc = walls.find((candidate) => candidate !== straight) + expect(arc).toBeDefined() + expect(arc!.start[0]).toBeCloseTo(toWorld(curved.start)[0]!, MM) + expect(arc!.end[1]).toBeCloseTo(toWorld(curved.end)[1]!, MM) + + const sofa = nodesOf('imported-mesh').find((mesh) => mesh.name === 'Sofa') + expect(sofa).toBeDefined() + const bounds = worldBounds(sofa!) + expect(bounds.min[0]!).toBeCloseTo(12, MM) + expect(bounds.max[0]!).toBeCloseTo(14, MM) + // Level-local height on the upper storey. + expect(bounds.min[1]!).toBeCloseTo(0, MM) + expect(bounds.max[1]!).toBeCloseTo(0.8, MM) + expect(bounds.min[2]!).toBeCloseTo(6, MM) + expect(bounds.max[2]!).toBeCloseTo(7, MM) + expect(scene.nodes[sofa!.parentId!]?.type).toBe('level') + }) +}) diff --git a/packages/ifc-converter/tests/export-review.test.ts b/packages/ifc-converter/tests/export-review.test.ts new file mode 100644 index 0000000000..0fd7bdc0ea --- /dev/null +++ b/packages/ifc-converter/tests/export-review.test.ts @@ -0,0 +1,353 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type AnyNode, + BuildingNode, + CeilingNode, + LevelNode, + SiteNode, + SlabNode, + WallNode, + ZoneNode, +} from '@pascal-app/core' +import * as WebIFC from 'web-ifc' +import { buildIfcExport, exportSceneToIfc, type IfcMeshPart } from '../src/export' +import { box, node } from './export-scenes' +import { expectWellFormedStep } from './export-step-check' + +const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` +const EPOCH = new Date(Date.UTC(2026, 0, 1)) +const api = new WebIFC.IfcAPI() + +beforeAll(async () => { + api.SetWasmPath(wasmPath, true) + await api.Init() +}) +afterAll(() => {}) + +type Mesh = { vertices: number[][]; triangles: number[][][] } + +/** World-space triangles of one element as web-ifc tessellates it (IFC Z-up). */ +function elementMesh(modelID: number, expressID: number): Mesh { + const vertices: number[][] = [] + const triangles: number[][][] = [] + const flat = api.GetFlatMesh(modelID, expressID) + for (let g = 0; g < flat.geometries.size(); g++) { + const placed = flat.geometries.get(g) + const m = placed.flatTransformation + const geometry = api.GetGeometry(modelID, placed.geometryExpressID) + const data = api.GetVertexArray(geometry.GetVertexData(), geometry.GetVertexDataSize()) + const index = api.GetIndexArray(geometry.GetIndexData(), geometry.GetIndexDataSize()) + const local: number[][] = [] + for (let v = 0; v + 5 < data.length; v += 6) { + const [x, y, z] = [data[v]!, data[v + 1]!, data[v + 2]!] + // web-ifc meshes are Y-up (X, Z, −Y); map back to IFC (X, Y, Z). + 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]! + local.push([wx, -wz, wy]) + } + vertices.push(...local) + for (let i = 0; i + 2 < index.length; i += 3) { + triangles.push([local[index[i]!]!, local[index[i + 1]!]!, local[index[i + 2]!]!]) + } + } + return { vertices, triangles } +} + +function volume(mesh: Mesh): number { + let sum = 0 + for (const [a, b, c] of mesh.triangles) { + sum += + a![0]! * (b![1]! * c![2]! - b![2]! * c![1]!) - + a![1]! * (b![0]! * c![2]! - b![2]! * c![0]!) + + a![2]! * (b![0]! * c![1]! - b![1]! * c![0]!) + } + return Math.abs(sum / 6) +} + +function withModel(ifc: string, run: (modelID: number) => T): T { + const modelID = api.OpenModel(new TextEncoder().encode(ifc)) + try { + return run(modelID) + } finally { + api.CloseModel(modelID) + } +} + +function idsOf(modelID: number, type: number): number[] { + const vector = api.GetLineIDsWithType(modelID, type) + return Array.from({ length: vector.size() }, (_, i) => vector.get(i)) +} + +const levelNodes = (...children: AnyNode[]): Record => { + const level = LevelNode.parse({ id: 'level_r', height: 3 }) + return Object.fromEntries([ + [level.id, { ...level, children: children.map((child) => child.id) }], + ...children.map((child) => [child.id, child]), + ]) +} + +const square = (size: number): [number, number][] => [ + [0, 0], + [size, 0], + [size, size], + [0, size], +] + +describe('cutouts that cross the boundary or overlap', () => { + // 4 × 4 m with a 1 × 2 m notch across the south edge and two overlapping + // 1 × 1 m holes (union 1.5 m²): 16 − 2 − 1.5 = 12.5 m². + const holes: [number, number][][] = [ + [ + [1, -1], + [2, -1], + [2, 1], + [1, 1], + ], + [ + [2.5, 2], + [3.5, 2], + [3.5, 3], + [2.5, 3], + ], + [ + [3, 2.5], + [3.5, 2.5], + [3.5, 3.5], + [3, 3.5], + ], + ] + const expectedArea = 16 - 1 - 1.25 + const slab = SlabNode.parse({ + id: 'slab_notch', + parentId: 'level_r', + polygon: square(4), + holes, + elevation: 0.2, + thickness: 0.2, + }) + const zone = ZoneNode.parse({ + id: 'zone_notch', + name: 'Notched', + parentId: 'level_r', + polygon: square(4), + holes, + ceilingHeight: 2.5, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_notch', + parentId: 'level_r', + polygon: square(4), + holes, + height: 2.5, + }) + const ifc = exportSceneToIfc({ nodes: levelNodes(slab, zone, ceiling), timestamp: EPOCH }) + + test('emit only enclosed, disjoint voids', () => { + expectWellFormedStep(ifc) + withModel(ifc, (modelID) => { + const [slabId] = idsOf(modelID, WebIFC.IFCSLAB) + expect(volume(elementMesh(modelID, slabId!))).toBeCloseTo(expectedArea * 0.2, 3) + const [spaceId] = idsOf(modelID, WebIFC.IFCSPACE) + expect(volume(elementMesh(modelID, spaceId!))).toBeCloseTo(expectedArea * 2.5, 2) + const [ceilingId] = idsOf(modelID, WebIFC.IFCCOVERING) + expect(volume(elementMesh(modelID, ceilingId!))).toBeCloseTo(expectedArea * 0.01, 4) + }) + }) +}) + +describe('hidden nodes', () => { + const site = SiteNode.parse({ id: 'site_h', visible: false }) + const building = BuildingNode.parse({ id: 'building_h', parentId: site.id }) + const level = LevelNode.parse({ id: 'level_h', parentId: building.id, height: 3 }) + const hiddenLevel = LevelNode.parse({ + id: 'level_hidden', + parentId: building.id, + level: 1, + height: 3, + visible: false, + }) + const wall = WallNode.parse({ id: 'wall_h', parentId: level.id, start: [0, 0], end: [3, 0] }) + const upperWall = WallNode.parse({ + id: 'wall_up', + parentId: hiddenLevel.id, + start: [0, 0], + end: [3, 0], + }) + const nodes: Record = { + [site.id]: { ...site, children: [building.id] } as AnyNode, + [building.id]: { ...building, children: [level.id, hiddenLevel.id] } as AnyNode, + [level.id]: { ...level, children: [wall.id] } as AnyNode, + [hiddenLevel.id]: { ...hiddenLevel, children: [upperWall.id] } as AnyNode, + [wall.id]: wall, + [upperWall.id]: upperWall, + } + + test('a hidden site keeps its buildings; a hidden level drops its contents', () => { + const { ifc, summary } = buildIfcExport({ nodes, onlyVisible: true, timestamp: EPOCH }) + expect(summary.elements.IFCWALL).toBe(1) + expect(ifc).toContain("'wall_h',.STANDARD.") + expect(ifc).not.toContain("'wall_up'") + }) +}) + +describe('stepped wall base', () => { + // Half the wall stands on a 0.40 m deck, half on a 0.05 m floor. + const wall = WallNode.parse({ + id: 'wall_step', + parentId: 'level_r', + start: [0, 0], + end: [4, 0], + thickness: 0.2, + height: 2.5, + }) + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_r', + polygon: [ + [-1, -1], + [2, -1], + [2, 1], + [-1, 1], + ], + elevation: 0.4, + thickness: 0.4, + }) + const floor = SlabNode.parse({ + id: 'slab_floor', + parentId: 'level_r', + polygon: [ + [2, -1], + [5, -1], + [5, 1], + [2, 1], + ], + elevation: 0.05, + thickness: 0.05, + }) + const ifc = exportSceneToIfc({ nodes: levelNodes(wall, deck, floor), timestamp: EPOCH }) + + test('keeps the lower bottom where the support is lower', () => { + withModel(ifc, (modelID) => { + const [wallId] = idsOf(modelID, WebIFC.IFCWALL) + const mesh = elementMesh(modelID, wallId!) + const bottomWhere = (inside: (x: number) => boolean) => + Math.min(...mesh.vertices.filter(([x]) => inside(x!)).map(([, , z]) => z!)) + expect(bottomWhere((x) => x < 1.9)).toBeCloseTo(0.4, 3) + expect(bottomWhere((x) => x > 2.1)).toBeCloseTo(0.05, 3) + expect(Math.max(...mesh.vertices.map(([, , z]) => z!))).toBeCloseTo(2.9, 3) + // 4 m × 0.2 m, 2.5 m tall on the deck half and 2.85 m on the floor half. + expect(volume(mesh)).toBeCloseTo(2 * 0.2 * 2.5 + 2 * 0.2 * 2.85, 3) + }) + }) +}) + +describe('slab body', () => { + const wall = WallNode.parse({ + id: 'wall_edge', + parentId: 'level_r', + start: [0, 0], + end: [4, 0], + thickness: 0.1, + }) + const slab = SlabNode.parse({ + id: 'slab_edge', + parentId: 'level_r', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + elevation: 0.05, + thickness: 0.05, + }) + + test('uses the rendered outline, which adopts the wall face', () => { + const ifc = exportSceneToIfc({ nodes: levelNodes(wall, slab), timestamp: EPOCH }) + withModel(ifc, (modelID) => { + const [slabId] = idsOf(modelID, WebIFC.IFCSLAB) + const ys = elementMesh(modelID, slabId!).vertices.map(([, y]) => y!) + // Pascal z = −0.05 (the wall's outer face) is IFC Y = +0.05. + expect(Math.max(...ys)).toBeCloseTo(0.05, 4) + }) + }) + + test('falls back to the rendered mesh when the body is not a prism', () => { + // A terrain fill reaching 0.6 m below the slab underside. + const fill: IfcMeshPart[] = [box([0, -0.6, 0], [4, 0.05, 3])] + const prism: IfcMeshPart[] = [box([0, 0, 0], [4, 0.05, 3])] + const nodes = levelNodes(slab) + const tessellated = exportSceneToIfc({ + nodes, + meshes: new Map([['slab_edge', fill]]), + timestamp: EPOCH, + }) + expect(tessellated).toMatch(/IFCSLAB\([^;]*\.FLOOR\.\);/) + expect(tessellated).toContain('IFCTRIANGULATEDFACESET(') + const extruded = exportSceneToIfc({ + nodes, + meshes: new Map([['slab_edge', prism]]), + timestamp: EPOCH, + }) + expect(extruded).not.toContain('IFCTRIANGULATEDFACESET(') + expect(extruded).toContain('IFCEXTRUDEDAREASOLID(') + }) +}) + +describe('scaling', () => { + // Rooms, their ceilings (explicit height, so core's resolver stays O(1)) and + // roofs whose segments have no mesh: little output per node, so the timing + // is dominated by the writer's own lookups (room → ceiling, roof → parts). + function scene(rooms: number): Record { + const children: AnyNode[] = [] + const segments: AnyNode[] = [] + for (let i = 0; i < rooms; i++) { + const x = (i % 50) * 5 + const z = Math.floor(i / 50) * 5 + const polygon: [number, number][] = [ + [x, z], + [x + 4, z], + [x + 4, z + 4], + [x, z + 4], + ] + children.push( + ZoneNode.parse({ id: `zone_${i}`, name: `Room ${i}`, parentId: 'level_r', polygon }), + CeilingNode.parse({ + id: `ceiling_${i}`, + parentId: 'level_r', + polygon, + zoneId: `zone_${i}`, + height: 2.6, + }), + node({ id: `roof_${i}`, type: 'roof', parentId: 'level_r' }), + ) + for (const suffix of ['a', 'b']) { + segments.push( + node({ id: `roof-segment_${i}${suffix}`, type: 'roof-segment', parentId: `roof_${i}` }), + ) + } + } + return { ...levelNodes(...children), ...Object.fromEntries(segments.map((n) => [n.id, n])) } + } + const time = (rooms: number) => { + const nodes = scene(rooms) + let best = Infinity + for (let run = 0; run < 3; run++) { + const started = performance.now() + buildIfcExport({ nodes, timestamp: EPOCH }) + best = Math.min(best, performance.now() - started) + } + return best + } + + test('grows linearly with the number of rooms', () => { + time(100) + const small = time(500) + const large = time(2000) + // Per-room scans of every node made 4× the rooms cost ~16×. + expect(large / small).toBeLessThan(6) + }) +}) diff --git a/packages/ifc-converter/tests/export-roundtrip.test.ts b/packages/ifc-converter/tests/export-roundtrip.test.ts new file mode 100644 index 0000000000..23987e7886 --- /dev/null +++ b/packages/ifc-converter/tests/export-roundtrip.test.ts @@ -0,0 +1,375 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + type AnyNode, + type ColumnNode, + type DoorNode, + getLevelElevations, + type SlabNode, + type WallNode, + type WindowNode, + type ZoneNode, +} from '@pascal-app/core' +import * as WebIFC from 'web-ifc' +import { convertIfcToPascal, type PascalSceneGraph } from '../src' +import { buildIfcExport, exportSceneToIfc } from '../src/export' +import { columnScene, roomWithOpenings, twoLevelScene, wallsOn } from './export-scenes' +import { expectWellFormedStep } from './export-step-check' + +const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` +const sampleHouse = fileURLToPath( + new URL('../../../apps/ifc-converter/public/test-ifc-files/10-sample-house.ifc', import.meta.url), +) +const MM = 3 // toBeCloseTo digits: |a - b| < 0.5e-3 +const EPOCH = new Date(Date.UTC(2026, 0, 1)) + +const api = new WebIFC.IfcAPI() +const quietLog = console.log + +beforeAll(async () => { + api.SetWasmPath(wasmPath, true) + await api.Init() + console.log = () => {} +}) +afterAll(() => { + console.log = quietLog +}) + +function openModel(text: string) { + return api.OpenModel(new TextEncoder().encode(text)) +} + +function idsOf(modelID: number, type: number): number[] { + const vector = api.GetLineIDsWithType(modelID, type) + return Array.from({ length: vector.size() }, (_, i) => vector.get(i)) +} + +function containerOf(modelID: number): Map { + const map = new Map() + for (const relId of idsOf(modelID, WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE)) { + const rel = api.GetLine(modelID, relId) + for (const element of rel.RelatedElements) map.set(element.value, rel.RelatingStructure.value) + } + for (const relId of idsOf(modelID, WebIFC.IFCRELAGGREGATES)) { + const rel = api.GetLine(modelID, relId) + for (const part of rel.RelatedObjects) map.set(part.value, rel.RelatingObject.value) + } + return map +} + +function triangleCount(modelID: number, expressID: number): number { + const mesh = api.GetFlatMesh(modelID, expressID) + let triangles = 0 + for (let i = 0; i < mesh.geometries.size(); i++) { + const geometry = api.GetGeometry(modelID, mesh.geometries.get(i).geometryExpressID) + triangles += geometry.GetIndexDataSize() / 3 + } + return triangles +} + +async function reimport(text: string): Promise { + return convertIfcToPascal(new TextEncoder().encode(text), undefined, { + simplify: false, + wasmPath, + }) +} + +function nodesOf(scene: { nodes: Record }, type: T['type']) { + return Object.values(scene.nodes).filter((node): node is T => node.type === type) +} + +function slabThickness(slab: SlabNode): number | undefined { + return ( + (slab as { thickness?: number }).thickness ?? + ((slab.metadata as Record | undefined)?.thickness as number | undefined) + ) +} + +function sameSegment(a: WallNode, b: WallNode) { + const d = (p: readonly number[], q: readonly number[]) => Math.hypot(p[0]! - q[0]!, p[1]! - q[1]!) + return d(a.start, b.start) < 1e-3 && d(a.end, b.end) < 1e-3 +} + +describe('IFC export — room with a door and a window', () => { + const nodes = roomWithOpenings() + const { ifc, summary } = buildIfcExport({ nodes, projectName: 'Room test', timestamp: EPOCH }) + + test('is a well-formed IFC4 file with valid references and unique GlobalIds', () => { + expectWellFormedStep(ifc) + expect(summary.elements).toEqual({ + IFCWALL: 4, + IFCDOOR: 1, + IFCWINDOW: 1, + IFCSLAB: 1, + IFCSPACE: 1, + IFCCOVERING: 1, + }) + expect(summary.skipped).toEqual([]) + }) + + test('is deterministic for the same scene and time', () => { + expect(exportSceneToIfc({ nodes, projectName: 'Room test', timestamp: EPOCH })).toBe(ifc) + }) + + test('web-ifc reads the spatial tree, voids, fills and geometry', () => { + const modelID = openModel(ifc) + try { + expect(api.GetModelSchema(modelID)).toBe('IFC4') + const count = (type: number) => idsOf(modelID, type).length + expect(count(WebIFC.IFCPROJECT)).toBe(1) + expect(count(WebIFC.IFCSITE)).toBe(1) + expect(count(WebIFC.IFCBUILDING)).toBe(1) + expect(count(WebIFC.IFCBUILDINGSTOREY)).toBe(1) + expect(count(WebIFC.IFCWALL)).toBe(4) + expect(count(WebIFC.IFCOPENINGELEMENT)).toBe(2) + expect(count(WebIFC.IFCRELVOIDSELEMENT)).toBe(2) + expect(count(WebIFC.IFCRELFILLSELEMENT)).toBe(2) + expect(count(WebIFC.IFCSPACE)).toBe(1) + + const storey = idsOf(modelID, WebIFC.IFCBUILDINGSTOREY)[0]! + const parents = containerOf(modelID) + for (const type of [ + WebIFC.IFCWALL, + WebIFC.IFCDOOR, + WebIFC.IFCWINDOW, + WebIFC.IFCSLAB, + WebIFC.IFCCOVERING, + WebIFC.IFCSPACE, + ]) { + for (const id of idsOf(modelID, type)) expect(parents.get(id)).toBe(storey) + } + for (const id of idsOf(modelID, WebIFC.IFCOPENINGELEMENT)) { + expect(parents.has(id)).toBe(false) + } + + // Every element tessellates, and the host wall carries its openings. + for (const type of [WebIFC.IFCWALL, WebIFC.IFCDOOR, WebIFC.IFCWINDOW, WebIFC.IFCSLAB]) { + for (const id of idsOf(modelID, type)) expect(triangleCount(modelID, id)).toBeGreaterThan(0) + } + const wallTriangles = idsOf(modelID, WebIFC.IFCWALL).map((id) => triangleCount(modelID, id)) + expect(Math.max(...wallTriangles)).toBeGreaterThan(Math.min(...wallTriangles)) + + const door = api.GetLine(modelID, idsOf(modelID, WebIFC.IFCDOOR)[0]!) + expect(door.OperationType.value).toBe('SINGLE_SWING_RIGHT') + expect(door.OverallWidth.value).toBeCloseTo(0.9, MM) + expect(door.OverallHeight.value).toBeCloseTo(2.1, MM) + expect(door.Tag.value).toBe('door_front') + } finally { + api.CloseModel(modelID) + } + }) + + test('re-imports with the same walls, openings, slab and room', async () => { + const scene = await reimport(ifc) + const originals = wallsOn(nodes, 'level_ground') + const walls = nodesOf(scene, 'wall') + expect(walls).toHaveLength(4) + for (const original of originals) { + const wall = walls.find((candidate) => sameSegment(candidate, original)) + expect(wall).toBeDefined() + expect(wall!.thickness).toBeCloseTo(original.thickness ?? 0.1, MM) + // Plane-bound wall on the 5 cm floor plate of a 2.8 m storey. + expect(wall!.height!).toBeCloseTo(2.75, MM) + } + + const [door] = nodesOf(scene, 'door') + expect(door).toBeDefined() + expect(door!.position[0]).toBeCloseTo(1.5, MM) + expect(door!.position[1]).toBeCloseTo(1.05, MM) + expect(door!.width).toBeCloseTo(0.9, MM) + expect(door!.height).toBeCloseTo(2.1, MM) + expect(door!.hingesSide).toBe('right') + const [window] = nodesOf(scene, 'window') + expect(window).toBeDefined() + expect(window!.position[0]).toBeCloseTo(3.5, MM) + expect(window!.position[1]).toBeCloseTo(1.5, MM) + expect(window!.width).toBeCloseTo(1.2, MM) + expect(window!.height).toBeCloseTo(1.2, MM) + const host = walls.find((wall) => wall.children.includes(door!.id as never)) + expect(host?.children).toContain(window!.id) + + const [slab] = nodesOf(scene, 'slab') + expect(slab).toBeDefined() + expect(slab!.elevation).toBeCloseTo(0.05, MM) + expect(slabThickness(slab!)).toBeCloseTo(0.05, MM) + + const [space] = nodesOf(scene, 'zone') + expect(space?.name).toBe('Living room') + expect(space?.roomNumber).toBe('R01') + const properties = (space?.metadata as { properties?: Record> }) + ?.properties + expect(properties?.Pascal?.NodeType).toBe('zone') + }) +}) + +describe('IFC export — two storeys with plates and ceilings', () => { + const nodes = twoLevelScene() + const { ifc, summary } = buildIfcExport({ nodes, timestamp: EPOCH }) + + test('exports one storey per level at the stacked elevation', () => { + expectWellFormedStep(ifc) + expect(summary.elements.IFCBUILDINGSTOREY).toBeUndefined() + expect(summary.elements.IFCWALL).toBe(8) + expect(summary.elements.IFCSLAB).toBe(2) + expect(summary.elements.IFCCOVERING).toBe(2) + expect(summary.elements.IFCSPACE).toBe(2) + const modelID = openModel(ifc) + try { + const storeys = idsOf(modelID, WebIFC.IFCBUILDINGSTOREY).map((id) => api.GetLine(modelID, id)) + expect(storeys.map((storey) => storey.Name.value)).toEqual(['Ground', 'Upper']) + expect(storeys.map((storey) => storey.Elevation.value)).toEqual([0, 2.8]) + expect(idsOf(modelID, WebIFC.IFCRELCOVERSSPACES)).toHaveLength(2) + const slabTypes = idsOf(modelID, WebIFC.IFCSLAB).map( + (id) => api.GetLine(modelID, id).PredefinedType.value, + ) + expect(slabTypes.sort()).toEqual(['BASESLAB', 'FLOOR']) + } finally { + api.CloseModel(modelID) + } + }) + + test('re-imports storey heights, slab tops and space names', async () => { + const scene = await reimport(ifc) + const levels = nodesOf(scene, 'level').sort((a, b) => a.level - b.level) + expect(levels.map((level) => level.name)).toEqual(['Ground', 'Upper']) + const reElevations = getLevelElevations(scene.nodes) + expect(reElevations.get(levels[0]!.id)!.baseY).toBeCloseTo(0, MM) + expect(reElevations.get(levels[1]!.id)!.baseY).toBeCloseTo(2.8, MM) + expect(levels[0]!.height!).toBeCloseTo(2.8, MM) + // The top storey has no storey above it to measure against, so its height + // travels as a standard base quantity. + const topQuantities = ( + levels[1]!.metadata as { properties?: Record> } + ).properties?.Qto_BuildingStoreyBaseQuantities + expect(topQuantities?.GrossHeight as number).toBeCloseTo(3.1, MM) + + const originalSlabs = nodesOf({ nodes }, 'slab') + const slabs = nodesOf(scene, 'slab') + expect(slabs).toHaveLength(originalSlabs.length) + for (const original of originalSlabs) { + const originalLevel = nodes[original.parentId!]! + const slab = slabs.find( + (candidate) => scene.nodes[candidate.parentId!]?.name === originalLevel.name, + ) + expect(slab).toBeDefined() + expect(slab!.elevation).toBeCloseTo(original.elevation, MM) + expect(slabThickness(slab!)).toBeCloseTo(original.thickness, MM) + } + + const spaces = nodesOf(scene, 'zone') + expect(spaces.map((space) => space.name).sort()).toEqual(['Bedroom', 'Kitchen']) + expect(spaces.find((space) => space.name === 'Bedroom')?.roomNumber).toBe('B1') + + for (const levelId of ['level_l0', 'level_l1']) { + for (const original of wallsOn(nodes, levelId)) { + const wall = nodesOf(scene, 'wall').find((candidate) => + sameSegment(candidate, original), + ) + expect(wall).toBeDefined() + } + } + }) +}) + +describe('IFC export — columns', () => { + const nodes = columnScene() + const ifc = exportSceneToIfc({ nodes, timestamp: EPOCH }) + + test('exports parametric IfcColumn extrusions', () => { + const entities = expectWellFormedStep(ifc) + const types = [...entities.values()].map((entity) => entity.type) + expect(types.filter((type) => type === 'IFCCOLUMN')).toHaveLength(2) + expect(types).toContain('IFCRECTANGLEPROFILEDEF') + expect(types).toContain('IFCCIRCLEPROFILEDEF') + }) + + test('re-imports position, section and height', async () => { + const scene = await reimport(ifc) + const columns = nodesOf(scene, 'column') + expect(columns).toHaveLength(2) + const rect = columns.find((column) => column.name === 'Rect column')! + expect(rect.crossSection).toBe('rectangular') + expect(rect.position[0]).toBeCloseTo(2, MM) + expect(rect.position[1]).toBeCloseTo(0, MM) + expect(rect.position[2]).toBeCloseTo(1, MM) + expect(rect.width).toBeCloseTo(0.3, MM) + expect(rect.depth).toBeCloseTo(0.5, MM) + expect(rect.height).toBeCloseTo(2.7, MM) + const round = columns.find((column) => column.name === 'Round column')! + expect(round.crossSection).toBe('round') + expect(round.position[0]).toBeCloseTo(-1.5, MM) + expect(round.position[2]).toBeCloseTo(3, MM) + expect(round.radius).toBeCloseTo(0.2, MM) + expect(round.height).toBeCloseTo(3, MM) + }) +}) + +describe('IFC export — sample house round trip', () => { + let first: PascalSceneGraph + let second: PascalSceneGraph + let ifc: string + + beforeAll(async () => { + first = await convertIfcToPascal(await readFile(sampleHouse), undefined, { + simplify: false, + wasmPath, + }) + ifc = exportSceneToIfc({ nodes: first.nodes, projectName: 'Sample house', timestamp: EPOCH }) + second = await reimport(ifc) + }) + + test('the export is well formed', () => { + expectWellFormedStep(ifc) + }) + + test('keeps wall, slab, opening and space counts', () => { + for (const type of ['level', 'wall', 'slab', 'door', 'window', 'zone'] as const) { + expect({ type, count: nodesOf(second, type).length }).toEqual({ + type, + count: nodesOf(first, type).length, + }) + } + expect(nodesOf(second, 'imported-mesh').length).toBe(nodesOf(first, 'imported-mesh').length) + }) + + test('keeps wall and slab geometry within 1 mm', () => { + const walls = nodesOf(second, 'wall') + for (const original of nodesOf(first, 'wall')) { + const wall = walls.find((candidate) => sameSegment(candidate, original)) + expect(wall).toBeDefined() + expect(wall!.thickness).toBeCloseTo(original.thickness!, MM) + expect(wall!.height!).toBeCloseTo(original.height!, MM) + } + const slabs = nodesOf(second, 'slab') + for (const original of nodesOf(first, 'slab')) { + const slab = slabs.find((candidate) => candidate.name === original.name) + expect(slab).toBeDefined() + expect(slab!.elevation).toBeCloseTo(original.elevation, MM) + expect(slabThickness(slab!)).toBeCloseTo(slabThickness(original)!, MM) + expect(slab!.polygon.length).toBe(original.polygon.length) + } + const spaces = nodesOf(second, 'zone') + for (const original of nodesOf(first, 'zone')) { + const space = spaces.find((candidate) => candidate.name === original.name) + expect(space).toBeDefined() + expect(space!.roomNumber).toBe(original.roomNumber) + } + }) + + test('re-export reuses the original GlobalIds', () => { + const original = new Set( + Object.values(first.nodes).flatMap((node) => { + const guid = (node.metadata as Record | undefined)?.globalId + return typeof guid === 'string' ? [guid] : [] + }), + ) + const secondGuids = Object.values(second.nodes).flatMap((node) => { + const guid = (node.metadata as Record | undefined)?.globalId + return typeof guid === 'string' ? [guid] : [] + }) + const reused = secondGuids.filter((guid) => original.has(guid)) + expect(reused.length).toBeGreaterThan(secondGuids.length * 0.9) + }) +}) diff --git a/packages/ifc-converter/tests/export-scenes.ts b/packages/ifc-converter/tests/export-scenes.ts new file mode 100644 index 0000000000..a935df3fd9 --- /dev/null +++ b/packages/ifc-converter/tests/export-scenes.ts @@ -0,0 +1,346 @@ +import { + type AnyNode, + BuildingNode, + ColumnNode, + createZone, + DoorNode, + LevelNode, + reconcileLevelStructure, + SiteNode, + type StructureNodes, + structureChangeBatch, + WallNode, + WindowNode, +} from '@pascal-app/core' +import type { IfcMeshPart } from '../src/export' + +type Nodes = Record + +function applyBatch(nodes: Nodes, batch: ReturnType): Nodes { + const next: Nodes = { ...nodes } + for (const id of batch.delete) delete next[id] + for (const { node, parentId } of batch.create) { + next[node.id] = { ...node, parentId: parentId ?? node.parentId } as AnyNode + } + for (const { id, data } of batch.update) { + if (next[id]) next[id] = { ...next[id], ...data } as AnyNode + } + return next +} + +let counter = 0 +const mintId = (kind: string) => `${kind}_x${++counter}` + +/** An enclosed room through the real structure commands: walls, zone, floor plate, ceiling. */ +export function addRoom( + nodes: Nodes, + levelId: string, + polygon: [number, number][], + name: string, + roomNumber?: string, +): Nodes { + const plan = createZone(nodes as StructureNodes, { + levelId, + polygon, + enclose: true, + mintId, + name, + }) + if (plan.conflicts) throw new Error(`createZone conflicts: ${JSON.stringify(plan.conflicts)}`) + let next = applyBatch(nodes, structureChangeBatch(plan.changes)) + const derived = reconcileLevelStructure({ + levelId, + nodes: next as StructureNodes, + previousNodes: nodes as StructureNodes, + mintId, + }) + next = applyBatch(next, structureChangeBatch(derived.patches)) + if (roomNumber && plan.zoneId) { + next[plan.zoneId] = { ...next[plan.zoneId]!, roomNumber } as AnyNode + } + return next +} + +export function wallsOn(nodes: Nodes, levelId: string) { + return Object.values(nodes).filter( + (node) => node.type === 'wall' && node.parentId === levelId, + ) as Extract[] +} + +/** 5 x 4 m room, 2.8 m storey, a door and a window in the wall along +x. */ +export function roomWithOpenings(): Nodes { + const level = LevelNode.parse({ id: 'level_ground', name: 'Ground floor', height: 2.8 }) + let nodes = addRoom( + { [level.id]: level }, + level.id, + [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + 'Living room', + 'R01', + ) + const host = wallsOn(nodes, level.id).find((wall) => wall.start[1] === 0 && wall.end[1] === 0)! + const door = DoorNode.parse({ + id: 'door_front', + name: 'Front door', + parentId: host.id, + wallId: host.id, + position: [1.5, 1.05, 0], + width: 0.9, + height: 2.1, + doorType: 'hinged', + hingesSide: 'right', + }) + const window = WindowNode.parse({ + id: 'window_south', + name: 'South window', + parentId: host.id, + wallId: host.id, + position: [3.5, 1.5, 0], + width: 1.2, + height: 1.2, + }) + nodes = { ...nodes, [door.id]: door, [window.id]: window } + return nodes +} + +/** Two stacked storeys (2.8 m and 3.1 m), each an enclosed room with plate and ceiling. */ +export function twoLevelScene(): Nodes { + const ground = LevelNode.parse({ id: 'level_l0', name: 'Ground', level: 0, height: 2.8 }) + const upper = LevelNode.parse({ id: 'level_l1', name: 'Upper', level: 1, height: 3.1 }) + let nodes: Nodes = { [ground.id]: ground, [upper.id]: upper } + nodes = addRoom( + nodes, + ground.id, + [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ], + 'Kitchen', + ) + nodes = addRoom( + nodes, + upper.id, + [ + [0, 0], + [6, 0], + [6, 5], + [0, 5], + ], + 'Bedroom', + 'B1', + ) + return nodes +} + +export function columnScene(): Nodes { + const level = LevelNode.parse({ id: 'level_col', name: 'Columns', height: 3 }) + const plain = { + style: 'plain', + baseStyle: 'none', + capitalStyle: 'none', + shaftProfile: 'straight', + baseHeight: 0, + capitalHeight: 0, + } as const + const square = ColumnNode.parse({ + id: 'column_rect', + name: 'Rect column', + parentId: level.id, + position: [2, 0, 1], + crossSection: 'rectangular', + width: 0.3, + depth: 0.5, + height: 2.7, + ...plain, + }) + const round = ColumnNode.parse({ + id: 'column_round', + name: 'Round column', + parentId: level.id, + position: [-1.5, 0, 3], + crossSection: 'round', + radius: 0.2, + height: 3, + ...plain, + }) + return { + [level.id]: { ...level, children: [square.id, round.id] } as AnyNode, + [square.id]: square, + [round.id]: round, + } +} + +/** Axis-aligned box as a mesh part, in world coordinates. */ +export function box(min: [number, number, number], max: [number, number, number]): IfcMeshPart { + const [x0, y0, z0] = min + const [x1, y1, z1] = max + const positions = [ + [x0, y0, z0], + [x1, y0, z0], + [x1, y1, z0], + [x0, y1, z0], + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + ].flat() + const indices = [ + [0, 2, 1, 0, 3, 2], + [4, 5, 6, 4, 6, 7], + [0, 1, 5, 0, 5, 4], + [2, 3, 7, 2, 7, 6], + [1, 2, 6, 1, 6, 5], + [0, 4, 7, 0, 7, 3], + ].flat() + return { positions, indices, color: [0.8, 0.4, 0.2], opacity: 1 } +} + +export const node = (fields: Record) => + ({ object: 'node', visible: true, metadata: {}, children: [], ...fields }) as unknown as AnyNode + +/** + * A rotated, offset building with straight and curved walls, rooms grouped in + * a unit, and mesh-only kinds (item, fence, roof, stair) with world-space boxes. + */ +export function groupedElementsScene() { + const site = SiteNode.parse({ id: 'site_main' }) + const building = BuildingNode.parse({ + id: 'building_main', + parentId: site.id, + position: [10, 0, 5], + rotation: [0, Math.PI / 2, 0], + }) + const ground = LevelNode.parse({ id: 'level_g', parentId: building.id, level: 0, height: 3 }) + const upper = LevelNode.parse({ id: 'level_u', parentId: building.id, level: 1, height: 3 }) + const wall = WallNode.parse({ + id: 'wall_straight', + parentId: ground.id, + start: [1, 0], + end: [4, 0], + thickness: 0.2, + height: 2.5, + }) + const curved = WallNode.parse({ + id: 'wall_curved', + parentId: upper.id, + start: [0, 0], + end: [4, 0], + curveOffset: 0.8, + thickness: 0.15, + height: 2.4, + }) + const nodes: Record = { + [site.id]: { ...site, children: [building.id] } as AnyNode, + [building.id]: { ...building, children: [ground.id, upper.id] } as AnyNode, + [ground.id]: ground, + [upper.id]: upper, + [wall.id]: wall, + [curved.id]: curved, + item_sofa: node({ id: 'item_sofa', type: 'item', name: 'Sofa', parentId: upper.id }), + item_hidden: node({ id: 'item_hidden', type: 'item', parentId: upper.id, visible: false }), + item_bare: node({ id: 'item_bare', type: 'item', name: 'No mesh', parentId: ground.id }), + fence_1: node({ id: 'fence_1', type: 'fence', parentId: ground.id }), + roof_1: node({ id: 'roof_1', type: 'roof', name: 'Roof', parentId: upper.id }), + 'roof-segment_1': node({ id: 'roof-segment_1', type: 'roof-segment', parentId: 'roof_1' }), + 'roof-segment_2': node({ id: 'roof-segment_2', type: 'roof-segment', parentId: 'roof_1' }), + stair_1: node({ id: 'stair_1', type: 'stair', name: 'Stair', parentId: ground.id }), + 'stair-segment_1': node({ id: 'stair-segment_1', type: 'stair-segment', parentId: 'stair_1' }), + zone_a: node({ + id: 'zone_a', + type: 'zone', + name: 'Flat A kitchen', + parentId: ground.id, + polygon: [ + [0, 0], + [3, 0], + [3, 3], + [0, 3], + ], + spaceRole: 'room', + ceilingHeight: 2.6, + }), + zone_b: node({ + id: 'zone_b', + type: 'zone', + name: 'Flat A bath', + parentId: ground.id, + polygon: [ + [3, 0], + [5, 0], + [5, 3], + [3, 3], + ], + spaceRole: 'room', + ceilingHeight: 2.4, + }), + unit_a: node({ + id: 'unit_a', + type: 'unit', + name: 'Flat A', + kind: 'apartment', + members: ['zone_a', 'zone_b'], + }), + tree_oak: node({ id: 'tree_oak', type: 'trees:tree', name: 'Oak', parentId: ground.id }), + spawn_1: node({ id: 'spawn_1', type: 'spawn', parentId: ground.id }), + // Legacy migration keeps a zero-thickness manual slab's degenerate interval. + slab_flat: node({ + id: 'slab_flat', + type: 'slab', + name: 'Flat slab', + parentId: ground.id, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + elevation: 0, + thickness: 0, + }), + slab_flat_bare: node({ + id: 'slab_flat_bare', + type: 'slab', + parentId: ground.id, + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + elevation: 0, + thickness: 0, + }), + slab_pool: node({ + id: 'slab_pool', + type: 'slab', + name: 'Pool', + parentId: ground.id, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation: -1.2, + thickness: 0.2, + recessed: true, + }), + } + // World coordinates: the sofa sits on the upper storey (world y 3). + const meshes = new Map([ + ['item_sofa', [box([12, 3, 6], [14, 3.8, 7])]], + ['item_hidden', [box([0, 3, 0], [1, 4, 1])]], + ['fence_1', [box([0, 0, -2], [4, 1, -1.9])]], + ['roof-segment_1', [box([10, 6, 5], [14, 6.2, 8])]], + ['roof-segment_2', [box([10, 6, 8], [14, 6.2, 11])]], + ['stair-segment_1', [box([11, 0, 6], [12, 3, 9])]], + ['tree_oak', [box([9, 0, 2], [10, 6, 3])]], + ['slab_flat', [box([10, 0, 3], [12, 0.001, 5])]], + ['slab_pool', [box([10, -1.2, 3], [12, 0, 5])]], + ]) + return { nodes, meshes, wall, curved } +} diff --git a/packages/ifc-converter/tests/export-step-check.ts b/packages/ifc-converter/tests/export-step-check.ts new file mode 100644 index 0000000000..fe08485fca --- /dev/null +++ b/packages/ifc-converter/tests/export-step-check.ts @@ -0,0 +1,136 @@ +import { expect } from 'bun:test' + +// IFC4 ADD2 TC1 explicit attribute counts for every entity the writer emits. +const ATTRIBUTE_COUNTS: Record = { + IFCPERSON: 8, + IFCORGANIZATION: 5, + IFCPERSONANDORGANIZATION: 3, + IFCAPPLICATION: 4, + IFCOWNERHISTORY: 8, + IFCSIUNIT: 4, + IFCUNITASSIGNMENT: 1, + IFCCARTESIANPOINT: 1, + IFCDIRECTION: 1, + IFCAXIS2PLACEMENT2D: 2, + IFCAXIS2PLACEMENT3D: 3, + IFCLOCALPLACEMENT: 2, + IFCGEOMETRICREPRESENTATIONCONTEXT: 6, + IFCGEOMETRICREPRESENTATIONSUBCONTEXT: 10, + IFCPROJECT: 9, + IFCSITE: 14, + IFCBUILDING: 12, + IFCBUILDINGSTOREY: 10, + IFCRELAGGREGATES: 6, + IFCRELCONTAINEDINSPATIALSTRUCTURE: 6, + IFCPROPERTYSINGLEVALUE: 4, + IFCPROPERTYSET: 5, + IFCRELDEFINESBYPROPERTIES: 6, + IFCQUANTITYLENGTH: 5, + IFCELEMENTQUANTITY: 6, + IFCMATERIAL: 3, + IFCMATERIALLAYER: 7, + IFCMATERIALLAYERSET: 3, + IFCMATERIALLAYERSETUSAGE: 5, + IFCRELASSOCIATESMATERIAL: 6, + IFCPOLYLINE: 1, + IFCSHAPEREPRESENTATION: 4, + IFCARBITRARYCLOSEDPROFILEDEF: 3, + IFCARBITRARYPROFILEDEFWITHVOIDS: 4, + IFCRECTANGLEPROFILEDEF: 5, + IFCCIRCLEPROFILEDEF: 4, + IFCEXTRUDEDAREASOLID: 4, + IFCPRODUCTDEFINITIONSHAPE: 3, + IFCWALL: 9, + IFCSLAB: 9, + IFCDOOR: 13, + IFCWINDOW: 13, + IFCOPENINGELEMENT: 9, + IFCRELVOIDSELEMENT: 6, + IFCRELFILLSELEMENT: 6, + IFCSPACE: 11, + IFCCOVERING: 9, + IFCRELCOVERSSPACES: 6, + IFCCOLUMN: 9, + IFCMEMBER: 9, + IFCPLATE: 9, + IFCBUILDINGELEMENTPROXY: 9, + IFCFURNISHINGELEMENT: 8, + IFCGEOGRAPHICELEMENT: 9, + IFCROOF: 9, + IFCSTAIR: 9, + IFCSTAIRFLIGHT: 13, + IFCCARTESIANPOINTLIST3D: 1, + IFCTRIANGULATEDFACESET: 5, + IFCCOLOURRGB: 4, + IFCSURFACESTYLESHADING: 2, + IFCSURFACESTYLE: 3, + IFCSTYLEDITEM: 3, + IFCZONE: 6, + IFCRELASSIGNSTOGROUP: 7, +} + +/** Split a STEP argument list on its top-level commas (quote- and paren-aware). */ +function topLevelArgs(args: string): string[] { + const out: string[] = [] + let depth = 0 + let quoted = false + let current = '' + for (let i = 0; i < args.length; i++) { + const char = args[i]! + if (quoted) { + current += char + if (char === "'") { + if (args[i + 1] === "'") current += args[++i] + else quoted = false + } + continue + } + if (char === "'") quoted = true + else if (char === '(') depth++ + else if (char === ')') depth-- + if (char === ',' && depth === 0) { + out.push(current) + current = '' + } else current += char + } + out.push(current) + return out +} + +type StepEntity = { id: number; type: string; args: string[]; body: string } + +function parseStep(text: string): Map { + const entities = new Map() + for (const match of text.matchAll(/^#(\d+)=([A-Z0-9]+)\((.*)\);$/gm)) { + const id = Number(match[1]) + entities.set(id, { id, type: match[2]!, args: topLevelArgs(match[3]!), body: match[3]! }) + } + return entities +} + +export function expectWellFormedStep(text: string) { + expect(text.startsWith('ISO-10303-21;\nHEADER;')).toBe(true) + expect(text).toContain("FILE_SCHEMA(('IFC4'));") + expect(text.trimEnd().endsWith('END-ISO-10303-21;')).toBe(true) + const entities = parseStep(text) + const dataLines = text.split('\n').filter((line) => line.startsWith('#')) + expect(entities.size).toBe(dataLines.length) + const guids = new Set() + for (const entity of entities.values()) { + const expected = ATTRIBUTE_COUNTS[entity.type] + if (expected === undefined) throw new Error(`No attribute count for ${entity.type}`) + expect({ type: entity.type, count: entity.args.length }).toEqual({ + type: entity.type, + count: expected, + }) + for (const ref of entity.body.replace(/'(?:[^']|'')*'/g, "''").matchAll(/#(\d+)/g)) { + expect(entities.has(Number(ref[1]))).toBe(true) + } + const first = entity.args[0]! + if (/^'[0-3][0-9A-Za-z_$]{21}'$/.test(first)) { + expect(guids.has(first)).toBe(false) + guids.add(first) + } + } + return entities +} From 65208ec34612dfa39370eccc675de5933c6877b2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 30 Sep 2026 14:54:58 -0400 Subject: [PATCH 3/3] fix(core): classify the IFC importer's metadata and derived-writer role The IFC importer hands the loader plates and ceilings already linked to their rooms, so it joins the derived-construction allowlist next to the load migrations. Its new metadata keys (pascalNodeId, ifcHostExpressID, ifcDerived, ifcSplit, sourceColor, typeName) are classified, and the stale metadata `thickness` row is dropped now that slabs store it natively. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L8CFFhDVTArznXMZn48Xu3 --- .../core/src/contracts/reference-inventory.ts | 17 +++++++++++++++-- .../src/store/derived-writer-allowlist.test.ts | 3 +++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 4b8ae26851..8defe4f872 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -836,7 +836,13 @@ export const METADATA_REFERENCES: readonly ExistingReference[] = [ ), meta('floorPlanUrl', { ...policy('asset', 'content', 'freeze', 'keep') }), ...( - ['expressID', 'globalId', 'hostWallExpressID', 'ifcSimplification.mergedExpressIDs[]'] as const + [ + 'expressID', + 'globalId', + 'hostWallExpressID', + 'ifcHostExpressID', + 'ifcSimplification.mergedExpressIDs[]', + ] as const ).map((key) => meta(key, { ...policy('source', 'content', 'freeze', 'keep'), @@ -852,6 +858,10 @@ export const METADATA_REFERENCES: readonly ExistingReference[] = [ targetKinds: ['slab'], note: 'The plate whose manual hole a floor opening was converted from.', }), + meta('pascalNodeId', { + ...policy('source', 'content', 'freeze', 'strip'), + note: 'Import provenance: the Pascal node id an IFC element carried in its Pascal property set, so a re-import keeps identity.', + }), meta('sourceIds[]', { ...policy('source', 'content', 'freeze', 'strip'), note: 'Import provenance: the source element ids an importer recorded (the /next converter writes them); find_nodes filters on them.', @@ -879,6 +889,8 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'floorReassignmentHeight', 'footprintApproximated', 'generatedBy', + 'ifcDerived', + 'ifcSplit', 'isFloorplanPreview', 'isNew', 'isTransient', @@ -927,8 +939,9 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ ...described('IFC attribute copy: a value or IFC label, not an id.', [ 'elevation', 'height', - 'thickness', 'sillHeight', + 'sourceColor', + 'typeName', 'polygon', 'material', 'materialLayers', diff --git a/packages/core/src/store/derived-writer-allowlist.test.ts b/packages/core/src/store/derived-writer-allowlist.test.ts index 4c8141a9ae..813669c216 100644 --- a/packages/core/src/store/derived-writer-allowlist.test.ts +++ b/packages/core/src/store/derived-writer-allowlist.test.ts @@ -17,6 +17,9 @@ const ALLOWLIST = new Set([ // Load migrations mint the same construction for scenes saved before it. 'packages/core/src/utils/room-zone-migration.ts', 'packages/core/src/utils/floor-plate-migration.ts', + // The IFC importer hands the loader plates and ceilings already linked to + // their rooms; the load migrations and the kernel then re-derive them. + 'packages/ifc-converter/src/room-first.ts', ]) const MARKERS = [/\bboundary:\s*'auto'/, /\bautoFromWalls:\s*true/]