@pascal-app/ifc-converter 1.0.0-beta.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,15 +1,17 @@
1
- import { BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, LevelNode, RoofNode, SiteNode, SlabNode, StairNode, WallNode, WindowNode, } from '@pascal-app/core';
1
+ import { BlockNode, BuildingNode, ColumnNode, DEFAULT_LEVEL_HEIGHT, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, GROUND_SUPPORT_ID, ImportedMeshNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode, } from '@pascal-app/core';
2
2
  import { customAlphabet } from 'nanoid';
3
3
  import * as WebIFC from 'web-ifc';
4
+ import { extractBeamGeometry } from './beam-geometry';
4
5
  import { simplifyConvertedSceneGraph } from './cleanup';
6
+ import { doorGlazingStyle, doorStyleFromIfcOperation } from './door-semantics';
7
+ import { selectStoreyForElevation } from './storey-semantics';
5
8
  function meta(node) {
6
9
  return (node?.metadata ?? {});
7
10
  }
8
- // Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
9
- // type that doesn't accept `undefined` (JSON has `null`, not undefined).
10
- // The converter pulls many fields from optional IFC properties that
11
- // often return `undefined`; stripping them at the boundary keeps the
12
- // schemas happy without spraying `?? null` through every assignment.
11
+ // The converter pulls many metadata fields from optional IFC properties
12
+ // that often return `undefined`. Those keys vanish the moment the graph is
13
+ // serialized, so stripping them here keeps the in-memory scene identical to
14
+ // the persisted one instead of spraying `?? null` through every assignment.
13
15
  function buildMetadata(input) {
14
16
  const out = {};
15
17
  for (const [key, value] of Object.entries(input)) {
@@ -499,6 +501,146 @@ function wallHeightThicknessFromExtents(extents, knownLengthMeters, unitFactor)
499
501
  }
500
502
  return null;
501
503
  }
504
+ function sourceColor(color) {
505
+ const channel = (value) => Math.max(0, Math.min(255, Math.round((value ?? 0.58) * 255)))
506
+ .toString(16)
507
+ .padStart(2, '0');
508
+ return `#${channel(color?.x)}${channel(color?.y)}${channel(color?.z)}`;
509
+ }
510
+ function roundMeshPosition(value) {
511
+ const rounded = Math.round(value * 10_000) / 10_000;
512
+ return rounded === 0 ? 0 : rounded;
513
+ }
514
+ function roundMeshNormal(value) {
515
+ const rounded = Math.round(value * 1000) / 1000;
516
+ return rounded === 0 ? 0 : rounded;
517
+ }
518
+ function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, originOffset, levelElevation, swapYZ) {
519
+ let flatMesh;
520
+ try {
521
+ flatMesh = ifcApi.GetFlatMesh(modelID, expressID);
522
+ }
523
+ catch {
524
+ return [];
525
+ }
526
+ const primitives = [];
527
+ try {
528
+ for (let geometryIndex = 0; geometryIndex < flatMesh.geometries.size(); geometryIndex++) {
529
+ const placed = flatMesh.geometries.get(geometryIndex);
530
+ const matrix = placed.flatTransformation;
531
+ const geometry = ifcApi.GetGeometry(modelID, placed.geometryExpressID);
532
+ try {
533
+ const vertices = ifcApi.GetVertexArray(geometry.GetVertexData(), geometry.GetVertexDataSize());
534
+ const sourceIndices = ifcApi.GetIndexArray(geometry.GetIndexData(), geometry.GetIndexDataSize());
535
+ const positions = [];
536
+ const normals = [];
537
+ for (let vertex = 0; vertex + 5 < vertices.length; vertex += 6) {
538
+ const x = vertices[vertex];
539
+ const y = vertices[vertex + 1];
540
+ const z = vertices[vertex + 2];
541
+ const world = [
542
+ matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
543
+ matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
544
+ matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14],
545
+ ];
546
+ // `GetFlatMesh` does not use the same axes as the STEP placement
547
+ // data read by `resolveWorldTransform`: web-ifc has already mapped
548
+ // IFC Z-up coordinates to an X/Y-up/-Z frame. Applying the regular
549
+ // STEP `swapYZ` transform here a second time makes plan depth look
550
+ // like height (and height look like plan depth), exploding fallback
551
+ // walls and railings across the scene.
552
+ const mappedPosition = swapYZ
553
+ ? [
554
+ world[0] - originOffset[0] * unitFactor,
555
+ world[1] - originOffset[2] * unitFactor - levelElevation,
556
+ -(world[2] + originOffset[1] * unitFactor),
557
+ ]
558
+ : [
559
+ world[0] - originOffset[0] * unitFactor,
560
+ -(world[2] + originOffset[1] * unitFactor),
561
+ world[1] - originOffset[2] * unitFactor - levelElevation,
562
+ ];
563
+ positions.push(...mappedPosition.map(roundMeshPosition));
564
+ const nx = vertices[vertex + 3];
565
+ const ny = vertices[vertex + 4];
566
+ const nz = vertices[vertex + 5];
567
+ const worldNormal = [
568
+ matrix[0] * nx + matrix[4] * ny + matrix[8] * nz,
569
+ matrix[1] * nx + matrix[5] * ny + matrix[9] * nz,
570
+ matrix[2] * nx + matrix[6] * ny + matrix[10] * nz,
571
+ ];
572
+ const mappedNormal = swapYZ
573
+ ? [worldNormal[0], worldNormal[1], -worldNormal[2]]
574
+ : [worldNormal[0], -worldNormal[2], worldNormal[1]];
575
+ const normalLength = Math.hypot(...mappedNormal) || 1;
576
+ normals.push(roundMeshNormal(mappedNormal[0] / normalLength), roundMeshNormal(mappedNormal[1] / normalLength), roundMeshNormal(mappedNormal[2] / normalLength));
577
+ }
578
+ const indices = Array.from(sourceIndices);
579
+ if (swapYZ) {
580
+ for (let index = 0; index + 2 < indices.length; index += 3) {
581
+ const second = indices[index + 1];
582
+ indices[index + 1] = indices[index + 2];
583
+ indices[index + 2] = second;
584
+ }
585
+ }
586
+ if (positions.length >= 9 && indices.length >= 3) {
587
+ primitives.push({
588
+ positions,
589
+ normals,
590
+ indices,
591
+ color: sourceColor(placed.color),
592
+ opacity: Math.max(0, Math.min(1, placed.color?.w ?? 1)),
593
+ });
594
+ }
595
+ }
596
+ finally {
597
+ ;
598
+ geometry.delete?.();
599
+ }
600
+ }
601
+ }
602
+ catch {
603
+ return primitives;
604
+ }
605
+ finally {
606
+ flatMesh.delete?.();
607
+ }
608
+ return primitives;
609
+ }
610
+ function meshFootprint(primitives, swapYZ) {
611
+ const unique = new Map();
612
+ const secondPlanAxis = swapYZ ? 2 : 1;
613
+ for (const primitive of primitives) {
614
+ for (let i = 0; i + 2 < primitive.positions.length; i += 3) {
615
+ const point = [
616
+ primitive.positions[i],
617
+ primitive.positions[i + secondPlanAxis],
618
+ ];
619
+ unique.set(`${point[0].toFixed(5)}:${point[1].toFixed(5)}`, point);
620
+ }
621
+ }
622
+ const points = [...unique.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
623
+ if (points.length < 3)
624
+ return null;
625
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
626
+ const lower = [];
627
+ for (const point of points) {
628
+ while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0)
629
+ lower.pop();
630
+ lower.push(point);
631
+ }
632
+ const upper = [];
633
+ for (let i = points.length - 1; i >= 0; i--) {
634
+ const point = points[i];
635
+ while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0)
636
+ upper.pop();
637
+ upper.push(point);
638
+ }
639
+ lower.pop();
640
+ upper.pop();
641
+ const hull = [...lower, ...upper];
642
+ return hull.length >= 3 ? hull : null;
643
+ }
502
644
  function getExtrusionPosition(ifcApi, modelID, element) {
503
645
  try {
504
646
  if (!element.Representation?.value)
@@ -554,6 +696,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
554
696
  swapYZ: options?.swapYZ ?? true,
555
697
  extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
556
698
  swapProfileDimensions: options?.swapProfileDimensions ?? false,
699
+ wasmPath: options?.wasmPath ?? '/',
557
700
  };
558
701
  const simplificationOptions = options?.simplify === false
559
702
  ? { enabled: false }
@@ -566,13 +709,22 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
566
709
  };
