diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5684e37809..0c406d238c 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -24,6 +24,7 @@ import type { GuideNode, GutterNode, HvacEquipmentNode, + ImportedMeshNode, ItemNode, LevelNode, LinesetNode, @@ -93,6 +94,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 @@ -296,6 +298,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 a477a88f4c..c671be4def 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,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 fd0e8f7f8f..4fbf59973c 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -118,6 +118,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 60d15aa9bb..07dbe302f1 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 2c980bfca5..96eccb276d 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -20,6 +20,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' @@ -59,6 +60,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..06a4a5d7ac 100644 --- a/packages/ifc-converter/README.md +++ b/packages/ifc-converter/README.md @@ -5,3 +5,18 @@ 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 for sites, buildings, levels, walls, doors, +windows, slabs, stairs, roofs, columns, and IFC spaces (as room zones). Beams, +railings, coverings, furnishings, proxies, curtain walls, plates, members, +footings, and native wall/slab shapes that cannot be parameterized 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/cleanup.ts b/packages/ifc-converter/src/cleanup.ts index 961ead4ecc..77c35ed231 100644 --- a/packages/ifc-converter/src/cleanup.ts +++ b/packages/ifc-converter/src/cleanup.ts @@ -199,13 +199,24 @@ function toWallSegment(wall: WallNode): WallSegment | null { } function wallLineTolerance(a: WallSegment, b: WallSegment) { - return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5)) + // Wall fragments must share essentially the same centerline. A tolerance based + // on half the wall thickness can collapse adjacent, parallel walls whose faces + // happen to meet (for example, a 4-inch exterior wall and an interior return + // offset by 2 inches). Keep a small allowance for IFC modelling noise instead. + return Math.max(0.005, Math.min(0.025, Math.max(a.thickness, b.thickness) * 0.1)) } function wallHeightCompatible(a: WallSegment, b: WallSegment) { return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE } +function wallMaterialCompatible(a: WallSegment, b: WallSegment) { + const materialA = (a.wall.metadata as { material?: unknown } | undefined)?.material + const materialB = (b.wall.metadata as { material?: unknown } | undefined)?.material + if (typeof materialA !== 'string' || typeof materialB !== 'string') return true + return materialA === materialB +} + function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) { const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1) if (gap <= maxJoinGap) return true @@ -220,6 +231,9 @@ function wallsCanMerge(a: WallSegment, b: WallSegment, maxJoinGap: number) { if (Math.abs(a.angleBucket - b.angleBucket) > 1) return false if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b)) return false if (!wallHeightCompatible(a, b)) return false + // Collinear walls can still represent different assemblies. Merging across + // that semantic boundary can bridge a real doorway or open connection. + if (!wallMaterialCompatible(a, b)) return false return wallIntervalsCompatible(a, b, maxJoinGap) } diff --git a/packages/ifc-converter/src/door-semantics.ts b/packages/ifc-converter/src/door-semantics.ts new file mode 100644 index 0000000000..9ec83cd138 --- /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: 2, + } + } + + 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..c0d73c0e3a 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -3,9 +3,12 @@ import { type AnyNodeId, BuildingNode, ColumnNode, + DEFAULT_LEVEL_HEIGHT, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, + ImportedMeshNode, + type ImportedMeshPrimitiveValue, LevelNode, RoofNode, SiteNode, @@ -13,10 +16,12 @@ import { 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' export type { IfcConversionSimplificationOptions, @@ -603,6 +608,172 @@ 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 extractImportedMeshPrimitives( + ifcApi: WebIFC.IfcAPI, + modelID: number, + expressID: number, + unitFactor: number, + originOffset: number[], + levelElevation: number, + toPascalPoint: PascalPointTransform, + 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. + if (swapYZ) { + positions.push( + (world[0]! - originOffset[0]!) * unitFactor, + (world[1]! - originOffset[2]!) * unitFactor - levelElevation, + -(world[2]! + originOffset[1]!) * unitFactor, + ) + } else { + positions.push( + ...toPascalPoint( + [ + (world[0]! - originOffset[0]!) * unitFactor, + (world[1]! - originOffset[1]!) * unitFactor, + (world[2]! - originOffset[2]!) * unitFactor, + ], + levelElevation, + ), + ) + } + + 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 + const normalLength = Math.hypot(...mappedNormal) || 1 + normals.push( + mappedNormal[0]! / normalLength, + mappedNormal[1]! / normalLength, + 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[]): [number, number][] | null { + const unique = new Map() + 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 + 2]!] + 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 +813,7 @@ export interface ConversionOptions { swapYZ?: boolean extrusionDepthIsHeight?: boolean swapProfileDimensions?: boolean + wasmPath?: string simplify?: boolean | IfcConversionSimplificationOptions label?: string } @@ -670,6 +842,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 +858,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) @@ -731,6 +904,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 +972,76 @@ 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 + }) + .sort( + (a, b) => + (storeyElevationByExpressId.get(a) ?? 0) - (storeyElevationByExpressId.get(b) ?? 0), + ) + + const atOrBelow = candidates.filter( + (candidate) => (storeyElevationByExpressId.get(candidate) ?? 0) <= elementElevation + 0.1, + ) + return atOrBelow.at(-1) ?? candidates.at(-1) ?? null + } + + 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) + } + progress('Processing sites...', 30) // Process sites const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE) @@ -900,6 +1150,7 @@ export async function convertIfcToPascal( } else { elevation *= unitFactor } + storeyElevationByExpressId.set(storeyExpressID, elevation) const levelNode = tryParse(LevelNode, 'level', { object: 'node', @@ -925,6 +1176,35 @@ export async function convertIfcToPascal( } } + // 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 + } + } + progress('Processing walls...', 60) // Build void/fill relationship maps for doors and windows @@ -978,8 +1258,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 +1305,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 +1340,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 @@ -1217,11 +1517,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 +1576,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 +1656,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 +1683,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 +1704,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 +1720,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 +1732,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 +1758,17 @@ 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') 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 +1782,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 +1830,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', @@ -1552,8 +1872,7 @@ export async function convertIfcToPascal( const nodeId = generateId('stair') expressIdToNodeId.set(stairExpressID, nodeId) - const parentExpressID = parentMap.get(stairExpressID) - const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null + const parentNodeId = resolveElementParent(stairExpressID) let position: [number, number, number] = [0, 0, 0] let boundingBox: [number, number, number] | undefined @@ -1563,7 +1882,7 @@ export async function convertIfcToPascal( ? 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]] + position = toPascalPoint(s, elementLevelElevation(stairExpressID)) // Try stair's own body first const body = getBodyExtrusionData(ifcApi, modelID, stair) @@ -1657,8 +1976,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 +1987,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 @@ -1759,8 +2077,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 +2091,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 @@ -1842,69 +2159,183 @@ export async function convertIfcToPascal( } } - // 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) + const spaces = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSPACE) + for (let i = 0; i < spaces.size(); i++) { + const spaceExpressId = spaces.get(i) + if (expressIdToNodeId.has(spaceExpressId)) continue + const space = ifcApi.GetLine(modelID, spaceExpressId) + const parentNodeId = resolveElementParent(spaceExpressId) + const levelElevation = elementLevelElevation(spaceExpressId) + const primitives = extractImportedMeshPrimitives( + ifcApi, + modelID, + spaceExpressId, + unitFactor, + originOffset, + levelElevation, + toPascalPoint, + opts.swapYZ, + ) + let polygon: [number, number][] | null = null + 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 */ + } + polygon ??= meshFootprint(primitives) + if (!polygon || polygon.length < 3) continue + if (primitives.length > 0) { + const ys = primitives.flatMap((primitive) => + primitive.positions.filter((_, index) => index % 3 === 1), + ) + if (ys.length > 0) ceilingHeight = Math.max(...ys) - Math.min(...ys) + } + + const nodeId = generateId('zone') + const zone = tryParse(ZoneNode, 'zone', { + object: 'node', + id: nodeId, + type: 'zone', + name: space.LongName?.value || space.Name?.value || `Space ${i + 1}`, + parentId: parentNodeId, + visible: true, + polygon, + spaceRole: 'room', + roomNumber: + space.LongName?.value && space.Name?.value !== space.LongName?.value + ? space.Name.value + : '', + ceilingHeight: Math.max(0.1, ceilingHeight), + metadata: buildMetadata({ + ifcType: 'IFCSPACE', + expressID: spaceExpressId, + globalId: space.GlobalId?.value, + predefinedType: space.PredefinedType?.value, + }), + }) + expressIdToNodeId.set(spaceExpressId, nodeId) + nodes[nodeId] = zone + if (parentNodeId && nodes[parentNodeId]) { + ;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId) + } + importedSpaceCount++ + } } catch { - /* not in all versions */ + /* IFC schema may not expose spaces */ + } + + // 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) } - for (const beamType of beamTypes) { + 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 beams = ifcApi.GetLineIDsWithType(modelID, beamType) - skippedBeamCount += beams.size() + elements = ifcApi.GetLineIDsWithType(modelID, ifcType) } catch { - /* type not present in this file */ + continue } - } - if (skippedBeamCount > 0) { - console.warn( - `[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`, - ) - } + 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 = extractImportedMeshPrimitives( + ifcApi, + modelID, + expressId, + unitFactor, + originOffset, + elementLevelElevation(expressId), + toPascalPoint, + opts.swapYZ, + ) + if (primitives.length === 0) continue - // Items: skipped for now — Pascal's ItemNode requires a full `asset` - // (catalog reference with id/src/dimensions/etc.) that the converter - // can't synthesise from raw IFC geometry. When the editor grows a - // raw-geometry escape hatch (or we add a placeholder-asset registry), - // restore the mapping from the pre-migration git history. We still - // walk the entities to log a count for diagnostics. - let skippedItemCount = 0 - const itemTypeKeys = [ - WebIFC.IFCFURNISHINGELEMENT, - WebIFC.IFCBUILDINGELEMENTPROXY, - WebIFC.IFCRAILING, - WebIFC.IFCCOVERING, - WebIFC.IFCCURTAINWALL, - WebIFC.IFCPLATE, - WebIFC.IFCMEMBER, - WebIFC.IFCFOOTING, - ] - for (const itemType of itemTypeKeys) { - try { - const items = ifcApi.GetLineIDsWithType(modelID, itemType) - skippedItemCount += items.size() - } catch { - /* type not present in this file */ + 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 + if (parentNodeId && nodes[parentNodeId]) { + ;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId) + } + 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 +2410,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 +2529,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/tests/cleanup.test.ts b/packages/ifc-converter/tests/cleanup.test.ts index e0544269c3..266e986f89 100644 --- a/packages/ifc-converter/tests/cleanup.test.ts +++ b/packages/ifc-converter/tests/cleanup.test.ts @@ -85,6 +85,36 @@ describe('simplifyConvertedSceneGraph', () => { expect((nodes.level_1 as { children: string[] }).children).toEqual([keptWall?.id]) }) + it('does not merge parallel wall fragments on centerlines offset by two inches', () => { + const nodes: Record = { + level_1: level('level_1', ['wall_a', 'wall_b']), + wall_a: wall('wall_a', [0, 0], [2, 0]), + wall_b: wall('wall_b', [2, 0.0508], [4, 0.0508]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + + it('does not merge collinear wall fragments with different IFC materials', () => { + const exterior = wall('wall_exterior', [0, 0], [2, 0]) + const interior = wall('wall_interior', [2.9, 0], [5, 0]) + exterior.metadata = { material: 'Exterior Finish Assembly' } + interior.metadata = { material: 'Interior Partition Assembly' } + const nodes: Record = { + level_1: level('level_1', ['wall_exterior', 'wall_interior']), + wall_exterior: exterior, + wall_interior: interior, + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + it('reprojects openings from removed walls onto the merged wall', () => { const nodes: Record = { level_1: level('level_1', ['wall_a', 'wall_b']), 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..3d72389466 --- /dev/null +++ b/packages/ifc-converter/tests/door-semantics.test.ts @@ -0,0 +1,48 @@ +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('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/nodes/src/imported-mesh/__tests__/geometry.test.ts b/packages/nodes/src/imported-mesh/__tests__/geometry.test.ts new file mode 100644 index 0000000000..735e1c0354 --- /dev/null +++ b/packages/nodes/src/imported-mesh/__tests__/geometry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' +import { ImportedMeshNode } from '@pascal-app/core' +import type { Mesh } from 'three' +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) + }) +}) diff --git a/packages/nodes/src/imported-mesh/definition.ts b/packages/nodes/src/imported-mesh/definition.ts new file mode 100644 index 0000000000..d0c23288a5 --- /dev/null +++ b/packages/nodes/src/imported-mesh/definition.ts @@ -0,0 +1,40 @@ +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' }, + movable: { axes: ['x', 'y', 'z'], gridSnap: true }, + 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 69a1aa1b25..fdc710e8ba 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -20,6 +20,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' @@ -78,6 +79,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, @@ -151,6 +153,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'