diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 55fdcfd9ba..9304d7b261 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -23,6 +23,7 @@ import type { GuideNode, GutterNode, HvacEquipmentNode, + ImportedMeshNode, ItemNode, LevelNode, LinesetNode, @@ -92,6 +93,7 @@ export interface NodeEvent { export type WallEvent = NodeEvent export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent +export type ImportedMeshEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent export type CabinetEvent = NodeEvent @@ -294,6 +296,7 @@ type EditorEvents = GridEvents & NodeEvents<'cabinet', CabinetEvent> & NodeEvents<'cabinet-module', CabinetModuleEvent> & NodeEvents<'item', ItemEvent> & + NodeEvents<'imported-mesh', ImportedMeshEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & NodeEvents<'elevator', ElevatorEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9bf1a6b741..309211b593 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export type { GridEvent, GuideEvent, GutterEvent, + ImportedMeshEvent, ItemEvent, LevelEvent, MeasurementEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index d11bdec7d1..f6c6f9308a 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -101,6 +101,11 @@ export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export { GutterNode, GutterOutlet } from './nodes/gutter' export { HvacEquipmentNode } from './nodes/hvac-equipment' +export { + ImportedMeshNode, + ImportedMeshPrimitive, + type ImportedMeshPrimitive as ImportedMeshPrimitiveValue, +} from './nodes/imported-mesh' export type { AnimationEffect, Asset, diff --git a/packages/core/src/schema/nodes/imported-mesh.ts b/packages/core/src/schema/nodes/imported-mesh.ts new file mode 100644 index 0000000000..c0cca4ded6 --- /dev/null +++ b/packages/core/src/schema/nodes/imported-mesh.ts @@ -0,0 +1,36 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +const finiteNumber = z.number().finite() +const vector3Array = z + .array(finiteNumber) + .refine((values) => values.length % 3 === 0, 'Expected XYZ triples') + +export const ImportedMeshPrimitive = z.object({ + positions: vector3Array, + normals: vector3Array.optional(), + indices: z.array(z.number().int().nonnegative()).default([]), + color: z.string().default('#94a3b8'), + opacity: z.number().min(0).max(1).default(1), +}) + +export type ImportedMeshPrimitive = z.infer + +/** Triangle geometry retained when an import has no native Pascal shape. */ +export const ImportedMeshNode = BaseNode.extend({ + id: objectId('imesh'), + type: nodeType('imported-mesh'), + position: z.tuple([finiteNumber, finiteNumber, finiteNumber]).default([0, 0, 0]), + rotation: z.tuple([finiteNumber, finiteNumber, finiteNumber]).default([0, 0, 0]), + primitives: z.array(ImportedMeshPrimitive).default([]), +}).describe( + dedent` + Imported mesh node - triangle geometry preserved from an external model + - position / rotation: transform in the parent coordinate system + - primitives: indexed triangle buffers in meters with source display colors + - metadata: source-format identity and properties + `, +) + +export type ImportedMeshNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 2032c61a7e..a54f9c7303 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -10,6 +10,7 @@ import type { DuctTerminalNode } from './duct-terminal' import type { FenceNode } from './fence' import type { GuideNode } from './guide' import type { HvacEquipmentNode } from './hvac-equipment' +import type { ImportedMeshNode } from './imported-mesh' import type { ItemNode } from './item' import type { LinesetNode } from './lineset' import type { LiquidLineNode } from './liquid-line' @@ -34,6 +35,7 @@ type CoreLevelChildId = | ConstructionDimensionNode['id'] | StructuralGridNode['id'] | ItemNode['id'] + | ImportedMeshNode['id'] | ZoneNode['id'] | SlabNode['id'] | CeilingNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 70575bcfb9..2d5a34da49 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -19,6 +19,7 @@ import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { GutterNode } from './nodes/gutter' import { HvacEquipmentNode } from './nodes/hvac-equipment' +import { ImportedMeshNode } from './nodes/imported-mesh' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { LinesetNode } from './nodes/lineset' @@ -58,6 +59,7 @@ export const AnyNode = z.discriminatedUnion('type', [ CabinetNode, CabinetModuleNode, ItemNode, + ImportedMeshNode, ZoneNode, SlabNode, CeilingNode, diff --git a/packages/ifc-converter/README.md b/packages/ifc-converter/README.md index aa71177c8e..d44e9648e1 100644 --- a/packages/ifc-converter/README.md +++ b/packages/ifc-converter/README.md @@ -5,3 +5,21 @@ IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against `@pascal-app/core` schemas. No DOM, no React. The UI lives in `apps/ifc-converter`. + +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. Beams, +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. +Imported meshes are import-only and do not appear as empty objects in the editor +palette. + +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. + +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/door-semantics.ts b/packages/ifc-converter/src/door-semantics.ts new file mode 100644 index 0000000000..ca5f64dfea --- /dev/null +++ b/packages/ifc-converter/src/door-semantics.ts @@ -0,0 +1,65 @@ +import type { DoorNode } from '@pascal-app/core' + +type DoorStyle = Partial + +const glazedSegments: DoorNode['segments'] = [ + { + type: 'glass', + heightRatio: 1, + columnRatios: [1], + dividerThickness: 0.025, + panelDepth: 0.008, + panelInset: 0.035, + }, +] + +/** Map standardized IfcDoor operation values to Pascal door families. */ +export function doorStyleFromIfcOperation(operationType: unknown): DoorStyle { + const operation = String(operationType ?? '').toUpperCase() + const leafCount = operation.startsWith('DOUBLE_DOOR') ? 2 : 1 + + if (operation.includes('SLIDING')) { + return { + doorType: 'sliding', + leafCount, + slideDirection: operation.includes('RIGHT') ? 'right' : 'left', + trackStyle: 'visible', + threshold: false, + } + } + + if (operation.includes('FOLDING')) { + return { + doorType: 'folding', + leafCount, + } + } + + if (operation === 'ROLLINGUP') { + return { + doorCategory: 'garage', + doorType: 'garage-rollup', + trackStyle: 'overhead', + } + } + + if (operation.startsWith('DOUBLE_DOOR')) { + return { doorType: 'double', leafCount: 2 } + } + + if (operation.includes('SWING_RIGHT')) return { hingesSide: 'right' } + if (operation.includes('SWING_LEFT')) return { hingesSide: 'left' } + return {} +} + +/** Apply the standardized Pset_DoorCommon glazing fraction after psets load. */ +export function doorGlazingStyle(door: DoorNode, glazingAreaFraction: unknown): DoorStyle { + const fraction = Number(glazingAreaFraction) + if (!Number.isFinite(fraction) || fraction <= 0) return {} + + return { + doorType: door.doorType === 'double' ? 'french' : door.doorType, + segments: glazedSegments, + contentPadding: [0.04, 0.05], + } +} diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index ad3d5a1400..7b00239353 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -3,20 +3,25 @@ import { type AnyNodeId, BuildingNode, ColumnNode, + DEFAULT_LEVEL_HEIGHT, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, + ImportedMeshNode, + type ImportedMeshPrimitiveValue, LevelNode, RoofNode, SiteNode, SlabNode, - StairNode, WallNode, WindowNode, + ZoneNode, } from '@pascal-app/core' import { customAlphabet } from 'nanoid' import * as WebIFC from 'web-ifc' import { type IfcConversionSimplificationOptions, simplifyConvertedSceneGraph } from './cleanup' +import { doorGlazingStyle, doorStyleFromIfcOperation } from './door-semantics' +import { selectStoreyForElevation } from './storey-semantics' export type { IfcConversionSimplificationOptions, @@ -603,6 +608,182 @@ function wallHeightThicknessFromExtents( return null } +type PascalPointTransform = ( + scenePoint: number[], + levelElevation: number, +) => [number, number, number] + +function sourceColor(color: { x?: number; y?: number; z?: number } | undefined): string { + const channel = (value: number | undefined) => + Math.max(0, Math.min(255, Math.round((value ?? 0.58) * 255))) + .toString(16) + .padStart(2, '0') + return `#${channel(color?.x)}${channel(color?.y)}${channel(color?.z)}` +} + +function roundMeshPosition(value: number): number { + const rounded = Math.round(value * 10_000) / 10_000 + return rounded === 0 ? 0 : rounded +} + +function roundMeshNormal(value: number): number { + const rounded = Math.round(value * 1000) / 1000 + return rounded === 0 ? 0 : rounded +} + +function extractImportedMeshPrimitives( + ifcApi: WebIFC.IfcAPI, + modelID: number, + expressID: number, + unitFactor: number, + originOffset: number[], + levelElevation: number, + swapYZ: boolean, +): ImportedMeshPrimitiveValue[] { + let flatMesh: { + geometries: { size: () => number; get: (index: number) => unknown } + delete?: () => void + } + try { + flatMesh = ifcApi.GetFlatMesh(modelID, expressID) as never + } catch { + return [] + } + + const primitives: ImportedMeshPrimitiveValue[] = [] + try { + for (let geometryIndex = 0; geometryIndex < flatMesh.geometries.size(); geometryIndex++) { + const placed = flatMesh.geometries.get(geometryIndex) as { + flatTransformation: number[] + geometryExpressID: number + color?: { x?: number; y?: number; z?: number; w?: number } + } + const matrix = placed.flatTransformation + const geometry = ifcApi.GetGeometry(modelID, placed.geometryExpressID) + try { + const vertices = ifcApi.GetVertexArray( + geometry.GetVertexData(), + geometry.GetVertexDataSize(), + ) + const sourceIndices = ifcApi.GetIndexArray( + geometry.GetIndexData(), + geometry.GetIndexDataSize(), + ) + const positions: number[] = [] + const normals: number[] = [] + + for (let vertex = 0; vertex + 5 < vertices.length; vertex += 6) { + const x = vertices[vertex]! + const y = vertices[vertex + 1]! + const z = vertices[vertex + 2]! + const world = [ + matrix[0]! * x + matrix[4]! * y + matrix[8]! * z + matrix[12]!, + matrix[1]! * x + matrix[5]! * y + matrix[9]! * z + matrix[13]!, + matrix[2]! * x + matrix[6]! * y + matrix[10]! * z + matrix[14]!, + ] + // `GetFlatMesh` does not use the same axes as the STEP placement + // data read by `resolveWorldTransform`: web-ifc has already mapped + // IFC Z-up coordinates to an X/Y-up/-Z frame. Applying the regular + // STEP `swapYZ` transform here a second time makes plan depth look + // like height (and height look like plan depth), exploding fallback + // walls and railings across the scene. + const mappedPosition: [number, number, number] = swapYZ + ? [ + world[0]! - originOffset[0]! * unitFactor, + world[1]! - originOffset[2]! * unitFactor - levelElevation, + -(world[2]! + originOffset[1]! * unitFactor), + ] + : [ + world[0]! - originOffset[0]! * unitFactor, + -(world[2]! + originOffset[1]! * unitFactor), + world[1]! - originOffset[2]! * unitFactor - levelElevation, + ] + positions.push(...mappedPosition.map(roundMeshPosition)) + + const nx = vertices[vertex + 3]! + const ny = vertices[vertex + 4]! + const nz = vertices[vertex + 5]! + const worldNormal = [ + matrix[0]! * nx + matrix[4]! * ny + matrix[8]! * nz, + matrix[1]! * nx + matrix[5]! * ny + matrix[9]! * nz, + matrix[2]! * nx + matrix[6]! * ny + matrix[10]! * nz, + ] + const mappedNormal = swapYZ + ? [worldNormal[0]!, worldNormal[1]!, -worldNormal[2]!] + : [worldNormal[0]!, -worldNormal[2]!, worldNormal[1]!] + const normalLength = Math.hypot(...mappedNormal) || 1 + normals.push( + roundMeshNormal(mappedNormal[0]! / normalLength), + roundMeshNormal(mappedNormal[1]! / normalLength), + roundMeshNormal(mappedNormal[2]! / normalLength), + ) + } + + const indices = Array.from(sourceIndices) + if (swapYZ) { + for (let index = 0; index + 2 < indices.length; index += 3) { + const second = indices[index + 1]! + indices[index + 1] = indices[index + 2]! + indices[index + 2] = second + } + } + if (positions.length >= 9 && indices.length >= 3) { + primitives.push({ + positions, + normals, + indices, + color: sourceColor(placed.color), + opacity: Math.max(0, Math.min(1, placed.color?.w ?? 1)), + }) + } + } finally { + ;(geometry as unknown as { delete?: () => void }).delete?.() + } + } + } catch { + return primitives + } finally { + flatMesh.delete?.() + } + return primitives +} + +function meshFootprint( + primitives: ImportedMeshPrimitiveValue[], + swapYZ: boolean, +): [number, number][] | null { + const unique = new Map() + const secondPlanAxis = swapYZ ? 2 : 1 + for (const primitive of primitives) { + for (let i = 0; i + 2 < primitive.positions.length; i += 3) { + const point: [number, number] = [ + primitive.positions[i]!, + primitive.positions[i + secondPlanAxis]!, + ] + unique.set(`${point[0].toFixed(5)}:${point[1].toFixed(5)}`, point) + } + } + const points = [...unique.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]) + if (points.length < 3) return null + const cross = (o: [number, number], a: [number, number], b: [number, number]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + const lower: [number, number][] = [] + for (const point of points) { + while (lower.length >= 2 && cross(lower.at(-2)!, lower.at(-1)!, point) <= 0) lower.pop() + lower.push(point) + } + const upper: [number, number][] = [] + for (let i = points.length - 1; i >= 0; i--) { + const point = points[i]! + while (upper.length >= 2 && cross(upper.at(-2)!, upper.at(-1)!, point) <= 0) upper.pop() + upper.push(point) + } + lower.pop() + upper.pop() + const hull = [...lower, ...upper] + return hull.length >= 3 ? hull : null +} + function getExtrusionPosition(ifcApi: WebIFC.IfcAPI, modelID: number, element: any): Mat4 | null { try { if (!element.Representation?.value) return null @@ -642,6 +823,7 @@ export interface ConversionOptions { swapYZ?: boolean extrusionDepthIsHeight?: boolean swapProfileDimensions?: boolean + wasmPath?: string simplify?: boolean | IfcConversionSimplificationOptions label?: string } @@ -670,6 +852,7 @@ export async function convertIfcToPascal( swapYZ: options?.swapYZ ?? true, extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true, swapProfileDimensions: options?.swapProfileDimensions ?? false, + wasmPath: options?.wasmPath ?? '/', } const simplificationOptions = options?.simplify === false @@ -685,7 +868,7 @@ export async function convertIfcToPascal( progress('Initializing IFC parser...', 0) const ifcApi = new WebIFC.IfcAPI() - ifcApi.SetWasmPath('/', true) + ifcApi.SetWasmPath(opts.wasmPath, true) await ifcApi.Init() progress('Opening IFC model...', 10) @@ -697,6 +880,16 @@ export async function convertIfcToPascal( const nodes: Record = {} const rootNodeIds: string[] = [] + function attachNodeToGraph(nodeId: string, parentNodeId: string | null | undefined) { + const parent = parentNodeId ? nodes[parentNodeId] : undefined + const children = parent && 'children' in parent ? (parent.children as string[]) : undefined + if (children) { + children.push(nodeId) + return + } + rootNodeIds.push(nodeId) + } + // Maps to track relationships const parentMap = new Map() const childrenMap = new Map() @@ -731,6 +924,13 @@ export async function convertIfcToPascal( ] } + const toPascalPoint: PascalPointTransform = (scenePoint, levelElevation) => + opts.swapYZ + ? [scenePoint[0]!, scenePoint[2]! - levelElevation, scenePoint[1]!] + : [scenePoint[0]!, scenePoint[1]!, scenePoint[2]! - levelElevation] + + const storeyElevationByExpressId = new Map() + // Collect storey expressIDs for level mapping const storeyExpressIds = new Set() const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY) @@ -792,6 +992,90 @@ export async function convertIfcToPascal( return null } + // Some IFC authoring tools aggregate roof elements under IfcRoof -> + // IfcBuilding instead of spatially containing them in an IfcBuildingStorey. + // Infer the most appropriate storey from the element elevation so those + // elements still become reachable, level-local Pascal nodes. + function resolveStoreyForElement(expressId: number): number | null { + const containedStorey = findStoreyForElement(expressId) + if (containedStorey != null) return containedStorey + + let buildingExpressId: number | null = null + let current: number | undefined = expressId + for (let guard = 0; guard < 20 && current != null; guard++) { + const nodeId = expressIdToNodeId.get(current) + if (nodeId && nodes[nodeId]?.type === 'building') { + buildingExpressId = current + break + } + current = parentMap.get(current) + } + + let elementElevation = Number.POSITIVE_INFINITY + try { + const element = ifcApi.GetLine(modelID, expressId) + if (element.ObjectPlacement?.value) { + const matrix = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value) + elementElevation = worldToScene(transformPoint3(matrix, [0, 0, 0]))[2]! + } + } catch { + /* use highest storey */ + } + + const candidates = [...storeyExpressIds] + .filter((candidate) => { + if (buildingExpressId == null) return true + let ancestor: number | undefined = candidate + for (let guard = 0; guard < 20 && ancestor != null; guard++) { + if (ancestor === buildingExpressId) return true + ancestor = parentMap.get(ancestor) + } + return false + }) + .map((candidate) => ({ + expressId: candidate, + elevation: storeyElevationByExpressId.get(candidate) ?? 0, + })) + + return selectStoreyForElevation(candidates, elementElevation) + } + + function resolveElementParent(expressId: number): string | null { + const storeyExpressId = resolveStoreyForElement(expressId) + if (storeyExpressId != null) { + const levelId = expressIdToNodeId.get(storeyExpressId) + if (levelId) return levelId + } + const parentExpressId = parentMap.get(expressId) + const parentNodeId = parentExpressId ? expressIdToNodeId.get(parentExpressId) : undefined + const parentNode = parentNodeId ? nodes[parentNodeId] : undefined + if (parentNode?.type === 'level') return parentNodeId ?? null + + return null + } + + function elementLevelElevation(expressId: number): number { + const storeyExpressId = resolveStoreyForElement(expressId) + return storeyExpressId == null ? 0 : (storeyElevationByExpressId.get(storeyExpressId) ?? 0) + } + + const importedPrimitivesByExpressId = new Map() + function importedMeshPrimitivesFor(expressId: number): ImportedMeshPrimitiveValue[] { + const cached = importedPrimitivesByExpressId.get(expressId) + if (cached) return cached + const primitives = extractImportedMeshPrimitives( + ifcApi, + modelID, + expressId, + unitFactor, + originOffset, + elementLevelElevation(expressId), + opts.swapYZ, + ) + importedPrimitivesByExpressId.set(expressId, primitives) + return primitives + } + progress('Processing sites...', 30) // Process sites const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE) @@ -868,9 +1152,7 @@ export async function convertIfcToPascal( nodes[nodeId] = buildingNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } + attachNodeToGraph(nodeId, parentNodeId) } progress('Processing levels...', 50) @@ -900,6 +1182,7 @@ export async function convertIfcToPascal( } else { elevation *= unitFactor } + storeyElevationByExpressId.set(storeyExpressID, elevation) const levelNode = tryParse(LevelNode, 'level', { object: 'node', @@ -920,8 +1203,35 @@ export async function convertIfcToPascal( nodes[nodeId] = levelNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) + attachNodeToGraph(nodeId, parentNodeId) + } + + // Pascal stacks levels from their stored heights. Match those heights to + // IFC storey elevations, while normalizing the lowest IFC storey to y=0. + const storeysByBuilding = new Map() + for (let i = 0; i < storeys.size(); i++) { + const storeyExpressId = storeys.get(i) + const buildingExpressId = parentMap.get(storeyExpressId) ?? null + const group = storeysByBuilding.get(buildingExpressId) ?? [] + group.push(storeyExpressId) + storeysByBuilding.set(buildingExpressId, group) + } + for (const group of storeysByBuilding.values()) { + group.sort( + (a, b) => (storeyElevationByExpressId.get(a) ?? 0) - (storeyElevationByExpressId.get(b) ?? 0), + ) + for (let index = 0; index < group.length; index++) { + const expressId = group[index]! + const nodeId = expressIdToNodeId.get(expressId) + const level = nodeId ? nodes[nodeId] : undefined + if (level?.type !== 'level') continue + const nextExpressId = group[index + 1] + const height = nextExpressId + ? (storeyElevationByExpressId.get(nextExpressId) ?? 0) - + (storeyElevationByExpressId.get(expressId) ?? 0) + : DEFAULT_LEVEL_HEIGHT + level.level = index + level.height = height > 0.1 ? height : DEFAULT_LEVEL_HEIGHT } } @@ -978,8 +1288,7 @@ export async function convertIfcToPascal( const nodeId = generateId('wall') expressIdToNodeId.set(wallExpressID, nodeId) - const parentExpressID = parentMap.get(wallExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null + const parentNodeId = resolveElementParent(wallExpressID) let start: [number, number] = [0, 0] let end: [number, number] | null = null @@ -1026,16 +1335,34 @@ export async function convertIfcToPascal( thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor } - // If no axis polyline, derive wall length from profile or XDim + // If no axis polyline, derive the centerline from the body's local + // profile. IFC rectangle/profile dimensions are centered on the wall + // placement origin. Treating that origin as an endpoint shifts every + // such wall by half its own length (most visible on exterior walls). if (!axisPts) { - let wallLength = body.xDim - if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) { - const xs = body.profilePoints.map((p) => p[0]) - wallLength = Math.max(...xs) - Math.min(...xs) + const extrusionMat = getExtrusionPosition(ifcApi, modelID, wall) + const centerlineMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat + let localStart: number[] | null = null + let localEnd: number[] | null = null + + if (body.profilePoints && body.profilePoints.length >= 3) { + const xs = body.profilePoints.map((point) => point[0]) + const ys = body.profilePoints.map((point) => point[1]) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const centerY = (Math.min(...ys) + Math.max(...ys)) / 2 + localStart = [minX, centerY, 0] + localEnd = [maxX, centerY, 0] + } else if (body.xDim) { + localStart = [-body.xDim / 2, 0, 0] + localEnd = [body.xDim / 2, 0, 0] } - if (wallLength) { - const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0])) - end = [se[0], se[1]] + + if (localStart && localEnd) { + const s0 = worldToScene(transformPoint3(centerlineMat, localStart)) + const s1 = worldToScene(transformPoint3(centerlineMat, localEnd)) + start = [s0[0], s0[1]] + end = [s1[0], s1[1]] } } } catch { @@ -1043,7 +1370,10 @@ export async function convertIfcToPascal( } // Skip walls where we couldn't determine geometry - if (!end) continue + if (!end) { + expressIdToNodeId.delete(wallExpressID) + continue + } // Plain IFCWALL frequently carries Brep / mapped geometry rather // than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't @@ -1100,9 +1430,7 @@ export async function convertIfcToPascal( nodes[nodeId] = wallNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } + attachNodeToGraph(nodeId, parentNodeId) } } @@ -1217,11 +1545,13 @@ export async function convertIfcToPascal( width: width ?? 0.9, height: height ?? 2.1, position: doorPosition, + ...doorStyleFromIfcOperation(element.OperationType?.value), metadata: buildMetadata({ ifcType: 'IFCDOOR', expressID: fillId, globalId: element.GlobalId?.value, hostWallExpressID: wallExpressID, + operationType: element.OperationType?.value, }), }) @@ -1274,8 +1604,10 @@ export async function convertIfcToPascal( // door/window → wall link is only implicit in the element's world // placement. We recover it by projecting the element's world position // onto the nearest wall segment. Anything farther than - // HOST_WALL_MAX_DIST from every wall stays parented to its spatial - // container at the origin — we have no basis to place it on a wall. + // HOST_WALL_MAX_DIST from every wall is left for the exact imported-mesh + // fallback below. A standalone Pascal door/window has wall-local + // coordinates, so putting one at its spatial container's origin silently + // moves it away from its IFC placement. const HOST_WALL_MAX_DIST = 1.0 // metres type WallInfo = { @@ -1352,6 +1684,13 @@ export async function convertIfcToPascal( const element = ifcApi.GetLine(modelID, fillId) const isDoor = doorExpressIds.has(fillId) + // A Pascal WindowNode is always hosted vertically in a wall. Roof + // windows need their full IFC transform, so leave skylights unmapped + // here and preserve them as imported mesh geometry below. + if (!isDoor && String(element.PredefinedType?.value ?? '').toUpperCase() === 'SKYLIGHT') { + continue + } + let width: number | undefined let height: number | undefined if (element.OverallWidth?.value) width = element.OverallWidth.value * unitFactor @@ -1372,13 +1711,13 @@ export async function convertIfcToPascal( const effWidth = width ?? (isDoor ? 0.9 : 1.0) const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null - // When hosted, parent to (and live inside) the wall — same as the - // void/fill path. Otherwise fall back to the spatial container. - const containerExpressID = parentMap.get(fillId) - const containerNodeId = containerExpressID - ? (expressIdToNodeId.get(containerExpressID) ?? null) - : null - const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId + // Native Pascal openings require a native Pascal wall. Preserve + // unhosted openings as exact IFC meshes instead of inventing a + // wall-local position at [0, 0, 0]. + if (!hosted) continue + + // Parent to (and live inside) the wall — same as the void/fill path. + const parentNodeId = hosted.info.nodeId if (isDoor) { const h = height ?? 2.1 @@ -1393,14 +1732,14 @@ export async function convertIfcToPascal( visible: true, width: width ?? 0.9, height: h, - // Placed by nearest-wall projection; [0,0,0] only when no wall - // is within range (then it sits on its spatial container). - position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0], - ...(hosted ? { wallId: hosted.info.nodeId } : {}), + position: [hosted.along, h / 2, 0], + wallId: hosted.info.nodeId, + ...doorStyleFromIfcOperation(element.OperationType?.value), metadata: buildMetadata({ ifcType: 'IFCDOOR', expressID: fillId, globalId: element.GlobalId?.value, + operationType: element.OperationType?.value, }), }) nodes[nodeId] = doorNode @@ -1409,7 +1748,7 @@ export async function convertIfcToPascal( } } else { const h = height ?? 1.2 - const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0 + const sill = scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0 const nodeId = generateId('window') expressIdToNodeId.set(fillId, nodeId) const windowNode = tryParse(WindowNode, 'window', { @@ -1421,13 +1760,13 @@ export async function convertIfcToPascal( visible: true, width: width ?? 1.0, height: h, - position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0], - ...(hosted ? { wallId: hosted.info.nodeId } : {}), + position: [hosted.along, sill + h / 2, 0], + wallId: hosted.info.nodeId, metadata: buildMetadata({ ifcType: 'IFCWINDOW', expressID: fillId, globalId: element.GlobalId?.value, - ...(hosted ? { sillHeight: sill } : {}), + sillHeight: sill, }), }) nodes[nodeId] = windowNode @@ -1447,11 +1786,22 @@ export async function convertIfcToPascal( const slabExpressID = slabs.get(i) const slab = ifcApi.GetLine(modelID, slabExpressID) + // SlabNode represents a horizontal plan polygon and participates in + // Pascal's storey-wide wall-support calculation. Preserve roofs and stair + // landings as exact meshes: roofs may be sloped, while a local landing is + // not a storey floor and must not raise adjacent wall bases. + const slabPredefinedType = String(slab.PredefinedType?.value ?? '').toUpperCase() + if ( + (slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') && + importedMeshPrimitivesFor(slabExpressID).length > 0 + ) { + continue + } + const nodeId = generateId('slab') expressIdToNodeId.set(slabExpressID, nodeId) - const parentExpressID = parentMap.get(slabExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null + const parentNodeId = resolveElementParent(slabExpressID) let polygon: [number, number][] | null = null let elevation = 0 @@ -1465,7 +1815,7 @@ export async function convertIfcToPascal( // Get elevation from placement Z const s = worldToScene(transformPoint3(worldMat, [0, 0, 0])) - elevation = s[2] + elevation = s[2] - elementLevelElevation(slabExpressID) // Get body extrusion data const body = getBodyExtrusionData(ifcApi, modelID, slab) @@ -1513,7 +1863,10 @@ export async function convertIfcToPascal( } // Skip slabs where we couldn't extract a polygon - if (!polygon || polygon.length < 3) continue + if (!polygon || polygon.length < 3) { + expressIdToNodeId.delete(slabExpressID) + continue + } const slabNode = tryParse(SlabNode, 'slab', { object: 'node', @@ -1531,120 +1884,14 @@ export async function convertIfcToPascal( ifcType: 'IFCSLAB', expressID: slabExpressID, globalId: slab.GlobalId?.value, + predefinedType: slab.PredefinedType?.value, thickness, }), }) nodes[nodeId] = slabNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } - } - - // Process stairs - const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR) - for (let i = 0; i < stairs.size(); i++) { - const stairExpressID = stairs.get(i) - if (expressIdToNodeId.has(stairExpressID)) continue - - const stair = ifcApi.GetLine(modelID, stairExpressID) - const nodeId = generateId('stair') - expressIdToNodeId.set(stairExpressID, nodeId) - - const parentExpressID = parentMap.get(stairExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null - - let position: [number, number, number] = [0, 0, 0] - let boundingBox: [number, number, number] | undefined - - try { - const worldMat = stair.ObjectPlacement?.value - ? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value) - : identity() - const s = worldToScene(transformPoint3(worldMat, [0, 0, 0])) - position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]] - - // Try stair's own body first - const body = getBodyExtrusionData(ifcApi, modelID, stair) - if (body.xDim && body.yDim && body.depth) { - boundingBox = opts.swapYZ - ? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor] - : [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor] - } - - // If no body, try to derive from stair flight children - if (!boundingBox) { - const stairChildren = childrenMap.get(stairExpressID) ?? [] - for (const childId of stairChildren) { - try { - const child = ifcApi.GetLine(modelID, childId) - // Check for NumberOfRisers / RiserHeight / TreadLength - const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value - const riserHeight = child.RiserHeight?.value - const treadLength = child.TreadLength?.value - if (nRisers && riserHeight && treadLength) { - const totalHeight = nRisers * riserHeight * unitFactor - const totalRun = (nRisers - 1) * treadLength * unitFactor - const width = 1.0 // Default stair width - const flightBody = getBodyExtrusionData(ifcApi, modelID, child) - const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width - boundingBox = opts.swapYZ - ? [totalRun || 1, totalHeight, stairWidth] - : [totalRun || 1, stairWidth, totalHeight] - break - } - // Fallback: try flight body extrusion - const flightBody = getBodyExtrusionData(ifcApi, modelID, child) - if (flightBody.xDim && flightBody.yDim && flightBody.depth) { - boundingBox = opts.swapYZ - ? [ - flightBody.xDim * unitFactor, - flightBody.depth * unitFactor, - flightBody.yDim * unitFactor, - ] - : [ - flightBody.xDim * unitFactor, - flightBody.yDim * unitFactor, - flightBody.depth * unitFactor, - ] - break - } - } catch { - /* skip child */ - } - } - } - } catch { - /* keep defaults */ - } - - const stairNode = tryParse(StairNode, 'stair', { - object: 'node', - id: nodeId, - type: 'stair', - name: stair.Name?.value || `Stair ${i + 1}`, - parentId: parentNodeId || null, - visible: true, - position, - children: [], - // TODO(ifc-fix): Pascal StairNode is parametric (segments / treads / - // risers). The converter only knows the bounding box right now; - // keep it in metadata until we map IFC stairs onto the parametric - // shape (or extend StairNode with a raw-geometry escape hatch). - metadata: buildMetadata({ - ifcType: 'IFCSTAIR', - expressID: stairExpressID, - globalId: stair.GlobalId?.value, - predefinedType: stair.PredefinedType?.value, - boundingBox, - }), - }) - - nodes[nodeId] = stairNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } + attachNodeToGraph(nodeId, parentNodeId) } // Process roofs @@ -1657,8 +1904,7 @@ export async function convertIfcToPascal( const nodeId = generateId('roof') expressIdToNodeId.set(roofExpressID, nodeId) - const parentExpressID = parentMap.get(roofExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null + const parentNodeId = resolveElementParent(roofExpressID) let polygon: [number, number][] | undefined let elevation: number | undefined @@ -1669,7 +1915,7 @@ export async function convertIfcToPascal( ? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value) : identity() const s = worldToScene(transformPoint3(worldMat, [0, 0, 0])) - elevation = s[2] + elevation = s[2] - elementLevelElevation(roofExpressID) const body = getBodyExtrusionData(ifcApi, modelID, roof) if (body.depth) height = body.depth * unitFactor @@ -1732,9 +1978,7 @@ export async function convertIfcToPascal( }) nodes[nodeId] = roofNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } + attachNodeToGraph(nodeId, parentNodeId) } // Process columns @@ -1759,8 +2003,7 @@ export async function convertIfcToPascal( const nodeId = generateId('column') expressIdToNodeId.set(colExpressID, nodeId) - const parentExpressID = parentMap.get(colExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null + const parentNodeId = resolveElementParent(colExpressID) let position: [number, number, number] = [0, 0, 0] let width: number | undefined @@ -1774,7 +2017,7 @@ export async function convertIfcToPascal( ? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value) : identity() const s = worldToScene(transformPoint3(worldMat, [0, 0, 0])) - position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]] + position = toPascalPoint(s, elementLevelElevation(colExpressID)) const body = getBodyExtrusionData(ifcApi, modelID, col) if (body.depth) height = body.depth * unitFactor @@ -1836,75 +2079,177 @@ export async function convertIfcToPascal( }) nodes[nodeId] = columnNode - if (parentNodeId && nodes[parentNodeId]) { - ;(nodes[parentNodeId] as any).children?.push(nodeId) - } + attachNodeToGraph(nodeId, parentNodeId) } } - // Beams: skipped for now — Pascal has no `beam` node type yet. When it - // lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping - // (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth, - // extrusion depth → axis length). Reference implementation lives in - // git history of this file. We still walk the entities to log how - // many beams the IFC contained so the conversion summary is accurate. - let skippedBeamCount = 0 - const beamTypes = [WebIFC.IFCBEAM] + // Spaces become editable Pascal room zones. Prefer their swept-area + // profile (preserves concavity); fall back to the mesh's plan hull. + let importedSpaceCount = 0 try { - beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE) - } catch { - /* not in all versions */ - } - for (const beamType of beamTypes) { - try { - const beams = ifcApi.GetLineIDsWithType(modelID, beamType) - skippedBeamCount += beams.size() - } catch { - /* type not present in this file */ + const spaces = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSPACE) + for (let i = 0; i < spaces.size(); i++) { + try { + const spaceExpressId = spaces.get(i) + if (expressIdToNodeId.has(spaceExpressId)) continue + const space = ifcApi.GetLine(modelID, spaceExpressId) + const parentNodeId = resolveElementParent(spaceExpressId) + const primitives = importedMeshPrimitivesFor(spaceExpressId) + let polygon: [number, number][] | null = null + let footprintApproximated = false + let ceilingHeight = DEFAULT_LEVEL_HEIGHT + try { + const worldMat = space.ObjectPlacement?.value + ? resolveWorldTransform(ifcApi, modelID, space.ObjectPlacement.value) + : identity() + const body = getBodyExtrusionData(ifcApi, modelID, space) + const extrusionMat = getExtrusionPosition(ifcApi, modelID, space) + if (body.profilePoints && body.profilePoints.length >= 3) { + const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat + polygon = body.profilePoints.map((point) => { + const scene = worldToScene(transformPoint3(combinedMat, [point[0], point[1], 0])) + return [scene[0], scene[1]] as [number, number] + }) + const first = polygon[0] + const last = polygon.at(-1) + if ( + polygon.length > 3 && + last && + Math.abs(first[0] - last[0]) < 1e-6 && + Math.abs(first[1] - last[1]) < 1e-6 + ) { + polygon.pop() + } + } + if (body.depth) ceilingHeight = body.depth * unitFactor + } catch { + /* mesh fallback below */ + } + if (!polygon) { + polygon = meshFootprint(primitives, opts.swapYZ) + footprintApproximated = polygon !== null + } + if (!polygon || polygon.length < 3) continue + if (primitives.length > 0) { + const heightAxis = opts.swapYZ ? 1 : 2 + const heights = primitives.flatMap((primitive) => + primitive.positions.filter((_, index) => index % 3 === heightAxis), + ) + if (heights.length > 0) ceilingHeight = Math.max(...heights) - Math.min(...heights) + } + + const spaceName = space.Name?.value + const longName = space.LongName?.value + const roomNumberCandidate = + longName && spaceName !== longName ? (spaceName ?? '').trim() : '' + const roomNumber = roomNumberCandidate.length <= 32 ? roomNumberCandidate : '' + const nodeId = generateId('zone') + const zone = tryParse(ZoneNode, 'zone', { + object: 'node', + id: nodeId, + type: 'zone', + name: longName || spaceName || `Space ${i + 1}`, + parentId: parentNodeId, + visible: true, + polygon, + spaceRole: 'room', + roomNumber, + ceilingHeight: Math.max(0.1, ceilingHeight), + metadata: buildMetadata({ + ifcType: 'IFCSPACE', + expressID: spaceExpressId, + globalId: space.GlobalId?.value, + predefinedType: space.PredefinedType?.value, + ifcName: spaceName, + footprintApproximated: footprintApproximated || undefined, + }), + }) + expressIdToNodeId.set(spaceExpressId, nodeId) + nodes[nodeId] = zone + attachNodeToGraph(nodeId, parentNodeId) + importedSpaceCount++ + } catch { + /* skip malformed space */ + } } - } - if (skippedBeamCount > 0) { - console.warn( - `[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`, - ) + } catch { + /* IFC schema may not expose spaces */ } - // Items: skipped for now — Pascal's ItemNode requires a full `asset` - // (catalog reference with id/src/dimensions/etc.) that the converter - // can't synthesise from raw IFC geometry. When the editor grows a - // raw-geometry escape hatch (or we add a placeholder-asset registry), - // restore the mapping from the pre-migration git history. We still - // walk the entities to log a count for diagnostics. - let skippedItemCount = 0 - const itemTypeKeys = [ - WebIFC.IFCFURNISHINGELEMENT, - WebIFC.IFCBUILDINGELEMENTPROXY, - WebIFC.IFCRAILING, - WebIFC.IFCCOVERING, - WebIFC.IFCCURTAINWALL, - WebIFC.IFCPLATE, - WebIFC.IFCMEMBER, - WebIFC.IFCFOOTING, - ] - for (const itemType of itemTypeKeys) { + // Preserve every unsupported building element as serialized triangle + // geometry. Failed native walls/slabs are included so unusual BRep or + // mapped geometry remains visible instead of silently disappearing. + const fallbackTypes = new Map() + const addFallbackType = (value: unknown, label: string) => { + if (typeof value === 'number' && value > 0) fallbackTypes.set(value, label) + } + addFallbackType(WebIFC.IFCBEAM, 'IFCBEAM') + addFallbackType(WebIFC.IFCBEAMSTANDARDCASE, 'IFCBEAMSTANDARDCASE') + addFallbackType(WebIFC.IFCFURNISHINGELEMENT, 'IFCFURNISHINGELEMENT') + addFallbackType(WebIFC.IFCBUILDINGELEMENTPROXY, 'IFCBUILDINGELEMENTPROXY') + addFallbackType(WebIFC.IFCRAILING, 'IFCRAILING') + addFallbackType(WebIFC.IFCCOVERING, 'IFCCOVERING') + addFallbackType(WebIFC.IFCCURTAINWALL, 'IFCCURTAINWALL') + addFallbackType(WebIFC.IFCPLATE, 'IFCPLATE') + addFallbackType(WebIFC.IFCMEMBER, 'IFCMEMBER') + addFallbackType(WebIFC.IFCFOOTING, 'IFCFOOTING') + addFallbackType(WebIFC.IFCSTAIRFLIGHT, 'IFCSTAIRFLIGHT') + addFallbackType(WebIFC.IFCSPACE, 'IFCSPACE') + addFallbackType(WebIFC.IFCWALL, 'IFCWALL') + addFallbackType(WebIFC.IFCWALLSTANDARDCASE, 'IFCWALLSTANDARDCASE') + addFallbackType(WebIFC.IFCSLAB, 'IFCSLAB') + addFallbackType(WebIFC.IFCWINDOW, 'IFCWINDOW') + addFallbackType(WebIFC.IFCWINDOWSTANDARDCASE, 'IFCWINDOWSTANDARDCASE') + addFallbackType(WebIFC.IFCDOOR, 'IFCDOOR') + addFallbackType(WebIFC.IFCDOORSTANDARDCASE, 'IFCDOORSTANDARDCASE') + + let importedMeshCount = 0 + for (const [ifcType, ifcTypeName] of fallbackTypes) { + let elements try { - const items = ifcApi.GetLineIDsWithType(modelID, itemType) - skippedItemCount += items.size() + elements = ifcApi.GetLineIDsWithType(modelID, ifcType) } catch { - /* type not present in this file */ + continue + } + for (let i = 0; i < elements.size(); i++) { + const expressId = elements.get(i) + if (expressIdToNodeId.has(expressId)) continue + const element = ifcApi.GetLine(modelID, expressId) + const parentNodeId = resolveElementParent(expressId) + const primitives = importedMeshPrimitivesFor(expressId) + if (primitives.length === 0) continue + + const nodeId = generateId('imesh') + const importedMesh = tryParse(ImportedMeshNode, 'imported mesh', { + object: 'node', + id: nodeId, + type: 'imported-mesh', + name: element.Name?.value || `${ifcTypeName} ${i + 1}`, + parentId: parentNodeId, + visible: true, + position: [0, 0, 0], + rotation: [0, 0, 0], + primitives, + metadata: buildMetadata({ + ifcType: ifcTypeName, + expressID: expressId, + globalId: element.GlobalId?.value, + predefinedType: element.PredefinedType?.value, + objectType: element.ObjectType?.value, + }), + }) + expressIdToNodeId.set(expressId, nodeId) + nodes[nodeId] = importedMesh + attachNodeToGraph(nodeId, parentNodeId) + importedMeshCount++ } - } - if (skippedItemCount > 0) { - console.warn( - `[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`, - ) } // Post-process: resolve levelId for all element nodes for (const node of Object.values(nodes)) { const m = meta(node) if (!m.expressID) continue - const storeyExpId = findStoreyForElement(m.expressID) + const storeyExpId = resolveStoreyForElement(m.expressID) if (storeyExpId != null) { m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined } @@ -1979,6 +2324,15 @@ export async function convertIfcToPascal( /* no property rels */ } + // Door presentation is derived only from standardized IFC semantics. + // OperationType is available on IfcDoor itself; glazing is conventionally + // carried by Pset_DoorCommon.GlazingAreaFraction. + for (const node of Object.values(nodes)) { + if (node.type !== 'door') continue + const glazingAreaFraction = meta(node).properties?.Pset_DoorCommon?.GlazingAreaFraction + Object.assign(node, doorGlazingStyle(node, glazingAreaFraction)) + } + // Materials via IFCRELASSOCIATESMATERIAL try { const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL) @@ -2089,8 +2443,8 @@ export async function convertIfcToPascal( stairs: Object.values(nodes).filter((n) => n.type === 'stair').length, roofs: Object.values(nodes).filter((n) => n.type === 'roof').length, columns: Object.values(nodes).filter((n) => n.type === 'column').length, - skippedBeams: skippedBeamCount, - skippedItems: skippedItemCount, + spaces: importedSpaceCount, + importedMeshes: importedMeshCount, }) progress('Complete!', 100) diff --git a/packages/ifc-converter/src/storey-semantics.ts b/packages/ifc-converter/src/storey-semantics.ts new file mode 100644 index 0000000000..4d69c287b4 --- /dev/null +++ b/packages/ifc-converter/src/storey-semantics.ts @@ -0,0 +1,13 @@ +export type StoreyElevation = { + expressId: number + elevation: number +} + +export function selectStoreyForElevation( + candidates: StoreyElevation[], + elementElevation: number, +): number | null { + const ordered = [...candidates].sort((a, b) => a.elevation - b.elevation) + const atOrBelow = ordered.filter((candidate) => candidate.elevation <= elementElevation + 0.1) + return atOrBelow.at(-1)?.expressId ?? ordered.at(0)?.expressId ?? null +} diff --git a/packages/ifc-converter/tests/door-semantics.test.ts b/packages/ifc-converter/tests/door-semantics.test.ts new file mode 100644 index 0000000000..16b96b671f --- /dev/null +++ b/packages/ifc-converter/tests/door-semantics.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test' +import { DoorNode } from '@pascal-app/core' +import { doorGlazingStyle, doorStyleFromIfcOperation } from '../src/door-semantics' + +function door(overrides: Record = {}) { + return DoorNode.parse({ + object: 'node', + id: 'door_test', + type: 'door', + name: 'Door', + parentId: null, + visible: true, + ...overrides, + }) +} + +describe('IFC door semantics', () => { + it('maps double sliding doors from IfcDoor.OperationType', () => { + expect(doorStyleFromIfcOperation('DOUBLE_DOOR_SLIDING')).toMatchObject({ + doorType: 'sliding', + leafCount: 2, + slideDirection: 'left', + trackStyle: 'visible', + threshold: false, + }) + }) + + it('preserves the standardized sliding direction', () => { + expect(doorStyleFromIfcOperation('SLIDING_TO_RIGHT')).toMatchObject({ + doorType: 'sliding', + leafCount: 1, + slideDirection: 'right', + }) + }) + + it('preserves single- and double-leaf folding operations', () => { + expect(doorStyleFromIfcOperation('FOLDING_TO_LEFT')).toMatchObject({ + doorType: 'folding', + leafCount: 1, + }) + expect(doorStyleFromIfcOperation('DOUBLE_DOOR_FOLDING')).toMatchObject({ + doorType: 'folding', + leafCount: 2, + }) + }) + + it('turns a glazed double hinged door into a French door', () => { + const style = doorStyleFromIfcOperation('DOUBLE_DOOR_SINGLE_SWING') + const glazing = doorGlazingStyle(door(style), 0.75) + + expect(glazing.doorType).toBe('french') + expect(glazing.segments).toEqual([expect.objectContaining({ type: 'glass', heightRatio: 1 })]) + }) + + it('does not infer glazing when the IFC property is absent or zero', () => { + expect(doorGlazingStyle(door(), undefined)).toEqual({}) + expect(doorGlazingStyle(door(), 0)).toEqual({}) + }) +}) diff --git a/packages/ifc-converter/tests/imported-mesh-conversion.test.ts b/packages/ifc-converter/tests/imported-mesh-conversion.test.ts new file mode 100644 index 0000000000..2c5f7e4974 --- /dev/null +++ b/packages/ifc-converter/tests/imported-mesh-conversion.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { AnyNode, ImportedMeshNode, WallNode, ZoneNode } from '@pascal-app/core' +import { type ConversionOptions, convertIfcToPascal, type PascalSceneGraph } from '../src' + +const fixturesDirectory = fileURLToPath( + new URL('../../../apps/ifc-converter/public/test-ifc-files/', import.meta.url), +) +const wasmPath = fileURLToPath(new URL('../../../node_modules/web-ifc/', import.meta.url)) + +async function convertFixture( + name: string, + transform?: (source: string) => string, + options?: ConversionOptions, +) { + const source = await readFile(`${fixturesDirectory}${name}`) + const data = transform + ? new TextEncoder().encode(transform(new TextDecoder().decode(source))) + : source + return convertIfcToPascal(data, undefined, { simplify: false, wasmPath, ...options }) +} + +function metadata(node: AnyNode): Record { + return (node.metadata ?? {}) as Record +} + +function importedMeshes(scene: PascalSceneGraph): ImportedMeshNode[] { + return Object.values(scene.nodes).filter( + (node): node is ImportedMeshNode => node.type === 'imported-mesh', + ) +} + +type PlanBounds = { minX: number; maxX: number; minZ: number; maxZ: number } + +function importedMeshPlanBounds(meshes: ImportedMeshNode[], secondPlanAxis = 2): PlanBounds { + const bounds: PlanBounds = { + minX: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + } + for (const mesh of meshes) { + for (const primitive of mesh.primitives) { + for (let index = 0; index + 2 < primitive.positions.length; index += 3) { + bounds.minX = Math.min(bounds.minX, primitive.positions[index]!) + bounds.maxX = Math.max(bounds.maxX, primitive.positions[index]!) + bounds.minZ = Math.min(bounds.minZ, primitive.positions[index + secondPlanAxis]!) + bounds.maxZ = Math.max(bounds.maxZ, primitive.positions[index + secondPlanAxis]!) + } + } + } + return bounds +} + +function wallPlanBounds(scene: PascalSceneGraph): PlanBounds { + const walls = Object.values(scene.nodes).filter((node): node is WallNode => node.type === 'wall') + return walls.reduce( + (bounds, wall) => { + const halfThickness = (wall.thickness ?? 0) / 2 + return { + minX: Math.min(bounds.minX, wall.start[0] - halfThickness, wall.end[0] - halfThickness), + maxX: Math.max(bounds.maxX, wall.start[0] + halfThickness, wall.end[0] + halfThickness), + minZ: Math.min(bounds.minZ, wall.start[1] - halfThickness, wall.end[1] - halfThickness), + maxZ: Math.max(bounds.maxZ, wall.start[1] + halfThickness, wall.end[1] + halfThickness), + } + }, + { + minX: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + }, + ) +} + +let openHouse: Promise | undefined +function openHouseScene() { + openHouse ??= convertFixture('04-ifc-open-house.ifc') + return openHouse +} + +let openHouseWithoutAxisSwap: Promise | undefined +function openHouseWithoutAxisSwapScene() { + openHouseWithoutAxisSwap ??= convertFixture('04-ifc-open-house.ifc', undefined, { + swapYZ: false, + }) + return openHouseWithoutAxisSwap +} + +let openHouseWithoutStorey: Promise | undefined +function openHouseWithoutStoreyScene() { + openHouseWithoutStorey ??= convertFixture('04-ifc-open-house.ifc', (source) => + source.replace('IFCBUILDINGSTOREY(', 'IFCBUILDINGELEMENTPROXY('), + ) + return openHouseWithoutStorey +} + +function reachableNodeIds(scene: PascalSceneGraph): Set { + const reachable = new Set() + const visit = (nodeId: string) => { + if (reachable.has(nodeId)) return + reachable.add(nodeId) + const node = scene.nodes[nodeId] + if (node && 'children' in node) { + for (const childId of node.children) visit(childId) + } + } + for (const rootNodeId of scene.rootNodeIds) visit(rootNodeId) + return reachable +} + +let duplex: Promise | undefined +function duplexScene() { + duplex ??= convertFixture('01-duplex.ifc') + return duplex +} + +let duplexWithoutAxisSwap: Promise | undefined +function duplexWithoutAxisSwapScene() { + duplexWithoutAxisSwap ??= convertFixture('01-duplex.ifc', undefined, { + swapYZ: false, + }) + return duplexWithoutAxisSwap +} + +let duplexWithMissingSpaceName: Promise | undefined +function duplexWithMissingSpaceNameScene() { + duplexWithMissingSpaceName ??= convertFixture('01-duplex.ifc', (source) => + source.replace( + /(#157= IFCSPACE\('[^']+',#41,)'A102'/, + (_match, prefix: string) => `${prefix}$`, + ), + ) + return duplexWithMissingSpaceName +} + +const longRoomNumber = 'ROOM-NUMBER-THAT-IS-LONGER-THAN-THIRTY-TWO-CHARACTERS' +let duplexWithLongRoomNumber: Promise | undefined +function duplexWithLongRoomNumberScene() { + duplexWithLongRoomNumber ??= convertFixture('01-duplex.ifc', (source) => + source.replace( + /(#157= IFCSPACE\('[^']+',#41,)'A102'/, + (_match, prefix: string) => `${prefix}'${longRoomNumber}'`, + ), + ) + return duplexWithLongRoomNumber +} + +describe('IFC imported mesh conversion', () => { + test('keeps millimetre mesh geometry aligned with native walls', async () => { + const scene = await openHouseScene() + const meshBounds = importedMeshPlanBounds(importedMeshes(scene)) + const nativeBounds = wallPlanBounds(scene) + + expect(meshBounds.maxX - meshBounds.minX).toBeGreaterThan(9) + expect(meshBounds.maxZ - meshBounds.minZ).toBeGreaterThan(5) + expect(Math.abs(meshBounds.minX - nativeBounds.minX)).toBeLessThan(1) + expect(Math.abs(meshBounds.maxX - nativeBounds.maxX)).toBeLessThan(1) + expect(Math.abs(meshBounds.minZ - nativeBounds.minZ)).toBeLessThan(1) + expect(Math.abs(meshBounds.maxZ - nativeBounds.maxZ)).toBeLessThan(1) + }, 30_000) + + test('keeps flat meshes aligned when STEP axis swapping is disabled', async () => { + const scene = await openHouseWithoutAxisSwapScene() + const meshBounds = importedMeshPlanBounds(importedMeshes(scene), 1) + const nativeBounds = wallPlanBounds(scene) + + expect(Math.abs(meshBounds.minX - nativeBounds.minX)).toBeLessThan(1) + expect(Math.abs(meshBounds.maxX - nativeBounds.maxX)).toBeLessThan(1) + expect(Math.abs(meshBounds.minZ - nativeBounds.minZ)).toBeLessThan(1) + expect(Math.abs(meshBounds.maxZ - nativeBounds.maxZ)).toBeLessThan(1) + }, 30_000) + + test('keeps mesh-derived room heights stable when STEP axis swapping is disabled', async () => { + const [defaultScene, unswappedScene] = await Promise.all([ + duplexScene(), + duplexWithoutAxisSwapScene(), + ]) + const defaultZones = Object.values(defaultScene.nodes).filter( + (node): node is ZoneNode => node.type === 'zone', + ) + const unswappedZonesByExpressId = new Map( + Object.values(unswappedScene.nodes) + .filter((node): node is ZoneNode => node.type === 'zone') + .map((zone) => [metadata(zone).expressID, zone]), + ) + + expect(defaultZones.length).toBeGreaterThan(0) + for (const zone of defaultZones) { + const unswappedZone = unswappedZonesByExpressId.get(metadata(zone).expressID) + expect(unswappedZone).toBeDefined() + expect(Math.abs(zone.ceilingHeight - unswappedZone!.ceilingHeight)).toBeLessThan(0.001) + } + }, 30_000) + + test('keeps converted nodes reachable when the IFC has no building storey', async () => { + const scene = await openHouseWithoutStoreyScene() + + expect(Object.values(scene.nodes).filter((node) => node.type === 'level')).toHaveLength(0) + expect(importedMeshes(scene).length).toBeGreaterThan(0) + expect(reachableNodeIds(scene).size).toBe(Object.keys(scene.nodes).length) + }, 30_000) + + test('preserves roof slabs as imported geometry when a mesh is available', async () => { + const scene = await openHouseScene() + const roofSlabs = importedMeshes(scene).filter( + (node) => + metadata(node).ifcType === 'IFCSLAB' && + String(metadata(node).predefinedType).toUpperCase() === 'ROOF', + ) + + expect(roofSlabs).toHaveLength(2) + }, 30_000) + + test('rounds serialized positions and normals to the storage precision', async () => { + const scene = await openHouseScene() + for (const mesh of importedMeshes(scene)) { + for (const primitive of mesh.primitives) { + for (const value of primitive.positions) { + expect(Math.abs(value * 10_000 - Math.round(value * 10_000))).toBeLessThan(1e-8) + } + for (const value of primitive.normals ?? []) { + expect(Math.abs(value * 1000 - Math.round(value * 1000))).toBeLessThan(1e-8) + } + } + } + }, 30_000) + + test('preserves stair flight meshes and continues after a space with no Name', async () => { + const scene = await duplexWithMissingSpaceNameScene() + const nodes = Object.values(scene.nodes) + + expect(nodes.filter((node) => node.type === 'zone')).toHaveLength(21) + expect( + nodes.some((node) => node.type === 'zone' && metadata(node).footprintApproximated === true), + ).toBe(true) + expect(nodes.filter((node) => node.type === 'stair')).toHaveLength(0) + expect( + nodes.filter( + (node) => node.type === 'imported-mesh' && metadata(node).ifcType === 'IFCSTAIRFLIGHT', + ), + ).toHaveLength(2) + }, 30_000) + + 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', + ) + const changedZone = zones.find((zone) => metadata(zone).expressID === 157) + + expect(zones).toHaveLength(21) + expect(changedZone).toBeDefined() + expect(changedZone!.roomNumber).toBe('') + expect(metadata(changedZone!).ifcName).toBe(longRoomNumber) + }, 30_000) +}) diff --git a/packages/ifc-converter/tests/storey-semantics.test.ts b/packages/ifc-converter/tests/storey-semantics.test.ts new file mode 100644 index 0000000000..2b9061d064 --- /dev/null +++ b/packages/ifc-converter/tests/storey-semantics.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test' +import { selectStoreyForElevation } from '../src/storey-semantics' + +const storeys = [ + { expressId: 30, elevation: 6 }, + { expressId: 10, elevation: -1.25 }, + { expressId: 20, elevation: 3.1 }, +] + +describe('selectStoreyForElevation', () => { + it('uses the lowest storey for an element below every storey', () => { + expect(selectStoreyForElevation(storeys, -2)).toBe(10) + }) + + it('uses the nearest storey at or below the element', () => { + expect(selectStoreyForElevation(storeys, 4)).toBe(20) + expect(selectStoreyForElevation(storeys, 8)).toBe(30) + }) + + it('returns null when no storey is available', () => { + expect(selectStoreyForElevation([], 0)).toBeNull() + }) +}) diff --git a/packages/nodes/src/imported-mesh/__tests__/geometry.test.ts b/packages/nodes/src/imported-mesh/__tests__/geometry.test.ts new file mode 100644 index 0000000000..f88f3359a3 --- /dev/null +++ b/packages/nodes/src/imported-mesh/__tests__/geometry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' +import { ImportedMeshNode } from '@pascal-app/core' +import type { Mesh } from 'three' +import { importedMeshDefinition } from '../definition' +import { buildImportedMeshGeometry } from '../geometry' + +describe('buildImportedMeshGeometry', () => { + test('builds indexed colored triangle primitives', () => { + const node = ImportedMeshNode.parse({ + id: 'imesh_test', + type: 'imported-mesh', + primitives: [ + { + positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], + indices: [0, 1, 2], + color: '#ff0000', + }, + ], + }) + const group = buildImportedMeshGeometry(node) + expect(group.children).toHaveLength(1) + const mesh = group.children[0] as Mesh + expect(mesh.geometry.getAttribute('position').count).toBe(3) + expect(mesh.geometry.index?.count).toBe(3) + }) + + test('is selectable and deletable but not movable', () => { + expect(importedMeshDefinition.capabilities.selectable).toBeDefined() + expect(importedMeshDefinition.capabilities.deletable).toBe(true) + expect('movable' in importedMeshDefinition.capabilities).toBe(false) + }) +}) diff --git a/packages/nodes/src/imported-mesh/definition.ts b/packages/nodes/src/imported-mesh/definition.ts new file mode 100644 index 0000000000..dc87ab2921 --- /dev/null +++ b/packages/nodes/src/imported-mesh/definition.ts @@ -0,0 +1,39 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildImportedMeshFloorplan } from './floorplan' +import { buildImportedMeshGeometry } from './geometry' +import { ImportedMeshNode } from './schema' + +/** Format-neutral fallback for imported objects without a parametric node. */ +export const importedMeshDefinition: NodeDefinition = { + kind: 'imported-mesh', + schemaVersion: 1, + schema: ImportedMeshNode, + category: 'structure', + snapProfile: 'item', + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + primitives: [], + }), + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + presettable: false, + }, + geometry: buildImportedMeshGeometry, + floorplan: buildImportedMeshFloorplan, + presentation: { + label: 'Imported Mesh', + description: 'Geometry preserved from an imported model when no native Pascal shape exists.', + icon: { kind: 'url', src: '/icons/item.webp' }, + hidden: true, + }, + mcp: { + description: 'Imported triangle geometry with source identity and properties in metadata.', + }, +} diff --git a/packages/nodes/src/imported-mesh/floorplan.ts b/packages/nodes/src/imported-mesh/floorplan.ts new file mode 100644 index 0000000000..71452edf9e --- /dev/null +++ b/packages/nodes/src/imported-mesh/floorplan.ts @@ -0,0 +1,44 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + ImportedMeshNode, +} from '@pascal-app/core' + +/** A compact plan proxy for imported geometry, derived from its XZ bounds. */ +export function buildImportedMeshFloorplan( + node: ImportedMeshNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const primitive of node.primitives) { + for (let i = 0; i + 2 < primitive.positions.length; i += 3) { + minX = Math.min(minX, primitive.positions[i]!) + maxX = Math.max(maxX, primitive.positions[i]!) + minZ = Math.min(minZ, primitive.positions[i + 2]!) + maxZ = Math.max(maxZ, primitive.positions[i + 2]!) + } + } + if (![minX, maxX, minZ, maxZ].every(Number.isFinite)) return null + + const yaw = node.rotation[1] + const cos = Math.cos(-yaw) + const sin = Math.sin(-yaw) + const toPlan = (x: number, z: number): FloorplanPoint => [ + node.position[0] + x * cos - z * sin, + node.position[2] + x * sin + z * cos, + ] + const selected = Boolean(ctx.viewState?.selected || ctx.viewState?.highlighted) + return { + kind: 'polygon', + points: [toPlan(minX, minZ), toPlan(maxX, minZ), toPlan(maxX, maxZ), toPlan(minX, maxZ)], + fill: '#94a3b8', + fillOpacity: selected ? 0.28 : 0.14, + stroke: selected ? (ctx.viewState?.palette?.selectedStroke ?? '#f97316') : '#64748b', + strokeWidth: selected ? 0.04 : 0.025, + vectorEffect: 'non-scaling-stroke', + } +} diff --git a/packages/nodes/src/imported-mesh/geometry.ts b/packages/nodes/src/imported-mesh/geometry.ts new file mode 100644 index 0000000000..aa32597da1 --- /dev/null +++ b/packages/nodes/src/imported-mesh/geometry.ts @@ -0,0 +1,41 @@ +import type { ImportedMeshNode } from '@pascal-app/core' +import { + BufferGeometry, + Float32BufferAttribute, + FrontSide, + Group, + Mesh, + MeshStandardMaterial, +} from 'three' + +/** Build serialized imported triangle buffers without source-format coupling. */ +export function buildImportedMeshGeometry(node: ImportedMeshNode): Group { + const group = new Group() + for (const [primitiveIndex, primitive] of node.primitives.entries()) { + if (primitive.positions.length < 9) continue + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(primitive.positions, 3)) + if (primitive.normals?.length === primitive.positions.length) { + geometry.setAttribute('normal', new Float32BufferAttribute(primitive.normals, 3)) + } else { + geometry.computeVertexNormals() + } + if (primitive.indices.length >= 3) geometry.setIndex(primitive.indices) + geometry.computeBoundingBox() + geometry.computeBoundingSphere() + + const material = new MeshStandardMaterial({ + color: primitive.color, + opacity: primitive.opacity, + transparent: primitive.opacity < 1, + depthWrite: primitive.opacity >= 1, + metalness: 0.05, + roughness: 0.8, + side: FrontSide, + }) + const mesh = new Mesh(geometry, material) + mesh.name = `${node.name ?? 'Imported mesh'} primitive ${primitiveIndex + 1}` + group.add(mesh) + } + return group +} diff --git a/packages/nodes/src/imported-mesh/index.ts b/packages/nodes/src/imported-mesh/index.ts new file mode 100644 index 0000000000..6875c456fe --- /dev/null +++ b/packages/nodes/src/imported-mesh/index.ts @@ -0,0 +1,3 @@ +export { importedMeshDefinition } from './definition' +export { buildImportedMeshGeometry } from './geometry' +export { ImportedMeshNode } from './schema' diff --git a/packages/nodes/src/imported-mesh/schema.ts b/packages/nodes/src/imported-mesh/schema.ts new file mode 100644 index 0000000000..5ca4e96227 --- /dev/null +++ b/packages/nodes/src/imported-mesh/schema.ts @@ -0,0 +1 @@ +export { ImportedMeshNode } from '@pascal-app/core' diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index b9040fba74..5f7ece833e 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -19,6 +19,7 @@ import { fenceDefinition } from './fence' import { guideDefinition } from './guide' import { gutterDefinition } from './gutter' import { hvacEquipmentDefinition } from './hvac-equipment' +import { importedMeshDefinition } from './imported-mesh' import { itemDefinition } from './item' import { levelDefinition } from './level' import { linesetDefinition } from './lineset' @@ -77,6 +78,7 @@ export const builtinPlugin: Plugin = { cabinetDefinition as unknown as AnyNodeDefinition, cabinetModuleDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, + importedMeshDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. columnDefinition as unknown as AnyNodeDefinition, @@ -148,6 +150,7 @@ export { fenceDefinition } from './fence' export { guideDefinition } from './guide' export { gutterDefinition } from './gutter' export { hvacEquipmentDefinition } from './hvac-equipment' +export { importedMeshDefinition } from './imported-mesh' export { itemDefinition } from './item' export { levelDefinition } from './level' export { linesetDefinition } from './lineset'