567
710
  progress('Initializing IFC parser...', 0);
568
711
  const ifcApi = new WebIFC.IfcAPI();
569
- ifcApi.SetWasmPath('/', true);
712
+ ifcApi.SetWasmPath(opts.wasmPath, true);
570
713
  await ifcApi.Init();
571
714
  progress('Opening IFC model...', 10);
572
715
  const modelID = ifcApi.OpenModel(ifcData);
573
716
  console.log(`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`);
574
717
  const nodes = {};
575
718
  const rootNodeIds = [];
719
+ function attachNodeToGraph(nodeId, parentNodeId) {
720
+ const parent = parentNodeId ? nodes[parentNodeId] : undefined;
721
+ const children = parent && 'children' in parent ? parent.children : undefined;
722
+ if (children) {
723
+ children.push(nodeId);
724
+ return;
725
+ }
726
+ rootNodeIds.push(nodeId);
727
+ }
576
728
  // Maps to track relationships
577
729
  const parentMap = new Map();
578
730
  const childrenMap = new Map();
@@ -603,6 +755,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
603
755
  (worldPt[2] - originOffset[2]) * unitFactor,
604
756
  ];
605
757
  }
758
+ const toPascalPoint = (scenePoint, levelElevation) => opts.swapYZ
759
+ ? [scenePoint[0], scenePoint[2] - levelElevation, scenePoint[1]]
760
+ : [scenePoint[0], scenePoint[1], scenePoint[2] - levelElevation];
761
+ const storeyElevationByExpressId = new Map();
606
762
  // Collect storey expressIDs for level mapping
607
763
  const storeyExpressIds = new Set();
608
764
  const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
@@ -621,9 +777,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
621
777
  parentMap.set(childID, parentExpressID);
622
778
  });
623
779
  if (!childrenMap.has(parentExpressID)) {
624
- childrenMap.set(parentExpressID, []);
780
+ childrenMap.set(parentExpressID, new Set());
625
781
  }
626
- childrenMap.get(parentExpressID)?.push(...children);
782
+ for (const childID of children)
783
+ childrenMap.get(parentExpressID).add(childID);
627
784
  }
628
785
  }
629
786
  // Second pass: collect spatial containment
@@ -638,9 +795,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
638
795
  parentMap.set(childID, parentExpressID);
639
796
  });
640
797
  if (!childrenMap.has(parentExpressID)) {
641
- childrenMap.set(parentExpressID, []);
798
+ childrenMap.set(parentExpressID, new Set());
642
799
  }
643
- childrenMap.get(parentExpressID)?.push(...children);
800
+ for (const childID of children)
801
+ childrenMap.get(parentExpressID).add(childID);
644
802
  }
645
803
  }
646
804
  // Resolve containing storey for an element by walking the parent chain
@@ -653,6 +811,80 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
653
811
  }
654
812
  return null;
655
813
  }
814
+ // Some IFC authoring tools aggregate roof elements under IfcRoof ->
815
+ // IfcBuilding instead of spatially containing them in an IfcBuildingStorey.
816
+ // Infer the most appropriate storey from the element elevation so those
817
+ // elements still become reachable, level-local Pascal nodes.
818
+ function resolveStoreyForElement(expressId) {
819
+ const containedStorey = findStoreyForElement(expressId);
820
+ if (containedStorey != null)
821
+ return containedStorey;
822
+ let buildingExpressId = null;
823
+ let current = expressId;
824
+ for (let guard = 0; guard < 20 && current != null; guard++) {
825
+ const nodeId = expressIdToNodeId.get(current);
826
+ if (nodeId && nodes[nodeId]?.type === 'building') {
827
+ buildingExpressId = current;
828
+ break;
829
+ }
830
+ current = parentMap.get(current);
831
+ }
832
+ let elementElevation = Number.POSITIVE_INFINITY;
833
+ try {
834
+ const element = ifcApi.GetLine(modelID, expressId);
835
+ if (element.ObjectPlacement?.value) {
836
+ const matrix = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value);
837
+ elementElevation = worldToScene(transformPoint3(matrix, [0, 0, 0]))[2];
838
+ }
839
+ }
840
+ catch {
841
+ /* use highest storey */
842
+ }
843
+ const candidates = [...storeyExpressIds]
844
+ .filter((candidate) => {
845
+ if (buildingExpressId == null)
846
+ return true;
847
+ let ancestor = candidate;
848
+ for (let guard = 0; guard < 20 && ancestor != null; guard++) {
849
+ if (ancestor === buildingExpressId)
850
+ return true;
851
+ ancestor = parentMap.get(ancestor);
852
+ }
853
+ return false;
854
+ })
855
+ .map((candidate) => ({
856
+ expressId: candidate,
857
+ elevation: storeyElevationByExpressId.get(candidate) ?? 0,
858
+ }));
859
+ return selectStoreyForElevation(candidates, elementElevation);
860
+ }
861
+ function resolveElementParent(expressId) {
862
+ const storeyExpressId = resolveStoreyForElement(expressId);
863
+ if (storeyExpressId != null) {
864
+ const levelId = expressIdToNodeId.get(storeyExpressId);
865
+ if (levelId)
866
+ return levelId;
867
+ }
868
+ const parentExpressId = parentMap.get(expressId);
869
+ const parentNodeId = parentExpressId ? expressIdToNodeId.get(parentExpressId) : undefined;
870
+ const parentNode = parentNodeId ? nodes[parentNodeId] : undefined;
871
+ if (parentNode?.type === 'level')
872
+ return parentNodeId ?? null;
873
+ return null;
874
+ }
875
+ function elementLevelElevation(expressId) {
876
+ const storeyExpressId = resolveStoreyForElement(expressId);
877
+ return storeyExpressId == null ? 0 : (storeyElevationByExpressId.get(storeyExpressId) ?? 0);
878
+ }
879
+ const importedPrimitivesByExpressId = new Map();
880
+ function importedMeshPrimitivesFor(expressId) {
881
+ const cached = importedPrimitivesByExpressId.get(expressId);
882
+ if (cached)
883
+ return cached;
884
+ const primitives = extractImportedMeshPrimitives(ifcApi, modelID, expressId, unitFactor, originOffset, elementLevelElevation(expressId), opts.swapYZ);
885
+ importedPrimitivesByExpressId.set(expressId, primitives);
886
+ return primitives;
887
+ }
656
888
  progress('Processing sites...', 30);
657
889
  // Process sites
658
890
  const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
@@ -720,10 +952,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
720
952
  }),
721
953
  });
722
954
  nodes[nodeId] = buildingNode;
723
- if (parentNodeId && nodes[parentNodeId]) {
724
- ;
725
- nodes[parentNodeId].children?.push(nodeId);
726
- }
955
+ attachNodeToGraph(nodeId, parentNodeId);
727
956
  }
728
957
  progress('Processing levels...', 50);
729
958
  // Process building storeys (levels)
@@ -751,6 +980,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
751
980
  else {
752
981
  elevation *= unitFactor;
753
982
  }
983
+ storeyElevationByExpressId.set(storeyExpressID, elevation);
754
984
  const levelNode = tryParse(LevelNode, 'level', {
755
985
  object: 'node',
756
986
  id: nodeId,
@@ -768,9 +998,33 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
768
998
  }),
769
999
  });
770
1000
  nodes[nodeId] = levelNode;
771
- if (parentNodeId && nodes[parentNodeId]) {
772
- ;
773
- nodes[parentNodeId].children?.push(nodeId);
1001
+ attachNodeToGraph(nodeId, parentNodeId);
1002
+ }
1003
+ // Pascal stacks levels from their stored heights. Match those heights to
1004
+ // IFC storey elevations, while normalizing the lowest IFC storey to y=0.
1005
+ const storeysByBuilding = new Map();
1006
+ for (let i = 0; i < storeys.size(); i++) {
1007
+ const storeyExpressId = storeys.get(i);
1008
+ const buildingExpressId = parentMap.get(storeyExpressId) ?? null;
1009
+ const group = storeysByBuilding.get(buildingExpressId) ?? [];
1010
+ group.push(storeyExpressId);
1011
+ storeysByBuilding.set(buildingExpressId, group);
1012
+ }
1013
+ for (const group of storeysByBuilding.values()) {
1014
+ group.sort((a, b) => (storeyElevationByExpressId.get(a) ?? 0) - (storeyElevationByExpressId.get(b) ?? 0));
1015
+ for (let index = 0; index < group.length; index++) {
1016
+ const expressId = group[index];
1017
+ const nodeId = expressIdToNodeId.get(expressId);
1018
+ const level = nodeId ? nodes[nodeId] : undefined;
1019
+ if (level?.type !== 'level')
1020
+ continue;
1021
+ const nextExpressId = group[index + 1];
1022
+ const height = nextExpressId
1023
+ ? (storeyElevationByExpressId.get(nextExpressId) ?? 0) -
1024
+ (storeyElevationByExpressId.get(expressId) ?? 0)
1025
+ : DEFAULT_LEVEL_HEIGHT;
1026
+ level.level = index;
1027
+ level.height = height > 0.1 ? height : DEFAULT_LEVEL_HEIGHT;
774
1028
  }
775
1029
  }
776
1030
  progress('Processing walls...', 60);
@@ -823,8 +1077,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
823
1077
  const wall = ifcApi.GetLine(modelID, wallExpressID);
824
1078
  const nodeId = generateId('wall');
825
1079
  expressIdToNodeId.set(wallExpressID, nodeId);
826
- const parentExpressID = parentMap.get(wallExpressID);
827
- const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1080
+ const parentNodeId = resolveElementParent(wallExpressID);
828
1081
  let start = [0, 0];
829
1082
  let end = null;
830
1083
  let thickness;
@@ -866,16 +1119,33 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
866
1119
  const ys = body.profilePoints.map((p) => p[1]);
867
1120
  thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor;
868
1121
  }
869
- // If no axis polyline, derive wall length from profile or XDim
1122
+ // If no axis polyline, derive the centerline from the body's local
1123
+ // profile. IFC rectangle/profile dimensions are centered on the wall
1124
+ // placement origin. Treating that origin as an endpoint shifts every
1125
+ // such wall by half its own length (most visible on exterior walls).
870
1126
  if (!axisPts) {
871
- let wallLength = body.xDim;
872
- if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
873
- const xs = body.profilePoints.map((p) => p[0]);
874
- wallLength = Math.max(...xs) - Math.min(...xs);
1127
+ const extrusionMat = getExtrusionPosition(ifcApi, modelID, wall);
1128
+ const centerlineMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1129
+ let localStart = null;
1130
+ let localEnd = null;
1131
+ if (body.profilePoints && body.profilePoints.length >= 3) {
1132
+ const xs = body.profilePoints.map((point) => point[0]);
1133
+ const ys = body.profilePoints.map((point) => point[1]);
1134
+ const minX = Math.min(...xs);
1135
+ const maxX = Math.max(...xs);
1136
+ const centerY = (Math.min(...ys) + Math.max(...ys)) / 2;
1137
+ localStart = [minX, centerY, 0];
1138
+ localEnd = [maxX, centerY, 0];
1139
+ }
1140
+ else if (body.xDim) {
1141
+ localStart = [-body.xDim / 2, 0, 0];
1142
+ localEnd = [body.xDim / 2, 0, 0];
875
1143
  }
876
- if (wallLength) {
877
- const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]));
878
- end = [se[0], se[1]];
1144
+ if (localStart && localEnd) {
1145
+ const s0 = worldToScene(transformPoint3(centerlineMat, localStart));
1146
+ const s1 = worldToScene(transformPoint3(centerlineMat, localEnd));
1147
+ start = [s0[0], s0[1]];
1148
+ end = [s1[0], s1[1]];
879
1149
  }
880
1150
  }
881
1151
  }
@@ -883,8 +1153,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
883
1153
  // keep defaults
884
1154
  }
885
1155
  // Skip walls where we couldn't determine geometry
886
- if (!end)
1156
+ if (!end) {
1157
+ expressIdToNodeId.delete(wallExpressID);
887
1158
  continue;
1159
+ }
888
1160
  // Plain IFCWALL frequently carries Brep / mapped geometry rather
889
1161
  // than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
890
1162
  // read its height/thickness (only IFCWALLSTANDARDCASE reliably
@@ -941,10 +1213,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
941
1213
  }),
942
1214
  });
943
1215
  nodes[nodeId] = wallNode;
944
- if (parentNodeId && nodes[parentNodeId]) {
945
- ;
946
- nodes[parentNodeId].children?.push(nodeId);
947
- }
1216
+ attachNodeToGraph(nodeId, parentNodeId);
948
1217
  }
949
1218
  }
950
1219
  // Process doors and windows via void/fill relationships
@@ -973,6 +1242,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
973
1242
  const fillId = openingToFill.get(openingId);
974
1243
  if (!fillId)
975
1244
  continue;
1245
+ // IFC fills belong to at most one opening, which voids one host element.
1246
+ // Repeated or conflicting relationships must not emit another node.
1247
+ if (expressIdToNodeId.has(fillId))
1248
+ continue;
976
1249
  const isDoor = doorExpressIds.has(fillId);
977
1250
  const isWindow = windowExpressIds.has(fillId);
978
1251
  if (!isDoor && !isWindow)
@@ -1035,7 +1308,6 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1035
1308
  }
1036
1309
  if (isDoor) {
1037
1310
  const nodeId = generateId('door');
1038
- expressIdToNodeId.set(fillId, nodeId);
1039
1311
  // Vertical centering is now handled: door center Y = height/2 so the
1040
1312
  // opening sits at the correct position. Remaining caveat: door bottom
1041
1313
  // is assumed at floor y=0 (i.e. the door starts at the wall's base).
@@ -1051,19 +1323,21 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1051
1323
  width: width ?? 0.9,
1052
1324
  height: height ?? 2.1,
1053
1325
  position: doorPosition,
1326
+ ...doorStyleFromIfcOperation(element.OperationType?.value),
1054
1327
  metadata: buildMetadata({
1055
1328
  ifcType: 'IFCDOOR',
1056
1329
  expressID: fillId,
1057
1330
  globalId: element.GlobalId?.value,
1058
1331
  hostWallExpressID: wallExpressID,
1332
+ operationType: element.OperationType?.value,
1059
1333
  }),
1060
1334
  });
1061
1335
  nodes[nodeId] = doorNode;
1336
+ expressIdToNodeId.set(fillId, nodeId);
1062
1337
  wallNode.children.push(nodeId);
1063
1338
  }
1064
1339
  else {
1065
1340
  const nodeId = generateId('window');
1066
- expressIdToNodeId.set(fillId, nodeId);
1067
1341
  // TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
1068
1342
  // sillHeight stays read-only metadata until we resolve the window
1069
1343
  // schema (Pascal's WindowNode doesn't have sillHeight today —
@@ -1092,6 +1366,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1092
1366
  }),
1093
1367
  });
1094
1368
  nodes[nodeId] = windowNode;
1369
+ expressIdToNodeId.set(fillId, nodeId);
1095
1370
  wallNode.children.push(nodeId);
1096
1371
  }
1097
1372
  }
@@ -1106,8 +1381,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1106
1381
  // door/window → wall link is only implicit in the element's world
1107
1382
  // placement. We recover it by projecting the element's world position
1108
1383
  // onto the nearest wall segment. Anything farther than
1109
- // HOST_WALL_MAX_DIST from every wall stays parented to its spatial
1110
- // container at the origin — we have no basis to place it on a wall.
1384
+ // HOST_WALL_MAX_DIST from every wall is left for the exact imported-mesh
1385
+ // fallback below. A standalone Pascal door/window has wall-local
1386
+ // coordinates, so putting one at its spatial container's origin silently
1387
+ // moves it away from its IFC placement.
1111
1388
  const HOST_WALL_MAX_DIST = 1.0; // metres
1112
1389
  const wallInfos = [];
1113
1390
  for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
@@ -1179,6 +1456,12 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1179
1456
  try {
1180
1457
  const element = ifcApi.GetLine(modelID, fillId);
1181
1458
  const isDoor = doorExpressIds.has(fillId);
1459
+ // A Pascal WindowNode is always hosted vertically in a wall. Roof
1460
+ // windows need their full IFC transform, so leave skylights unmapped
1461
+ // here and preserve them as imported mesh geometry below.
1462
+ if (!isDoor && String(element.PredefinedType?.value ?? '').toUpperCase() === 'SKYLIGHT') {
1463
+ continue;
1464
+ }
1182
1465
  let width;
1183
1466
  let height;
1184
1467
  if (element.OverallWidth?.value)
@@ -1199,17 +1482,16 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1199
1482
  }
1200
1483
  const effWidth = width ?? (isDoor ? 0.9 : 1.0);
1201
1484
  const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null;
1202
- // When hosted, parent to (and live inside) the wall — same as the
1203
- // void/fill path. Otherwise fall back to the spatial container.
1204
- const containerExpressID = parentMap.get(fillId);
1205
- const containerNodeId = containerExpressID
1206
- ? (expressIdToNodeId.get(containerExpressID) ?? null)
1207
- : null;
1208
- const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId;
1485
+ // Native Pascal openings require a native Pascal wall. Preserve
1486
+ // unhosted openings as exact IFC meshes instead of inventing a
1487
+ // wall-local position at [0, 0, 0].
1488
+ if (!hosted)
1489
+ continue;
1490
+ // Parent to (and live inside) the wall — same as the void/fill path.
1491
+ const parentNodeId = hosted.info.nodeId;
1209
1492
  if (isDoor) {
1210
1493
  const h = height ?? 2.1;
1211
1494
  const nodeId = generateId('door');
1212
- expressIdToNodeId.set(fillId, nodeId);
1213
1495
  const doorNode = tryParse(DoorNode, 'door', {
1214
1496
  object: 'node',
1215
1497
  id: nodeId,
@@ -1219,17 +1501,18 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1219
1501
  visible: true,
1220
1502
  width: width ?? 0.9,
1221
1503
  height: h,
1222
- // Placed by nearest-wall projection; [0,0,0] only when no wall
1223
- // is within range (then it sits on its spatial container).
1224
- position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
1225
- ...(hosted ? { wallId: hosted.info.nodeId } : {}),
1504
+ position: [hosted.along, h / 2, 0],
1505
+ wallId: hosted.info.nodeId,
1506
+ ...doorStyleFromIfcOperation(element.OperationType?.value),
1226
1507
  metadata: buildMetadata({
1227
1508
  ifcType: 'IFCDOOR',
1228
1509
  expressID: fillId,
1229
1510
  globalId: element.GlobalId?.value,
1511
+ operationType: element.OperationType?.value,
1230
1512
  }),
1231
1513
  });
1232
1514
  nodes[nodeId] = doorNode;
1515
+ expressIdToNodeId.set(fillId, nodeId);
1233
1516
  if (parentNodeId && nodes[parentNodeId]) {
1234
1517
  ;
1235
1518
  nodes[parentNodeId].children?.push(nodeId);
@@ -1237,9 +1520,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1237
1520
  }
1238
1521
  else {
1239
1522
  const h = height ?? 1.2;
1240
- const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
1523
+ const sill = scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
1241
1524
  const nodeId = generateId('window');
1242
- expressIdToNodeId.set(fillId, nodeId);
1243
1525
  const windowNode = tryParse(WindowNode, 'window', {
1244
1526
  object: 'node',
1245
1527
  id: nodeId,
@@ -1249,16 +1531,17 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1249
1531
  visible: true,
1250
1532
  width: width ?? 1.0,
1251
1533
  height: h,
1252
- position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
1253
- ...(hosted ? { wallId: hosted.info.nodeId } : {}),
1534
+ position: [hosted.along, sill + h / 2, 0],
1535
+ wallId: hosted.info.nodeId,
1254
1536
  metadata: buildMetadata({
1255
1537
  ifcType: 'IFCWINDOW',
1256
1538
  expressID: fillId,
1257
1539
  globalId: element.GlobalId?.value,
1258
- ...(hosted ? { sillHeight: sill } : {}),
1540
+ sillHeight: sill,
1259
1541
  }),
1260
1542
  });
1261
1543
  nodes[nodeId] = windowNode;
1544
+ expressIdToNodeId.set(fillId, nodeId);
1262
1545
  if (parentNodeId && nodes[parentNodeId]) {
1263
1546
  ;
1264
1547
  nodes[parentNodeId].children?.push(nodeId);
@@ -1275,10 +1558,18 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1275
1558
  for (let i = 0; i < slabs.size(); i++) {
1276
1559
  const slabExpressID = slabs.get(i);
1277
1560
  const slab = ifcApi.GetLine(modelID, slabExpressID);
1561
+ // SlabNode represents a horizontal plan polygon and participates in
1562
+ // Pascal's storey-wide wall-support calculation. Preserve roofs and stair
1563
+ // landings as exact meshes: roofs may be sloped, while a local landing is
1564
+ // not a storey floor and must not raise adjacent wall bases.
1565
+ const slabPredefinedType = String(slab.PredefinedType?.value ?? '').toUpperCase();
1566
+ if ((slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') &&
1567
+ importedMeshPrimitivesFor(slabExpressID).length > 0) {
1568
+ continue;
1569
+ }
1278
1570
  const nodeId = generateId('slab');
1279
1571
  expressIdToNodeId.set(slabExpressID, nodeId);
1280
- const parentExpressID = parentMap.get(slabExpressID);
1281
- const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1572
+ const parentNodeId = resolveElementParent(slabExpressID);
1282
1573
  let polygon = null;
1283
1574
  let elevation = 0;
1284
1575
  let thickness;
@@ -1289,7 +1580,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1289
1580
  : identity();
1290
1581
  // Get elevation from placement Z
1291
1582
  const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1292
- elevation = s[2];
1583
+ elevation = s[2] - elementLevelElevation(slabExpressID);
1293
1584
  // Get body extrusion data
1294
1585
  const body = getBodyExtrusionData(ifcApi, modelID, slab);
1295
1586
  // Extrusion depth is slab thickness
@@ -1332,8 +1623,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1332
1623
  // keep defaults
1333
1624
  }
1334
1625
  // Skip slabs where we couldn't extract a polygon
1335
- if (!polygon || polygon.length < 3)
1626
+ if (!polygon || polygon.length < 3) {
1627
+ expressIdToNodeId.delete(slabExpressID);
1336
1628
  continue;
1629
+ }
1337
1630
  const slabNode = tryParse(SlabNode, 'slab', {
1338
1631
  object: 'node',
1339
1632
  id: nodeId,
@@ -1350,114 +1643,12 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1350
1643
  ifcType: 'IFCSLAB',
1351
1644
  expressID: slabExpressID,
1352
1645
  globalId: slab.GlobalId?.value,
1646
+ predefinedType: slab.PredefinedType?.value,
1353
1647
  thickness,
1354
1648
  }),
1355
1649
  });
1356
1650
  nodes[nodeId] = slabNode;
1357
- if (parentNodeId && nodes[parentNodeId]) {
1358
- ;
1359
- nodes[parentNodeId].children?.push(nodeId);
1360
- }
1361
- }
1362
- // Process stairs
1363
- const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR);
1364
- for (let i = 0; i < stairs.size(); i++) {
1365
- const stairExpressID = stairs.get(i);
1366
- if (expressIdToNodeId.has(stairExpressID))
1367
- continue;
1368
- const stair = ifcApi.GetLine(modelID, stairExpressID);
1369
- const nodeId = generateId('stair');
1370
- expressIdToNodeId.set(stairExpressID, nodeId);
1371
- const parentExpressID = parentMap.get(stairExpressID);
1372
- const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1373
- let position = [0, 0, 0];
1374
- let boundingBox;
1375
- try {
1376
- const worldMat = stair.ObjectPlacement?.value
1377
- ? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
1378
- : identity();
1379
- const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1380
- position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
1381
- // Try stair's own body first
1382
- const body = getBodyExtrusionData(ifcApi, modelID, stair);
1383
- if (body.xDim && body.yDim && body.depth) {
1384
- boundingBox = opts.swapYZ
1385
- ? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
1386
- : [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor];
1387
- }
1388
- // If no body, try to derive from stair flight children
1389
- if (!boundingBox) {
1390
- const stairChildren = childrenMap.get(stairExpressID) ?? [];
1391
- for (const childId of stairChildren) {
1392
- try {
1393
- const child = ifcApi.GetLine(modelID, childId);
1394
- // Check for NumberOfRisers / RiserHeight / TreadLength
1395
- const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value;
1396
- const riserHeight = child.RiserHeight?.value;
1397
- const treadLength = child.TreadLength?.value;
1398
- if (nRisers && riserHeight && treadLength) {
1399
- const totalHeight = nRisers * riserHeight * unitFactor;
1400
- const totalRun = (nRisers - 1) * treadLength * unitFactor;
1401
- const width = 1.0; // Default stair width
1402
- const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
1403
- const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width;
1404
- boundingBox = opts.swapYZ
1405
- ? [totalRun || 1, totalHeight, stairWidth]
1406
- : [totalRun || 1, stairWidth, totalHeight];
1407
- break;
1408
- }
1409
- // Fallback: try flight body extrusion
1410
- const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
1411
- if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
1412
- boundingBox = opts.swapYZ
1413
- ? [
1414
- flightBody.xDim * unitFactor,
1415
- flightBody.depth * unitFactor,
1416
- flightBody.yDim * unitFactor,
1417
- ]
1418
- : [
1419
- flightBody.xDim * unitFactor,
1420
- flightBody.yDim * unitFactor,
1421
- flightBody.depth * unitFactor,
1422
- ];
1423
- break;
1424
- }
1425
- }
1426
- catch {
1427
- /* skip child */
1428
- }
1429
- }
1430
- }
1431
- }
1432
- catch {
1433
- /* keep defaults */
1434
- }
1435
- const stairNode = tryParse(StairNode, 'stair', {
1436
- object: 'node',
1437
- id: nodeId,
1438
- type: 'stair',
1439
- name: stair.Name?.value || `Stair ${i + 1}`,
1440
- parentId: parentNodeId || null,
1441
- visible: true,
1442
- position,
1443
- children: [],
1444
- // TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
1445
- // risers). The converter only knows the bounding box right now;
1446
- // keep it in metadata until we map IFC stairs onto the parametric
1447
- // shape (or extend StairNode with a raw-geometry escape hatch).
1448
- metadata: buildMetadata({
1449
- ifcType: 'IFCSTAIR',
1450
- expressID: stairExpressID,
1451
- globalId: stair.GlobalId?.value,
1452
- predefinedType: stair.PredefinedType?.value,
1453
- boundingBox,
1454
- }),
1455
- });
1456
- nodes[nodeId] = stairNode;
1457
- if (parentNodeId && nodes[parentNodeId]) {
1458
- ;
1459
- nodes[parentNodeId].children?.push(nodeId);
1460
- }
1651
+ attachNodeToGraph(nodeId, parentNodeId);
1461
1652
  }
1462
1653
  // Process roofs
1463
1654
  const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF);
@@ -1468,8 +1659,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1468
1659
  const roof = ifcApi.GetLine(modelID, roofExpressID);
1469
1660
  const nodeId = generateId('roof');
1470
1661
  expressIdToNodeId.set(roofExpressID, nodeId);
1471
- const parentExpressID = parentMap.get(roofExpressID);
1472
- const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1662
+ const parentNodeId = resolveElementParent(roofExpressID);
1473
1663
  let polygon;
1474
1664
  let elevation;
1475
1665
  let height;
@@ -1478,7 +1668,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1478
1668
  ? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
1479
1669
  : identity();
1480
1670
  const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1481
- elevation = s[2];
1671
+ elevation = s[2] - elementLevelElevation(roofExpressID);
1482
1672
  const body = getBodyExtrusionData(ifcApi, modelID, roof);
1483
1673
  if (body.depth)
1484
1674
  height = body.depth * unitFactor;
@@ -1537,10 +1727,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1537
1727
  }),
1538
1728
  });
1539
1729
  nodes[nodeId] = roofNode;
1540
- if (parentNodeId && nodes[parentNodeId]) {
1541
- ;
1542
- nodes[parentNodeId].children?.push(nodeId);
1543
- }
1730
+ attachNodeToGraph(nodeId, parentNodeId);
1544
1731
  }
1545
1732
  // Process columns
1546
1733
  const columnTypes = [WebIFC.IFCCOLUMN];
@@ -1565,8 +1752,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1565
1752
  const col = ifcApi.GetLine(modelID, colExpressID);
1566
1753
  const nodeId = generateId('column');
1567
1754
  expressIdToNodeId.set(colExpressID, nodeId);
1568
- const parentExpressID = parentMap.get(colExpressID);
1569
- const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1755
+ const parentNodeId = resolveElementParent(colExpressID);
1570
1756
  let position = [0, 0, 0];
1571
1757
  let width;
1572
1758
  let depth;
@@ -1578,7 +1764,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1578
1764
  ? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
1579
1765
  : identity();
1580
1766
  const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1581
- position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
1767
+ position = toPascalPoint(s, elementLevelElevation(colExpressID));
1582
1768
  const body = getBodyExtrusionData(ifcApi, modelID, col);
1583
1769
  if (body.depth)
1584
1770
  height = body.depth * unitFactor;
@@ -1644,73 +1830,232 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1644
1830
  }),
1645
1831
  });
1646
1832
  nodes[nodeId] = columnNode;
1647
- if (parentNodeId && nodes[parentNodeId]) {
1648
- ;
1649
- nodes[parentNodeId].children?.push(nodeId);
1650
- }
1833
+ attachNodeToGraph(nodeId, parentNodeId);
1651
1834
  }
1652
1835
  }
1653
- // Beams: skipped for now — Pascal has no `beam` node type yet. When it
1654
- // lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
1655
- // (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
1656
- // extrusion depth → axis length). Reference implementation lives in
1657
- // git history of this file. We still walk the entities to log how
1658
- // many beams the IFC contained so the conversion summary is accurate.
1659
- let skippedBeamCount = 0;
1660
- const beamTypes = [WebIFC.IFCBEAM];
1836
+ // Spaces become editable Pascal room zones. Prefer their swept-area
1837
+ // profile (preserves concavity); fall back to the mesh's plan hull.
1838
+ let importedSpaceCount = 0;
1661
1839
  try {
1662
- beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE);
1840
+ const spaces = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSPACE);
1841
+ for (let i = 0; i < spaces.size(); i++) {
1842
+ try {
1843
+ const spaceExpressId = spaces.get(i);
1844
+ if (expressIdToNodeId.has(spaceExpressId))
1845
+ continue;
1846
+ const space = ifcApi.GetLine(modelID, spaceExpressId);
1847
+ const parentNodeId = resolveElementParent(spaceExpressId);
1848
+ const primitives = importedMeshPrimitivesFor(spaceExpressId);
1849
+ let polygon = null;
1850
+ let footprintApproximated = false;
1851
+ let ceilingHeight = DEFAULT_LEVEL_HEIGHT;
1852
+ try {
1853
+ const worldMat = space.ObjectPlacement?.value
1854
+ ? resolveWorldTransform(ifcApi, modelID, space.ObjectPlacement.value)
1855
+ : identity();
1856
+ const body = getBodyExtrusionData(ifcApi, modelID, space);
1857
+ const extrusionMat = getExtrusionPosition(ifcApi, modelID, space);
1858
+ if (body.profilePoints && body.profilePoints.length >= 3) {
1859
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1860
+ polygon = body.profilePoints.map((point) => {
1861
+ const scene = worldToScene(transformPoint3(combinedMat, [point[0], point[1], 0]));
1862
+ return [scene[0], scene[1]];
1863
+ });
1864
+ const first = polygon[0];
1865
+ const last = polygon.at(-1);
1866
+ if (polygon.length > 3 &&
1867
+ last &&
1868
+ Math.abs(first[0] - last[0]) < 1e-6 &&
1869
+ Math.abs(first[1] - last[1]) < 1e-6) {
1870
+ polygon.pop();
1871
+ }
1872
+ }
1873
+ if (body.depth)
1874
+ ceilingHeight = body.depth * unitFactor;
1875
+ }
1876
+ catch {
1877
+ /* mesh fallback below */
1878
+ }
1879
+ if (!polygon) {
1880
+ polygon = meshFootprint(primitives, opts.swapYZ);
1881
+ footprintApproximated = polygon !== null;
1882
+ }
1883
+ if (!polygon || polygon.length < 3)
1884
+ continue;
1885
+ if (primitives.length > 0) {
1886
+ const heightAxis = opts.swapYZ ? 1 : 2;
1887
+ const heights = primitives.flatMap((primitive) => primitive.positions.filter((_, index) => index % 3 === heightAxis));
1888
+ if (heights.length > 0)
1889
+ ceilingHeight = Math.max(...heights) - Math.min(...heights);
1890
+ }
1891
+ const spaceName = space.Name?.value;
1892
+ const longName = space.LongName?.value;
1893
+ const roomNumberCandidate = longName && spaceName !== longName ? (spaceName ?? '').trim() : '';
1894
+ const roomNumber = roomNumberCandidate.length <= 32 ? roomNumberCandidate : '';
1895
+ const nodeId = generateId('zone');
1896
+ const zone = tryParse(ZoneNode, 'zone', {
1897
+ object: 'node',
1898
+ id: nodeId,
1899
+ type: 'zone',
1900
+ name: longName || spaceName || `Space ${i + 1}`,
1901
+ parentId: parentNodeId,
1902
+ visible: true,
1903
+ polygon,
1904
+ spaceRole: 'room',
1905
+ roomNumber,
1906
+ ceilingHeight: Math.max(0.1, ceilingHeight),
1907
+ metadata: buildMetadata({
1908
+ ifcType: 'IFCSPACE',
1909
+ expressID: spaceExpressId,
1910
+ globalId: space.GlobalId?.value,
1911
+ predefinedType: space.PredefinedType?.value,
1912
+ ifcName: spaceName,
1913
+ footprintApproximated: footprintApproximated || undefined,
1914
+ }),
1915
+ });
1916
+ expressIdToNodeId.set(spaceExpressId, nodeId);
1917
+ nodes[nodeId] = zone;
1918
+ attachNodeToGraph(nodeId, parentNodeId);
1919
+ importedSpaceCount++;
1920
+ }
1921
+ catch {
1922
+ /* skip malformed space */
1923
+ }
1924
+ }
1663
1925
  }
1664
1926
  catch {
1665
- /* not in all versions */
1927
+ /* IFC schema may not expose spaces */
1666
1928
  }
1667
- for (const beamType of beamTypes) {
1668
- try {
1669
- const beams = ifcApi.GetLineIDsWithType(modelID, beamType);
1670
- skippedBeamCount += beams.size();
1671
- }
1672
- catch {
1673
- /* type not present in this file */
1929
+ progress('Processing beams...', 85);
1930
+ let convertedBeamCount = 0;
1931
+ let skippedBeamCount = 0;
1932
+ for (const beamType of [WebIFC.IFCBEAM, WebIFC.IFCBEAMSTANDARDCASE]) {
1933
+ const beams = ifcApi.GetLineIDsWithType(modelID, beamType);
1934
+ for (let i = 0; i < beams.size(); i++) {
1935
+ const beamExpressID = beams.get(i);
1936
+ if (expressIdToNodeId.has(beamExpressID))
1937
+ continue;
1938
+ try {
1939
+ const beam = ifcApi.GetLine(modelID, beamExpressID);
1940
+ const storeyExpressID = findStoreyForElement(beamExpressID);
1941
+ const parentExpressID = storeyExpressID ?? parentMap.get(beamExpressID);
1942
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : undefined;
1943
+ const parent = parentNodeId ? nodes[parentNodeId] : undefined;
1944
+ const levelElevation = parent?.type === 'level' ? Number(meta(parent).elevation ?? 0) : 0;
1945
+ const geometry = extractBeamGeometry(ifcApi, modelID, beamExpressID, {
1946
+ origin: originOffset,
1947
+ unitFactor,
1948
+ swapYZ: opts.swapYZ,
1949
+ levelElevation,
1950
+ });
1951
+ if (!geometry)
1952
+ throw new Error('No renderable beam geometry');
1953
+ const beamNode = tryParse(BlockNode, 'beam', {
1954
+ name: beam.Name?.value || `Beam ${i + 1}`,
1955
+ parentId: parentNodeId ?? null,
1956
+ ...geometry,
1957
+ // IFC already places the beam vertically; overlapping slabs must not lift it again.
1958
+ supportSlabId: GROUND_SUPPORT_ID,
1959
+ metadata: buildMetadata({
1960
+ ifcType: beamType === WebIFC.IFCBEAM ? 'IFCBEAM' : 'IFCBEAMSTANDARDCASE',
1961
+ expressID: beamExpressID,
1962
+ globalId: beam.GlobalId?.value,
1963
+ predefinedType: beam.PredefinedType?.value,
1964
+ }),
1965
+ });
1966
+ nodes[beamNode.id] = beamNode;
1967
+ expressIdToNodeId.set(beamExpressID, beamNode.id);
1968
+ if (parent && 'children' in parent) {
1969
+ ;
1970
+ parent.children.push(beamNode.id);
1971
+ }
1972
+ else {
1973
+ rootNodeIds.push(beamNode.id);
1974
+ }
1975
+ convertedBeamCount++;
1976
+ }
1977
+ catch (error) {
1978
+ skippedBeamCount++;
1979
+ console.warn(`[IFC→Pascal] Could not convert beam #${beamExpressID}:`, error);
1980
+ }
1674
1981
  }
1675
1982
  }
1676
- if (skippedBeamCount > 0) {
1677
- console.warn(`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`);
1678
- }
1679
- // Items: skipped for now — Pascal's ItemNode requires a full `asset`
1680
- // (catalog reference with id/src/dimensions/etc.) that the converter
1681
- // can't synthesise from raw IFC geometry. When the editor grows a
1682
- // raw-geometry escape hatch (or we add a placeholder-asset registry),
1683
- // restore the mapping from the pre-migration git history. We still
1684
- // walk the entities to log a count for diagnostics.
1685
- let skippedItemCount = 0;
1686
- const itemTypeKeys = [
1687
- WebIFC.IFCFURNISHINGELEMENT,
1688
- WebIFC.IFCBUILDINGELEMENTPROXY,
1689
- WebIFC.IFCRAILING,
1690
- WebIFC.IFCCOVERING,
1691
- WebIFC.IFCCURTAINWALL,
1692
- WebIFC.IFCPLATE,
1693
- WebIFC.IFCMEMBER,
1694
- WebIFC.IFCFOOTING,
1695
- ];
1696
- for (const itemType of itemTypeKeys) {
1983
+ // Preserve every unsupported building element as serialized triangle
1984
+ // geometry. Failed native walls/slabs are included so unusual BRep or
1985
+ // mapped geometry remains visible instead of silently disappearing.
1986
+ const fallbackTypes = new Map();
1987
+ const addFallbackType = (value, label) => {
1988
+ if (typeof value === 'number' && value > 0)
1989
+ fallbackTypes.set(value, label);
1990
+ };
1991
+ addFallbackType(WebIFC.IFCBEAM, 'IFCBEAM');
1992
+ addFallbackType(WebIFC.IFCBEAMSTANDARDCASE, 'IFCBEAMSTANDARDCASE');
1993
+ addFallbackType(WebIFC.IFCFURNISHINGELEMENT, 'IFCFURNISHINGELEMENT');
1994
+ addFallbackType(WebIFC.IFCBUILDINGELEMENTPROXY, 'IFCBUILDINGELEMENTPROXY');
1995
+ addFallbackType(WebIFC.IFCRAILING, 'IFCRAILING');
1996
+ addFallbackType(WebIFC.IFCCOVERING, 'IFCCOVERING');
1997
+ addFallbackType(WebIFC.IFCCURTAINWALL, 'IFCCURTAINWALL');
1998
+ addFallbackType(WebIFC.IFCPLATE, 'IFCPLATE');
1999
+ addFallbackType(WebIFC.IFCMEMBER, 'IFCMEMBER');
2000
+ addFallbackType(WebIFC.IFCFOOTING, 'IFCFOOTING');
2001
+ addFallbackType(WebIFC.IFCSTAIRFLIGHT, 'IFCSTAIRFLIGHT');
2002
+ addFallbackType(WebIFC.IFCSPACE, 'IFCSPACE');
2003
+ addFallbackType(WebIFC.IFCWALL, 'IFCWALL');
2004
+ addFallbackType(WebIFC.IFCWALLSTANDARDCASE, 'IFCWALLSTANDARDCASE');
2005
+ addFallbackType(WebIFC.IFCSLAB, 'IFCSLAB');
2006
+ addFallbackType(WebIFC.IFCWINDOW, 'IFCWINDOW');
2007
+ addFallbackType(WebIFC.IFCWINDOWSTANDARDCASE, 'IFCWINDOWSTANDARDCASE');
2008
+ addFallbackType(WebIFC.IFCDOOR, 'IFCDOOR');
2009
+ addFallbackType(WebIFC.IFCDOORSTANDARDCASE, 'IFCDOORSTANDARDCASE');
2010
+ let importedMeshCount = 0;
2011
+ for (const [ifcType, ifcTypeName] of fallbackTypes) {
2012
+ let elements;
1697
2013
  try {
1698
- const items = ifcApi.GetLineIDsWithType(modelID, itemType);
1699
- skippedItemCount += items.size();
2014
+ elements = ifcApi.GetLineIDsWithType(modelID, ifcType);
1700
2015
  }
1701
2016
  catch {
1702
- /* type not present in this file */
2017
+ continue;
2018
+ }
2019
+ for (let i = 0; i < elements.size(); i++) {
2020
+ const expressId = elements.get(i);
2021
+ if (expressIdToNodeId.has(expressId))
2022
+ continue;
2023
+ const element = ifcApi.GetLine(modelID, expressId);
2024
+ const parentNodeId = resolveElementParent(expressId);
2025
+ const primitives = importedMeshPrimitivesFor(expressId);
2026
+ if (primitives.length === 0)
2027
+ continue;
2028
+ const nodeId = generateId('imesh');
2029
+ const importedMesh = tryParse(ImportedMeshNode, 'imported mesh', {
2030
+ object: 'node',
2031
+ id: nodeId,
2032
+ type: 'imported-mesh',
2033
+ name: element.Name?.value || `${ifcTypeName} ${i + 1}`,
2034
+ parentId: parentNodeId,
2035
+ visible: true,
2036
+ position: [0, 0, 0],
2037
+ rotation: [0, 0, 0],
2038
+ primitives,
2039
+ metadata: buildMetadata({
2040
+ ifcType: ifcTypeName,
2041
+ expressID: expressId,
2042
+ globalId: element.GlobalId?.value,
2043
+ predefinedType: element.PredefinedType?.value,
2044
+ objectType: element.ObjectType?.value,
2045
+ }),
2046
+ });
2047
+ expressIdToNodeId.set(expressId, nodeId);
2048
+ nodes[nodeId] = importedMesh;
2049
+ attachNodeToGraph(nodeId, parentNodeId);
2050
+ importedMeshCount++;
1703
2051
  }
1704
- }
1705
- if (skippedItemCount > 0) {
1706
- console.warn(`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`);
1707
2052
  }
1708
2053
  // Post-process: resolve levelId for all element nodes
1709
2054
  for (const node of Object.values(nodes)) {
1710
2055
  const m = meta(node);
1711
2056
  if (!m.expressID)
1712
2057
  continue;
1713
- const storeyExpId = findStoreyForElement(m.expressID);
2058
+ const storeyExpId = resolveStoreyForElement(m.expressID);
1714
2059
  if (storeyExpId != null) {
1715
2060
  m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined;
1716
2061
  }
@@ -1789,6 +2134,15 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1789
2134
  catch {
1790
2135
  /* no property rels */
1791
2136
  }
2137
+ // Door presentation is derived only from standardized IFC semantics.
2138
+ // OperationType is available on IfcDoor itself; glazing is conventionally
2139
+ // carried by Pset_DoorCommon.GlazingAreaFraction.
2140
+ for (const node of Object.values(nodes)) {
2141
+ if (node.type !== 'door')
2142
+ continue;
2143
+ const glazingAreaFraction = meta(node).properties?.Pset_DoorCommon?.GlazingAreaFraction;
2144
+ Object.assign(node, doorGlazingStyle(node, glazingAreaFraction));
2145
+ }
1792
2146
  // Materials via IFCRELASSOCIATESMATERIAL
1793
2147
  try {
1794
2148
  const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
@@ -1901,8 +2255,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1901
2255
  stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
1902
2256
  roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
1903
2257
  columns: Object.values(nodes).filter((n) => n.type === 'column').length,
2258
+ beams: convertedBeamCount,
1904
2259
  skippedBeams: skippedBeamCount,
1905
- skippedItems: skippedItemCount,
2260
+ spaces: importedSpaceCount,
2261
+ importedMeshes: importedMeshCount,
1906
2262
  });
1907
2263
  progress('Complete!', 100);
1908
2264
  return {