@pascal-app/ifc-converter 1.0.3 → 1.0.5

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.
Files changed (43) hide show
  1. package/README.md +63 -4
  2. package/dist/beam-geometry.d.ts.map +1 -1
  3. package/dist/beam-geometry.js +3 -2
  4. package/dist/cleanup.d.ts +8 -1
  5. package/dist/cleanup.d.ts.map +1 -1
  6. package/dist/cleanup.js +13 -4
  7. package/dist/collections.d.ts +4 -0
  8. package/dist/collections.d.ts.map +1 -0
  9. package/dist/collections.js +66 -0
  10. package/dist/export/door-operation.d.ts +13 -0
  11. package/dist/export/door-operation.d.ts.map +1 -0
  12. package/dist/export/door-operation.js +45 -0
  13. package/dist/export/guid.d.ts +15 -0
  14. package/dist/export/guid.d.ts.map +1 -0
  15. package/dist/export/guid.js +80 -0
  16. package/dist/export/ifc-model.d.ts +91 -0
  17. package/dist/export/ifc-model.d.ts.map +1 -0
  18. package/dist/export/ifc-model.js +184 -0
  19. package/dist/export/index.d.ts +3 -0
  20. package/dist/export/index.d.ts.map +1 -0
  21. package/dist/export/index.js +2 -0
  22. package/dist/export/scene-to-ifc.d.ts +45 -0
  23. package/dist/export/scene-to-ifc.d.ts.map +1 -0
  24. package/dist/export/scene-to-ifc.js +1175 -0
  25. package/dist/export/step.d.ts +64 -0
  26. package/dist/export/step.d.ts.map +1 -0
  27. package/dist/export/step.js +158 -0
  28. package/dist/export/wall-base.d.ts +25 -0
  29. package/dist/export/wall-base.d.ts.map +1 -0
  30. package/dist/export/wall-base.js +91 -0
  31. package/dist/ids.d.ts +3 -0
  32. package/dist/ids.d.ts.map +1 -0
  33. package/dist/ids.js +35 -0
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +570 -151
  37. package/dist/room-first.d.ts +35 -0
  38. package/dist/room-first.d.ts.map +1 -0
  39. package/dist/room-first.js +885 -0
  40. package/dist/wall-joins.d.ts +21 -0
  41. package/dist/wall-joins.d.ts.map +1 -0
  42. package/dist/wall-joins.js +367 -0
  43. package/package.json +10 -6
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
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
- import { customAlphabet } from 'nanoid';
1
+ import { BlockNode, BuildingNode, CeilingNode, ColumnNode, containsPoint, DEFAULT_LEVEL_HEIGHT, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, GROUND_SUPPORT_ID, ImportedMeshNode, LevelNode, polygonInteriorPoint, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode, zoneColorForSeed, } from '@pascal-app/core';
3
2
  import * as WebIFC from 'web-ifc';
4
3
  import { extractBeamGeometry } from './beam-geometry';
5
4
  import { simplifyConvertedSceneGraph } from './cleanup';
5
+ import { importCollections } from './collections';
6
6
  import { doorGlazingStyle, doorStyleFromIfcOperation } from './door-semantics';
7
+ import { nextId, seedIds } from './ids';
8
+ import { applyRoomFirstStructure } from './room-first';
7
9
  import { selectStoreyForElevation } from './storey-semantics';
10
+ import { redundantWallIds } from './wall-joins';
8
11
  function meta(node) {
9
12
  return (node?.metadata ?? {});
10
13
  }
@@ -42,10 +45,6 @@ function tryParse(schema, kind, input) {
42
45
  throw err;
43
46
  }
44
47
  }
45
- const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
46
- function generateId(prefix) {
47
- return `${prefix}_${nanoid()}`;
48
- }
49
48
  // --- Unit detection ---
50
49
  function getLengthUnitFactor(ifcApi, modelID) {
51
50
  const prefixFactors = {
@@ -271,6 +270,36 @@ function getAxisPolyline(ifcApi, modelID, element) {
271
270
  }
272
271
  return null;
273
272
  }
273
+ const DEFAULT_SLAB_THICKNESS = 0.05;
274
+ /** Deepest covering mesh read as flat when it has no extrusion to check. */
275
+ const MAX_FLAT_COVERING_DEPTH = 0.3;
276
+ function readCurvePoints(ifcApi, modelID, curveRef) {
277
+ const curve = ifcApi.GetLine(modelID, curveRef);
278
+ const pts = [];
279
+ if (!curve.Points)
280
+ return pts;
281
+ // IFCPOLYLINE — Points is array of CartesianPoint refs
282
+ if (Array.isArray(curve.Points) && curve.Points.length > 0 && curve.Points[0]?.value != null) {
283
+ for (const ptRef of curve.Points) {
284
+ const pt = ifcApi.GetLine(modelID, ptRef.value);
285
+ const coords = pt.Coordinates.map((c) => (typeof c === 'number' ? c : (c?.value ?? 0)));
286
+ pts.push([coords[0] ?? 0, coords[1] ?? 0]);
287
+ }
288
+ }
289
+ // IFCINDEXEDPOLYCURVE — Points is a reference to a point list
290
+ else if (curve.Points?.value) {
291
+ const ptList = ifcApi.GetLine(modelID, curve.Points.value);
292
+ if (ptList.CoordList) {
293
+ for (const coords of ptList.CoordList) {
294
+ const c = Array.isArray(coords)
295
+ ? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
296
+ : [];
297
+ pts.push([c[0] ?? 0, c[1] ?? 0]);
298
+ }
299
+ }
300
+ }
301
+ return pts;
302
+ }
274
303
  function extractFromExtrusionItem(ifcApi, modelID, item, result) {
275
304
  // Unwrap BooleanClippingResult → follow FirstOperand chain to find extrusion
276
305
  let current = item;
@@ -287,6 +316,12 @@ function extractFromExtrusionItem(ifcApi, modelID, item, result) {
287
316
  if (current.Depth?.value) {
288
317
  result.depth = current.Depth.value;
289
318
  }
319
+ if (current.ExtrudedDirection?.value) {
320
+ const direction = ifcApi.GetLine(modelID, current.ExtrudedDirection.value);
321
+ const ratios = (direction.DirectionRatios ?? []).map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
322
+ if (ratios.length >= 2)
323
+ result.direction = [ratios[0] ?? 0, ratios[1] ?? 0, ratios[2] ?? 0];
324
+ }
290
325
  if (current.SweptArea?.value) {
291
326
  const profile = ifcApi.GetLine(modelID, current.SweptArea.value);
292
327
  // Detect the profile shape by the fields present rather than the IFC
@@ -305,34 +340,16 @@ function extractFromExtrusionItem(ifcApi, modelID, item, result) {
305
340
  // Extract profile points — OuterCurve for ArbitraryClosedProfileDef
306
341
  const curveRef = profile.OuterCurve?.value;
307
342
  if (curveRef) {
308
- const curve = ifcApi.GetLine(modelID, curveRef);
309
- if (curve.Points) {
310
- const pts = [];
311
- // IFCPOLYLINE — Points is array of CartesianPoint refs
312
- if (Array.isArray(curve.Points) &&
313
- curve.Points.length > 0 &&
314
- curve.Points[0]?.value != null) {
315
- for (const ptRef of curve.Points) {
316
- const pt = ifcApi.GetLine(modelID, ptRef.value);
317
- const coords = pt.Coordinates.map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
318
- pts.push([coords[0] ?? 0, coords[1] ?? 0]);
319
- }
320
- }
321
- // IFCINDEXEDPOLYCURVE — Points is a reference to a point list
322
- else if (curve.Points?.value) {
323
- const ptList = ifcApi.GetLine(modelID, curve.Points.value);
324
- if (ptList.CoordList) {
325
- for (const coords of ptList.CoordList) {
326
- const c = Array.isArray(coords)
327
- ? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
328
- : [];
329
- pts.push([c[0] ?? 0, c[1] ?? 0]);
330
- }
331
- }
332
- }
333
- if (pts.length >= 3)
334
- result.profilePoints = pts;
335
- }
343
+ const pts = readCurvePoints(ifcApi, modelID, curveRef);
344
+ if (pts.length >= 3)
345
+ result.profilePoints = pts;
346
+ }
347
+ for (const innerRef of Array.isArray(profile.InnerCurves) ? profile.InnerCurves : []) {
348
+ if (!innerRef?.value)
349
+ continue;
350
+ const pts = readCurvePoints(ifcApi, modelID, innerRef.value);
351
+ if (pts.length >= 3)
352
+ result.innerCurves.push(pts);
336
353
  }
337
354
  }
338
355
  return result.depth !== null;
@@ -345,6 +362,8 @@ function getBodyExtrusionData(ifcApi, modelID, element) {
345
362
  profilePoints: null,
346
363
  profileShape: null,
347
364
  radius: null,
365
+ innerCurves: [],
366
+ direction: null,
348
367
  };
349
368
  try {
350
369
  if (!element.Representation?.value)
@@ -406,9 +425,9 @@ function findExtrusionPosition(ifcApi, modelID, item) {
406
425
  // world AABB. A world AABB conflates length and thickness for any wall
407
426
  // the placement rotates (a 37°-rotated 0.2m wall would read ~1.9m
408
427
  // thick); projecting onto the actual axis is rotation-invariant.
409
- // (axisX, axisY) is the unit wall direction in the converter's
410
- // horizontal frame, which is parallel to web-ifc world XY — both are IFC
411
- // world coords, differing only by origin/scale, which cancel in extents.
428
+ // (axisX, axisY) is the unit wall direction in IFC world XY. web-ifc
429
+ // meshes come in (X, Z, -Y), so a vertex's IFC plan point is (wx, -wz)
430
+ // and its height is wy; origin and scale cancel in extents.
412
431
  // Returns extents in the geometry's native units (caller resolves
413
432
  // scale), or null on any failure.
414
433
  function measureWallLocalExtents(ifcApi, modelID, expressID, axisX, axisY) {
@@ -445,8 +464,8 @@ function measureWallLocalExtents(ifcApi, modelID, expressID, axisX, axisY) {
445
464
  const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
446
465
  const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
447
466
  const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
448
- const a = wx * axisX + wy * axisY;
449
- const p = wx * perpX + wy * perpY;
467
+ const a = wx * axisX - wz * axisY;
468
+ const p = wx * perpX - wz * perpY;
450
469
  if (a < minA)
451
470
  minA = a;
452
471
  if (a > maxA)
@@ -455,10 +474,10 @@ function measureWallLocalExtents(ifcApi, modelID, expressID, axisX, axisY) {
455
474
  minP = p;
456
475
  if (p > maxP)
457
476
  maxP = p;
458
- if (wz < minV)
459
- minV = wz;
460
- if (wz > maxV)
461
- maxV = wz;
477
+ if (wy < minV)
478
+ minV = wy;
479
+ if (wy > maxV)
480
+ maxV = wy;
462
481
  any = true;
463
482
  }
464
483
  }
@@ -545,15 +564,15 @@ function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, o
545
564
  ];
546
565
  // `GetFlatMesh` does not use the same axes as the STEP placement
547
566
  // 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.
567
+ // IFC Z-up coordinates to (X, Z, -Y), which is Pascal's Y-up
568
+ // right-handed frame. The default preset only removes the origin
569
+ // offset and level elevation; negating the third axis again would
570
+ // mirror the mesh.
552
571
  const mappedPosition = swapYZ
553
572
  ? [
554
573
  world[0] - originOffset[0] * unitFactor,
555
574
  world[1] - originOffset[2] * unitFactor - levelElevation,
556
- -(world[2] + originOffset[1] * unitFactor),
575
+ world[2] + originOffset[1] * unitFactor,
557
576
  ]
558
577
  : [
559
578
  world[0] - originOffset[0] * unitFactor,
@@ -570,19 +589,12 @@ function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, o
570
589
  matrix[2] * nx + matrix[6] * ny + matrix[10] * nz,
571
590
  ];
572
591
  const mappedNormal = swapYZ
573
- ? [worldNormal[0], worldNormal[1], -worldNormal[2]]
592
+ ? worldNormal
574
593
  : [worldNormal[0], -worldNormal[2], worldNormal[1]];
575
594
  const normalLength = Math.hypot(...mappedNormal) || 1;
576
595
  normals.push(roundMeshNormal(mappedNormal[0] / normalLength), roundMeshNormal(mappedNormal[1] / normalLength), roundMeshNormal(mappedNormal[2] / normalLength));
577
596
  }
578
597
  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
598
  if (positions.length >= 9 && indices.length >= 3) {
587
599
  primitives.push({
588
600
  positions,
@@ -713,6 +725,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
713
725
  await ifcApi.Init();
714
726
  progress('Opening IFC model...', 10);
715
727
  const modelID = ifcApi.OpenModel(ifcData);
728
+ seedIds(ifcData);
716
729
  console.log(`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`);
717
730
  const nodes = {};
718
731
  const rootNodeIds = [];
@@ -748,10 +761,16 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
748
761
  catch {
749
762
  /* keep zero offset */
750
763
  }
764
+ // Scene points keep IFC's axis order (plan [0] and [1], vertical [2]), but
765
+ // the plan's second axis is Pascal z. Pascal is Y-up right-handed, so seen
766
+ // from above IFC north (+Y) is Pascal -Z: the default preset negates IFC Y
767
+ // here, once, for every placement-derived wall, opening, slab, roof, space
768
+ // and column. Mapping +Y to +Z mirrors the whole model.
769
+ const planDepthSign = opts.swapYZ ? -1 : 1;
751
770
  function worldToScene(worldPt) {
752
771
  return [
753
772
  (worldPt[0] - originOffset[0]) * unitFactor,
754
- (worldPt[1] - originOffset[1]) * unitFactor,
773
+ planDepthSign * (worldPt[1] - originOffset[1]) * unitFactor,
755
774
  (worldPt[2] - originOffset[2]) * unitFactor,
756
775
  ];
757
776
  }
@@ -801,6 +820,59 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
801
820
  childrenMap.get(parentExpressID).add(childID);
802
821
  }
803
822
  }
823
+ // Elements a Pascal export wrote carry their node identity in a `Pascal`
824
+ // property set; storeys carry their height as the GrossHeight quantity.
825
+ const pascalIdentity = new Map();
826
+ const grossHeightByExpressId = new Map();
827
+ try {
828
+ const relDefines = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
829
+ for (let i = 0; i < relDefines.size(); i++) {
830
+ try {
831
+ const rel = ifcApi.GetLine(modelID, relDefines.get(i));
832
+ if (!rel.RelatedObjects || !rel.RelatingPropertyDefinition?.value)
833
+ continue;
834
+ const definition = ifcApi.GetLine(modelID, rel.RelatingPropertyDefinition.value);
835
+ if (definition.Name?.value === 'Pascal' && definition.HasProperties) {
836
+ const values = {};
837
+ for (const ref of definition.HasProperties) {
838
+ const property = ifcApi.GetLine(modelID, ref.value);
839
+ if (property.Name?.value)
840
+ values[property.Name.value] = property.NominalValue?.value;
841
+ }
842
+ if (typeof values.NodeId !== 'string' || typeof values.NodeType !== 'string')
843
+ continue;
844
+ for (const object of rel.RelatedObjects)
845
+ pascalIdentity.set(object.value, { id: values.NodeId, type: values.NodeType });
846
+ }
847
+ for (const ref of definition.Quantities ?? []) {
848
+ const quantity = ifcApi.GetLine(modelID, ref.value);
849
+ if (quantity.Name?.value !== 'GrossHeight' || !(quantity.LengthValue?.value > 0))
850
+ continue;
851
+ for (const object of rel.RelatedObjects)
852
+ grossHeightByExpressId.set(object.value, quantity.LengthValue.value * unitFactor);
853
+ }
854
+ }
855
+ catch {
856
+ /* skip rel */
857
+ }
858
+ }
859
+ }
860
+ catch {
861
+ /* no property rels */
862
+ }
863
+ // Re-importing a Pascal export keeps each node's id, so the scene keeps its
864
+ // identity (links, selections, history) across the round trip.
865
+ const usedPascalIds = new Set();
866
+ function nodeIdFor(expressId, kind, type = kind) {
867
+ const identity = pascalIdentity.get(expressId);
868
+ if (identity?.type === type &&
869
+ identity.id.startsWith(`${kind}_`) &&
870
+ !usedPascalIds.has(identity.id)) {
871
+ usedPascalIds.add(identity.id);
872
+ return identity.id;
873
+ }
874
+ return nextId(kind);
875
+ }
804
876
  // Resolve containing storey for an element by walking the parent chain
805
877
  function findStoreyForElement(expressId) {
806
878
  let current = expressId;
@@ -885,6 +957,85 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
885
957
  importedPrimitivesByExpressId.set(expressId, primitives);
886
958
  return primitives;
887
959
  }
960
+ // Level-local vertical extent of the element's triangles.
961
+ function meshHeightRange(expressId) {
962
+ const heightAxis = opts.swapYZ ? 1 : 2;
963
+ let min = Number.POSITIVE_INFINITY;
964
+ let max = Number.NEGATIVE_INFINITY;
965
+ for (const primitive of importedMeshPrimitivesFor(expressId)) {
966
+ for (let index = heightAxis; index < primitive.positions.length; index += 3) {
967
+ const value = primitive.positions[index];
968
+ if (value < min)
969
+ min = value;
970
+ if (value > max)
971
+ max = value;
972
+ }
973
+ }
974
+ return min <= max ? { min, max } : null;
975
+ }
976
+ // Plan outline, holes and level-local vertical extent of an extruded
977
+ // element (slab, covering, opening, site). `flat` is false when the
978
+ // profile or extrusion is not horizontal/vertical.
979
+ function extrudedPlan(element, levelElevation) {
980
+ const worldMat = element.ObjectPlacement?.value
981
+ ? resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value)
982
+ : identity();
983
+ const body = getBodyExtrusionData(ifcApi, modelID, element);
984
+ const extrusionMat = getExtrusionPosition(ifcApi, modelID, element);
985
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
986
+ const toPlan = (points) => {
987
+ const ring = points.map((point) => {
988
+ const scene = worldToScene(transformPoint3(combinedMat, [point[0], point[1], 0]));
989
+ return [scene[0], scene[1]];
990
+ });
991
+ const first = ring[0];
992
+ const last = ring.at(-1);
993
+ if (ring.length > 3 &&
994
+ first &&
995
+ last &&
996
+ Math.abs(first[0] - last[0]) < 1e-6 &&
997
+ Math.abs(first[1] - last[1]) < 1e-6)
998
+ ring.pop();
999
+ return ring;
1000
+ };
1001
+ let polygon = null;
1002
+ if (body.profilePoints && body.profilePoints.length >= 3) {
1003
+ polygon = toPlan(body.profilePoints);
1004
+ }
1005
+ else if (body.xDim && body.yDim) {
1006
+ const hw = body.xDim / 2;
1007
+ const hh = body.yDim / 2;
1008
+ polygon = toPlan([
1009
+ [-hw, -hh],
1010
+ [hw, -hh],
1011
+ [hw, hh],
1012
+ [-hw, hh],
1013
+ ]);
1014
+ }
1015
+ if (polygon && polygon.length < 3)
1016
+ polygon = null;
1017
+ let top = null;
1018
+ let bottom = null;
1019
+ let flat = null;
1020
+ if (body.depth) {
1021
+ const direction = body.direction ?? [0, 0, 1];
1022
+ const length = Math.hypot(direction[0], direction[1], direction[2]) || 1;
1023
+ const tip = direction.map((value) => (value / length) * body.depth);
1024
+ const z0 = worldToScene(transformPoint3(combinedMat, [0, 0, 0]))[2];
1025
+ const z1 = worldToScene(transformPoint3(combinedMat, tip))[2];
1026
+ top = Math.max(z0, z1) - levelElevation;
1027
+ bottom = Math.min(z0, z1) - levelElevation;
1028
+ flat =
1029
+ Math.abs(combinedMat[10]) > 0.999 && Math.abs(z1 - z0) >= body.depth * unitFactor * 0.999;
1030
+ }
1031
+ return {
1032
+ polygon,
1033
+ holes: polygon ? body.innerCurves.map(toPlan).filter((ring) => ring.length >= 3) : [],
1034
+ top,
1035
+ bottom,
1036
+ flat,
1037
+ };
1038
+ }
888
1039
  progress('Processing sites...', 30);
889
1040
  // Process sites
890
1041
  const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
@@ -892,7 +1043,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
892
1043
  for (let i = 0; i < sites.size(); i++) {
893
1044
  const siteExpressID = sites.get(i);
894
1045
  const site = ifcApi.GetLine(modelID, siteExpressID);
895
- const nodeId = generateId('site');
1046
+ const nodeId = nodeIdFor(siteExpressID, 'site');
896
1047
  expressIdToNodeId.set(siteExpressID, nodeId);
897
1048
  rootNodeIds.push(nodeId);
898
1049
  const siteNode = tryParse(SiteNode, 'site', {
@@ -903,10 +1054,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
903
1054
  parentId: null,
904
1055
  visible: true,
905
1056
  polygon: {
906
- // Pascal SiteNode requires a property-line polygon. The
907
- // converter doesn't read IFC site geometry yet, so seed the
908
- // editor's default 30x30 square here.
909
- // TODO(ifc-fix): derive from IfcSite.SiteAddress or building footprints.
1057
+ // Placeholder until the whole model is read: fitSitePolygons below
1058
+ // replaces it with the site footprint or the imported extent.
910
1059
  type: 'polygon',
911
1060
  points: [
912
1061
  [-15, -15],
@@ -931,7 +1080,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
931
1080
  for (let i = 0; i < buildings.size(); i++) {
932
1081
  const buildingExpressID = buildings.get(i);
933
1082
  const building = ifcApi.GetLine(modelID, buildingExpressID);
934
- const nodeId = generateId('building');
1083
+ const nodeId = nodeIdFor(buildingExpressID, 'building');
935
1084
  expressIdToNodeId.set(buildingExpressID, nodeId);
936
1085
  const parentExpressID = parentMap.get(buildingExpressID);
937
1086
  const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
@@ -961,7 +1110,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
961
1110
  for (let i = 0; i < storeys.size(); i++) {
962
1111
  const storeyExpressID = storeys.get(i);
963
1112
  const storey = ifcApi.GetLine(modelID, storeyExpressID);
964
- const nodeId = generateId('level');
1113
+ const nodeId = nodeIdFor(storeyExpressID, 'level');
965
1114
  expressIdToNodeId.set(storeyExpressID, nodeId);
966
1115
  const parentExpressID = parentMap.get(storeyExpressID);
967
1116
  const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
@@ -1022,24 +1171,24 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1022
1171
  const height = nextExpressId
1023
1172
  ? (storeyElevationByExpressId.get(nextExpressId) ?? 0) -
1024
1173
  (storeyElevationByExpressId.get(expressId) ?? 0)
1025
- : DEFAULT_LEVEL_HEIGHT;
1174
+ : (grossHeightByExpressId.get(expressId) ?? DEFAULT_LEVEL_HEIGHT);
1026
1175
  level.level = index;
1027
1176
  level.height = height > 0.1 ? height : DEFAULT_LEVEL_HEIGHT;
1028
1177
  }
1029
1178
  }
1030
1179
  progress('Processing walls...', 60);
1031
- // Build void/fill relationship maps for doors and windows
1032
- // IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement)
1033
- const wallToOpenings = new Map();
1180
+ // Build void/fill relationship maps for doors, windows and slab holes
1181
+ // IFCRELVOIDSELEMENT: wall/slab (RelatingBuildingElement) → opening (RelatedOpeningElement)
1182
+ const hostToOpenings = new Map();
1034
1183
  const relVoids = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELVOIDSELEMENT);
1035
1184
  for (let i = 0; i < relVoids.size(); i++) {
1036
1185
  const rel = ifcApi.GetLine(modelID, relVoids.get(i));
1037
- const wallId = rel.RelatingBuildingElement?.value;
1186
+ const hostId = rel.RelatingBuildingElement?.value;
1038
1187
  const openingId = rel.RelatedOpeningElement?.value;
1039
- if (wallId && openingId) {
1040
- if (!wallToOpenings.has(wallId))
1041
- wallToOpenings.set(wallId, []);
1042
- wallToOpenings.get(wallId).push(openingId);
1188
+ if (hostId && openingId) {
1189
+ if (!hostToOpenings.has(hostId))
1190
+ hostToOpenings.set(hostId, []);
1191
+ hostToOpenings.get(hostId).push(openingId);
1043
1192
  }
1044
1193
  }
1045
1194
  // IFCRELFILLSELEMENT: opening (RelatingOpeningElement) → door/window (RelatedBuildingElement)
@@ -1066,6 +1215,69 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1066
1215
  for (let i = 0; i < ids.size(); i++)
1067
1216
  windowExpressIds.add(ids.get(i));
1068
1217
  }
1218
+ // IfcMaterialLayerSetUsage places the wall body relative to its reference
1219
+ // line: across [low, high] on the wall's local Y (model units).
1220
+ const layerUsageByElement = new Map();
1221
+ try {
1222
+ const relMaterials = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
1223
+ for (let i = 0; i < relMaterials.size(); i++) {
1224
+ try {
1225
+ const rel = ifcApi.GetLine(modelID, relMaterials.get(i));
1226
+ if (!rel.RelatingMaterial?.value || !rel.RelatedObjects)
1227
+ continue;
1228
+ const usage = ifcApi.GetLine(modelID, rel.RelatingMaterial.value);
1229
+ if (usage.type !== WebIFC.IFCMATERIALLAYERSETUSAGE || !usage.ForLayerSet?.value)
1230
+ continue;
1231
+ if (String(usage.LayerSetDirection?.value ?? 'AXIS2').toUpperCase() !== 'AXIS2')
1232
+ continue;
1233
+ const layerSet = ifcApi.GetLine(modelID, usage.ForLayerSet.value);
1234
+ let total = 0;
1235
+ for (const layerRef of layerSet.MaterialLayers ?? []) {
1236
+ total += Number(ifcApi.GetLine(modelID, layerRef.value).LayerThickness?.value ?? 0);
1237
+ }
1238
+ const offset = Number(usage.OffsetFromReferenceLine?.value ?? 0);
1239
+ if (!(total > 0) || !Number.isFinite(offset))
1240
+ continue;
1241
+ const negative = String(usage.DirectionSense?.value ?? '').toUpperCase() === 'NEGATIVE';
1242
+ const extent = negative
1243
+ ? { low: offset - total, high: offset }
1244
+ : { low: offset, high: offset + total };
1245
+ for (const object of rel.RelatedObjects)
1246
+ layerUsageByElement.set(object.value, extent);
1247
+ }
1248
+ catch {
1249
+ /* skip rel */
1250
+ }
1251
+ }
1252
+ }
1253
+ catch {
1254
+ /* no material rels */
1255
+ }
1256
+ // Wall node id → plan unit vector from the IFC reference line into the body.
1257
+ const wallBodySides = new Map();
1258
+ // IfcRelConnectsPathElements: walls joined at an end (ATSTART/ATEND).
1259
+ const wallConnections = [];
1260
+ try {
1261
+ const relPaths = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONNECTSPATHELEMENTS);
1262
+ for (let i = 0; i < relPaths.size(); i++) {
1263
+ try {
1264
+ const rel = ifcApi.GetLine(modelID, relPaths.get(i));
1265
+ const ends = [rel.RelatingConnectionType?.value, rel.RelatedConnectionType?.value].map((value) => String(value ?? '').toUpperCase());
1266
+ if (!ends.some((value) => value === 'ATSTART' || value === 'ATEND'))
1267
+ continue;
1268
+ const a = rel.RelatingElement?.value;
1269
+ const b = rel.RelatedElement?.value;
1270
+ if (a && b)
1271
+ wallConnections.push([a, b]);
1272
+ }
1273
+ catch {
1274
+ /* skip rel */
1275
+ }
1276
+ }
1277
+ }
1278
+ catch {
1279
+ /* no connection rels */
1280
+ }
1069
1281
  // Process walls (both IFCWALL and IFCWALLSTANDARDCASE)
1070
1282
  const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE];
1071
1283
  for (const wallType of wallTypes) {
@@ -1075,13 +1287,14 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1075
1287
  if (expressIdToNodeId.has(wallExpressID))
1076
1288
  continue;
1077
1289
  const wall = ifcApi.GetLine(modelID, wallExpressID);
1078
- const nodeId = generateId('wall');
1290
+ const nodeId = nodeIdFor(wallExpressID, 'wall');
1079
1291
  expressIdToNodeId.set(wallExpressID, nodeId);
1080
1292
  const parentNodeId = resolveElementParent(wallExpressID);
1081
1293
  let start = [0, 0];
1082
1294
  let end = null;
1083
1295
  let thickness;
1084
1296
  let height;
1297
+ let onReferenceLine = false;
1085
1298
  try {
1086
1299
  // Resolve world placement
1087
1300
  const worldMat = wall.ObjectPlacement?.value
@@ -1090,6 +1303,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1090
1303
  // Try to get axis polyline (local 2D line along wall)
1091
1304
  const axisPts = getAxisPolyline(ifcApi, modelID, wall);
1092
1305
  if (axisPts && axisPts.length >= 2) {
1306
+ onReferenceLine = true;
1093
1307
  const s0 = worldToScene(transformPoint3(worldMat, axisPts[0]));
1094
1308
  const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1]));
1095
1309
  start = [s0[0], s0[1]];
@@ -1175,7 +1389,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1175
1389
  // IFC ground plane, which is also the mapping used for the
1176
1390
  // wall's start/end above.
1177
1391
  const axisX = (end[0] - start[0]) / wallLenM;
1178
- const axisY = (end[1] - start[1]) / wallLenM;
1392
+ // Back to IFC world XY, the frame measureWallLocalExtents projects in.
1393
+ const axisY = (planDepthSign * (end[1] - start[1])) / wallLenM;
1179
1394
  const extents = measureWallLocalExtents(ifcApi, modelID, wallExpressID, axisX, axisY);
1180
1395
  const geom = extents
1181
1396
  ? wallHeightThicknessFromExtents(extents, wallLenM, unitFactor)
@@ -1192,6 +1407,29 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1192
1407
  height = DEFAULT_WALL_HEIGHT;
1193
1408
  if (thickness === undefined)
1194
1409
  thickness = DEFAULT_WALL_THICKNESS;
1410
+ // Every later pass (merging, corner joins, room faces) works on body
1411
+ // centrelines; a face reference line is restored at the end as
1412
+ // `justification` (see applyWallReferenceLines).
1413
+ const layers = layerUsageByElement.get(wallExpressID);
1414
+ if (layers && onReferenceLine && wall.ObjectPlacement?.value) {
1415
+ const worldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value);
1416
+ const sideX = worldMat[1];
1417
+ const sideY = planDepthSign * worldMat[5];
1418
+ const sideLength = Math.hypot(sideX, sideY);
1419
+ const low = layers.low * unitFactor;
1420
+ const high = layers.high * unitFactor;
1421
+ const centre = (low + high) / 2;
1422
+ if (sideLength > 1e-6 && Math.abs(centre) > Math.max(0.001, thickness * 0.02)) {
1423
+ const side = [sideX / sideLength, sideY / sideLength];
1424
+ start = [start[0] + side[0] * centre, start[1] + side[1] * centre];
1425
+ end = [end[0] + side[0] * centre, end[1] + side[1] * centre];
1426
+ const faceTolerance = Math.max(0.002, thickness * 0.02);
1427
+ if (Math.abs(low) <= faceTolerance)
1428
+ wallBodySides.set(nodeId, side);
1429
+ else if (Math.abs(high) <= faceTolerance)
1430
+ wallBodySides.set(nodeId, [-side[0], -side[1]]);
1431
+ }
1432
+ }
1195
1433
  const wallNode = tryParse(WallNode, 'wall', {
1196
1434
  object: 'node',
1197
1435
  id: nodeId,
@@ -1216,8 +1454,28 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1216
1454
  attachNodeToGraph(nodeId, parentNodeId);
1217
1455
  }
1218
1456
  }
1457
+ // Cladding, linings and embedded walls fall through to the imported-mesh
1458
+ // pass. Ends that met a removed lining still reach its host wall's body.
1459
+ const wallLinings = new Map();
1460
+ for (const [wallNodeId, host] of redundantWallIds(Object.values(nodes).filter((node) => node.type === 'wall'))) {
1461
+ const expressId = meta(nodes[wallNodeId]).expressID;
1462
+ if (expressId === undefined || hostToOpenings.has(expressId) || pascalIdentity.has(expressId))
1463
+ continue;
1464
+ const hostExpressId = meta(host).expressID;
1465
+ const thickness = nodes[wallNodeId].thickness ?? 0;
1466
+ if (hostExpressId !== undefined)
1467
+ wallLinings.set(hostExpressId, Math.max(wallLinings.get(hostExpressId) ?? 0, thickness));
1468
+ for (const node of Object.values(nodes)) {
1469
+ if (node.children?.includes(wallNodeId))
1470
+ node.children = node.children.filter((id) => id !== wallNodeId);
1471
+ }
1472
+ if (rootNodeIds.includes(wallNodeId))
1473
+ rootNodeIds.splice(rootNodeIds.indexOf(wallNodeId), 1);
1474
+ delete nodes[wallNodeId];
1475
+ expressIdToNodeId.delete(expressId);
1476
+ }
1219
1477
  // Process doors and windows via void/fill relationships
1220
- for (const [wallExpressID, openingIds] of wallToOpenings) {
1478
+ for (const [wallExpressID, openingIds] of hostToOpenings) {
1221
1479
  const wallNodeId = expressIdToNodeId.get(wallExpressID);
1222
1480
  if (!wallNodeId)
1223
1481
  continue;
@@ -1307,7 +1565,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1307
1565
  }
1308
1566
  }
1309
1567
  if (isDoor) {
1310
- const nodeId = generateId('door');
1568
+ const nodeId = nodeIdFor(fillId, 'door');
1311
1569
  // Vertical centering is now handled: door center Y = height/2 so the
1312
1570
  // opening sits at the correct position. Remaining caveat: door bottom
1313
1571
  // is assumed at floor y=0 (i.e. the door starts at the wall's base).
@@ -1323,6 +1581,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1323
1581
  width: width ?? 0.9,
1324
1582
  height: height ?? 2.1,
1325
1583
  position: doorPosition,
1584
+ wallId: wallNodeId,
1585
+ floorThresholdVersion: 1,
1326
1586
  ...doorStyleFromIfcOperation(element.OperationType?.value),
1327
1587
  metadata: buildMetadata({
1328
1588
  ifcType: 'IFCDOOR',
@@ -1337,11 +1597,9 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1337
1597
  wallNode.children.push(nodeId);
1338
1598
  }
1339
1599
  else {
1340
- const nodeId = generateId('window');
1341
- // TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
1342
- // sillHeight stays read-only metadata until we resolve the window
1343
- // schema (Pascal's WindowNode doesn't have sillHeight today —
1344
- // moved to metadata for now so we don't lose the value).
1600
+ const nodeId = nodeIdFor(fillId, 'window');
1601
+ // WindowNode has no sill field: the sill sets the centre height in
1602
+ // position[1]; the IFC value is kept in metadata as well.
1345
1603
  const windowPosition = [
1346
1604
  position ?? 0,
1347
1605
  (sillHeight ?? 0) + (height ?? 1.2) / 2,
@@ -1357,6 +1615,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1357
1615
  width: width ?? 1.0,
1358
1616
  height: height ?? 1.2,
1359
1617
  position: windowPosition,
1618
+ wallId: wallNodeId,
1619
+ floorThresholdVersion: 1,
1360
1620
  metadata: buildMetadata({
1361
1621
  ifcType: 'IFCWINDOW',
1362
1622
  expressID: fillId,
@@ -1491,7 +1751,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1491
1751
  const parentNodeId = hosted.info.nodeId;
1492
1752
  if (isDoor) {
1493
1753
  const h = height ?? 2.1;
1494
- const nodeId = generateId('door');
1754
+ const nodeId = nodeIdFor(fillId, 'door');
1495
1755
  const doorNode = tryParse(DoorNode, 'door', {
1496
1756
  object: 'node',
1497
1757
  id: nodeId,
@@ -1503,6 +1763,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1503
1763
  height: h,
1504
1764
  position: [hosted.along, h / 2, 0],
1505
1765
  wallId: hosted.info.nodeId,
1766
+ floorThresholdVersion: 1,
1506
1767
  ...doorStyleFromIfcOperation(element.OperationType?.value),
1507
1768
  metadata: buildMetadata({
1508
1769
  ifcType: 'IFCDOOR',
@@ -1521,7 +1782,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1521
1782
  else {
1522
1783
  const h = height ?? 1.2;
1523
1784
  const sill = scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
1524
- const nodeId = generateId('window');
1785
+ const nodeId = nodeIdFor(fillId, 'window');
1525
1786
  const windowNode = tryParse(WindowNode, 'window', {
1526
1787
  object: 'node',
1527
1788
  id: nodeId,
@@ -1533,6 +1794,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1533
1794
  height: h,
1534
1795
  position: [hosted.along, sill + h / 2, 0],
1535
1796
  wallId: hosted.info.nodeId,
1797
+ floorThresholdVersion: 1,
1536
1798
  metadata: buildMetadata({
1537
1799
  ifcType: 'IFCWINDOW',
1538
1800
  expressID: fillId,
@@ -1567,66 +1829,45 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1567
1829
  importedMeshPrimitivesFor(slabExpressID).length > 0) {
1568
1830
  continue;
1569
1831
  }
1570
- const nodeId = generateId('slab');
1571
- expressIdToNodeId.set(slabExpressID, nodeId);
1572
- const parentNodeId = resolveElementParent(slabExpressID);
1573
- let polygon = null;
1574
- let elevation = 0;
1575
- let thickness;
1832
+ const levelElevation = elementLevelElevation(slabExpressID);
1833
+ let plan = null;
1576
1834
  try {
1577
- // Resolve world placement for the slab element
1578
- const worldMat = slab.ObjectPlacement?.value
1579
- ? resolveWorldTransform(ifcApi, modelID, slab.ObjectPlacement.value)
1580
- : identity();
1581
- // Get elevation from placement Z
1582
- const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1583
- elevation = s[2] - elementLevelElevation(slabExpressID);
1584
- // Get body extrusion data
1585
- const body = getBodyExtrusionData(ifcApi, modelID, slab);
1586
- // Extrusion depth is slab thickness
1587
- if (body.depth) {
1588
- thickness = body.depth * unitFactor;
1589
- }
1590
- // Extrusion Position provides an additional local offset for the profile
1591
- const extrusionMat = getExtrusionPosition(ifcApi, modelID, slab);
1592
- if (body.profilePoints && body.profilePoints.length >= 3) {
1593
- const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1594
- polygon = body.profilePoints.map((pt) => {
1595
- const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]));
1596
- return [sc[0], sc[1]];
1597
- });
1598
- const first = polygon[0];
1599
- const last = polygon[polygon.length - 1];
1600
- if (polygon.length > 3 &&
1601
- Math.abs(first[0] - last[0]) < 1e-6 &&
1602
- Math.abs(first[1] - last[1]) < 1e-6) {
1603
- polygon.pop();
1604
- }
1605
- }
1606
- else if (body.xDim && body.yDim) {
1607
- const hw = body.xDim / 2;
1608
- const hh = body.yDim / 2;
1609
- const corners = [
1610
- [-hw, -hh, 0],
1611
- [hw, -hh, 0],
1612
- [hw, hh, 0],
1613
- [-hw, hh, 0],
1614
- ];
1615
- const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1616
- polygon = corners.map((c) => {
1617
- const sc = worldToScene(transformPoint3(combinedMat, c));
1618
- return [sc[0], sc[1]];
1619
- });
1620
- }
1835
+ plan = extrudedPlan(slab, levelElevation);
1621
1836
  }
1622
1837
  catch {
1623
- // keep defaults
1838
+ // mesh fallback below
1624
1839
  }
1625
- // Skip slabs where we couldn't extract a polygon
1626
- if (!polygon || polygon.length < 3) {
1627
- expressIdToNodeId.delete(slabExpressID);
1840
+ // A sloped or tilted slab is not a Pascal floor; keep its exact mesh.
1841
+ if (plan?.flat === false && importedMeshPrimitivesFor(slabExpressID).length > 0)
1628
1842
  continue;
1629
- }
1843
+ if (!plan?.polygon)
1844
+ continue;
1845
+ // Revit extrudes floors down from their top, other tools up from their
1846
+ // bottom: the top is wherever the extrusion ends highest. The mesh wins
1847
+ // when the body is more than that one extrusion (extra items, clipping).
1848
+ const meshRange = meshHeightRange(slabExpressID);
1849
+ const meshAgrees = !meshRange ||
1850
+ (plan.top !== null &&
1851
+ Math.abs(meshRange.max - plan.top) <= 0.01 &&
1852
+ Math.abs(meshRange.min - (plan.bottom ?? plan.top)) <= 0.01);
1853
+ const top = (meshAgrees ? plan.top : meshRange?.max) ?? 0;
1854
+ const bottom = meshAgrees ? (plan.bottom ?? undefined) : meshRange?.min;
1855
+ const thickness = bottom !== undefined && top - bottom >= 0.005 ? top - bottom : DEFAULT_SLAB_THICKNESS;
1856
+ const holes = [
1857
+ ...plan.holes,
1858
+ ...(hostToOpenings.get(slabExpressID) ?? []).flatMap((openingId) => {
1859
+ try {
1860
+ const opening = extrudedPlan(ifcApi.GetLine(modelID, openingId), levelElevation);
1861
+ return opening?.polygon ? [opening.polygon] : [];
1862
+ }
1863
+ catch {
1864
+ return [];
1865
+ }
1866
+ }),
1867
+ ];
1868
+ const parentNodeId = resolveElementParent(slabExpressID);
1869
+ const nodeId = nodeIdFor(slabExpressID, 'slab');
1870
+ expressIdToNodeId.set(slabExpressID, nodeId);
1630
1871
  const slabNode = tryParse(SlabNode, 'slab', {
1631
1872
  object: 'node',
1632
1873
  id: nodeId,
@@ -1634,22 +1875,117 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1634
1875
  name: slab.Name?.value || `Slab ${i + 1}`,
1635
1876
  parentId: parentNodeId || null,
1636
1877
  visible: true,
1637
- polygon,
1638
- holes: [],
1639
- elevation,
1640
- // TODO(ifc-fix): Pascal SlabNode has no `thickness` field — moved
1641
- // to metadata so the IFC value isn't lost.
1878
+ polygon: plan.polygon,
1879
+ holes,
1880
+ holeMetadata: holes.map(() => ({ source: 'manual' })),
1881
+ elevation: top,
1882
+ thickness,
1642
1883
  metadata: buildMetadata({
1643
1884
  ifcType: 'IFCSLAB',
1644
1885
  expressID: slabExpressID,
1645
1886
  globalId: slab.GlobalId?.value,
1646
1887
  predefinedType: slab.PredefinedType?.value,
1647
- thickness,
1888
+ sourceColor: importedMeshPrimitivesFor(slabExpressID)[0]?.color,
1648
1889
  }),
1649
1890
  });
1650
1891
  nodes[nodeId] = slabNode;
1651
1892
  attachNodeToGraph(nodeId, parentNodeId);
1652
1893
  }
1894
+ // Ceiling and flooring coverings become Pascal ceilings and floor finishes
1895
+ // (see room-first.ts). Everything else stays an exact imported mesh.
1896
+ const coveringIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCCOVERING);
1897
+ for (let i = 0; i < coveringIds.size(); i++) {
1898
+ const coveringExpressID = coveringIds.get(i);
1899
+ try {
1900
+ const covering = ifcApi.GetLine(modelID, coveringExpressID);
1901
+ const role = String(covering.PredefinedType?.value ?? '').toUpperCase();
1902
+ if (role !== 'CEILING' && role !== 'FLOORING')
1903
+ continue;
1904
+ const levelElevation = elementLevelElevation(coveringExpressID);
1905
+ const range = meshHeightRange(coveringExpressID);
1906
+ if (!range)
1907
+ continue;
1908
+ let plan = null;
1909
+ try {
1910
+ plan = extrudedPlan(covering, levelElevation);
1911
+ }
1912
+ catch {
1913
+ // hull fallback below
1914
+ }
1915
+ // A sloped covering is no Pascal ceiling or floor: it keeps its exact mesh.
1916
+ if (plan?.flat === false)
1917
+ continue;
1918
+ let polygon = plan?.polygon ?? null;
1919
+ if (!polygon) {
1920
+ // Without an extrusion to read, only a thin mesh is known to be flat.
1921
+ if (range.max - range.min > MAX_FLAT_COVERING_DEPTH)
1922
+ continue;
1923
+ polygon = meshFootprint(importedMeshPrimitivesFor(coveringExpressID), opts.swapYZ);
1924
+ }
1925
+ if (!polygon || polygon.length < 3)
1926
+ continue;
1927
+ const holes = [
1928
+ ...(plan?.polygon ? plan.holes : []),
1929
+ ...(hostToOpenings.get(coveringExpressID) ?? []).flatMap((openingId) => {
1930
+ try {
1931
+ const opening = extrudedPlan(ifcApi.GetLine(modelID, openingId), levelElevation);
1932
+ return opening?.polygon ? [opening.polygon] : [];
1933
+ }
1934
+ catch {
1935
+ return [];
1936
+ }
1937
+ }),
1938
+ ];
1939
+ const holeMetadata = holes.map(() => ({ source: 'manual' }));
1940
+ const parentNodeId = resolveElementParent(coveringExpressID);
1941
+ if (!parentNodeId)
1942
+ continue;
1943
+ const color = importedMeshPrimitivesFor(coveringExpressID)[0]?.color;
1944
+ const metadata = buildMetadata({
1945
+ ifcType: 'IFCCOVERING',
1946
+ expressID: coveringExpressID,
1947
+ globalId: covering.GlobalId?.value,
1948
+ predefinedType: role,
1949
+ objectType: covering.ObjectType?.value,
1950
+ sourceColor: color,
1951
+ });
1952
+ const name = covering.Name?.value || (role === 'CEILING' ? 'Ceiling' : 'Flooring');
1953
+ const node = role === 'CEILING'
1954
+ ? tryParse(CeilingNode, 'ceiling', {
1955
+ object: 'node',
1956
+ id: nodeIdFor(coveringExpressID, 'ceiling'),
1957
+ type: 'ceiling',
1958
+ name,
1959
+ parentId: parentNodeId,
1960
+ visible: true,
1961
+ polygon,
1962
+ holes,
1963
+ holeMetadata,
1964
+ height: range.min,
1965
+ metadata,
1966
+ })
1967
+ : tryParse(SlabNode, 'slab', {
1968
+ object: 'node',
1969
+ id: nodeIdFor(coveringExpressID, 'slab'),
1970
+ type: 'slab',
1971
+ name,
1972
+ parentId: parentNodeId,
1973
+ visible: true,
1974
+ polygon,
1975
+ holes,
1976
+ holeMetadata,
1977
+ elevation: range.max,
1978
+ thickness: Math.max(0.005, range.max - range.min),
1979
+ metadata,
1980
+ });
1981
+ expressIdToNodeId.set(coveringExpressID, node.id);
1982
+ nodes[node.id] = node;
1983
+ attachNodeToGraph(node.id, parentNodeId);
1984
+ }
1985
+ catch {
1986
+ // keep it for the imported-mesh fallback
1987
+ }
1988
+ }
1653
1989
  // Process roofs
1654
1990
  const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF);
1655
1991
  for (let i = 0; i < roofs.size(); i++) {
@@ -1657,7 +1993,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1657
1993
  if (expressIdToNodeId.has(roofExpressID))
1658
1994
  continue;
1659
1995
  const roof = ifcApi.GetLine(modelID, roofExpressID);
1660
- const nodeId = generateId('roof');
1996
+ const nodeId = nodeIdFor(roofExpressID, 'roof');
1661
1997
  expressIdToNodeId.set(roofExpressID, nodeId);
1662
1998
  const parentNodeId = resolveElementParent(roofExpressID);
1663
1999
  let polygon;
@@ -1750,7 +2086,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1750
2086
  if (expressIdToNodeId.has(colExpressID))
1751
2087
  continue;
1752
2088
  const col = ifcApi.GetLine(modelID, colExpressID);
1753
- const nodeId = generateId('column');
2089
+ const nodeId = nodeIdFor(colExpressID, 'column');
1754
2090
  expressIdToNodeId.set(colExpressID, nodeId);
1755
2091
  const parentNodeId = resolveElementParent(colExpressID);
1756
2092
  let position = [0, 0, 0];
@@ -1892,15 +2228,18 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1892
2228
  const longName = space.LongName?.value;
1893
2229
  const roomNumberCandidate = longName && spaceName !== longName ? (spaceName ?? '').trim() : '';
1894
2230
  const roomNumber = roomNumberCandidate.length <= 32 ? roomNumberCandidate : '';
1895
- const nodeId = generateId('zone');
2231
+ const nodeId = nodeIdFor(spaceExpressId, 'zone');
1896
2232
  const zone = tryParse(ZoneNode, 'zone', {
1897
2233
  object: 'node',
1898
2234
  id: nodeId,
1899
2235
  type: 'zone',
2236
+ color: zoneColorForSeed(nodeId),
1900
2237
  name: longName || spaceName || `Space ${i + 1}`,
1901
2238
  parentId: parentNodeId,
1902
2239
  visible: true,
1903
2240
  polygon,
2241
+ // The room keeps this identity point through adoption and edits.
2242
+ seed: polygonInteriorPoint({ polygon }),
1904
2243
  spaceRole: 'room',
1905
2244
  roomNumber,
1906
2245
  ceilingHeight: Math.max(0.1, ceilingHeight),
@@ -1951,6 +2290,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1951
2290
  if (!geometry)
1952
2291
  throw new Error('No renderable beam geometry');
1953
2292
  const beamNode = tryParse(BlockNode, 'beam', {
2293
+ id: nodeIdFor(beamExpressID, 'block'),
1954
2294
  name: beam.Name?.value || `Beam ${i + 1}`,
1955
2295
  parentId: parentNodeId ?? null,
1956
2296
  ...geometry,
@@ -1998,6 +2338,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1998
2338
  addFallbackType(WebIFC.IFCPLATE, 'IFCPLATE');
1999
2339
  addFallbackType(WebIFC.IFCMEMBER, 'IFCMEMBER');
2000
2340
  addFallbackType(WebIFC.IFCFOOTING, 'IFCFOOTING');
2341
+ addFallbackType(WebIFC.IFCGEOGRAPHICELEMENT, 'IFCGEOGRAPHICELEMENT');
2001
2342
  addFallbackType(WebIFC.IFCSTAIRFLIGHT, 'IFCSTAIRFLIGHT');
2002
2343
  addFallbackType(WebIFC.IFCSPACE, 'IFCSPACE');
2003
2344
  addFallbackType(WebIFC.IFCWALL, 'IFCWALL');
@@ -2025,7 +2366,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
2025
2366
  const primitives = importedMeshPrimitivesFor(expressId);
2026
2367
  if (primitives.length === 0)
2027
2368
  continue;
2028
- const nodeId = generateId('imesh');
2369
+ const nodeId = nodeIdFor(expressId, 'imesh', 'imported-mesh');
2029
2370
  const importedMesh = tryParse(ImportedMeshNode, 'imported mesh', {
2030
2371
  object: 'node',
2031
2372
  id: nodeId,
@@ -2060,6 +2401,13 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
2060
2401
  m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined;
2061
2402
  }
2062
2403
  }
2404
+ // Pascal-authored nodes are already Pascal geometry: cleanup leaves them be.
2405
+ for (const node of Object.values(nodes)) {
2406
+ const m = meta(node);
2407
+ const identity = m.expressID === undefined ? undefined : pascalIdentity.get(m.expressID);
2408
+ if (identity)
2409
+ m.pascalNodeId = identity.id;
2410
+ }
2063
2411
  // Post-process: extract property sets and materials
2064
2412
  const elementExpressIds = new Set();
2065
2413
  const expressIdToNode = new Map();
@@ -2233,13 +2581,83 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
2233
2581
  catch {
2234
2582
  /* no type rels */
2235
2583
  }
2584
+ // The site's property line: the IfcSite footprint when it holds the whole
2585
+ // model, else the imported extent plus a margin.
2586
+ function fitSitePolygons() {
2587
+ let minX = Number.POSITIVE_INFINITY;
2588
+ let minZ = Number.POSITIVE_INFINITY;
2589
+ let maxX = Number.NEGATIVE_INFINITY;
2590
+ let maxZ = Number.NEGATIVE_INFINITY;
2591
+ const include = (x, z, pad = 0) => {
2592
+ minX = Math.min(minX, x - pad);
2593
+ minZ = Math.min(minZ, z - pad);
2594
+ maxX = Math.max(maxX, x + pad);
2595
+ maxZ = Math.max(maxZ, z + pad);
2596
+ };
2597
+ const secondPlanAxis = opts.swapYZ ? 2 : 1;
2598
+ for (const node of Object.values(nodes)) {
2599
+ if (node.type === 'wall') {
2600
+ for (const point of [node.start, node.end])
2601
+ include(point[0], point[1], node.thickness ?? 0);
2602
+ }
2603
+ else if (node.type === 'slab' || node.type === 'zone' || node.type === 'ceiling') {
2604
+ for (const point of node.polygon)
2605
+ include(point[0], point[1]);
2606
+ }
2607
+ else if (node.type === 'column') {
2608
+ include(node.position[0], node.position[2], Math.max(node.width ?? 0, node.depth ?? 0));
2609
+ }
2610
+ else if (node.type === 'imported-mesh') {
2611
+ for (const primitive of node.primitives)
2612
+ for (let index = 0; index + 2 < primitive.positions.length; index += 3)
2613
+ include(primitive.positions[index], primitive.positions[index + secondPlanAxis]);
2614
+ }
2615
+ }
2616
+ if (!(minX <= maxX))
2617
+ return;
2618
+ const SITE_MARGIN = 5;
2619
+ const extent = [
2620
+ [minX - SITE_MARGIN, minZ - SITE_MARGIN],
2621
+ [maxX + SITE_MARGIN, minZ - SITE_MARGIN],
2622
+ [maxX + SITE_MARGIN, maxZ + SITE_MARGIN],
2623
+ [minX - SITE_MARGIN, maxZ + SITE_MARGIN],
2624
+ ];
2625
+ for (const node of Object.values(nodes)) {
2626
+ if (node.type !== 'site')
2627
+ continue;
2628
+ let footprint = null;
2629
+ try {
2630
+ const site = ifcApi.GetLine(modelID, Number(meta(node).expressID));
2631
+ footprint = extrudedPlan(site, 0)?.polygon ?? null;
2632
+ }
2633
+ catch {
2634
+ footprint = null;
2635
+ }
2636
+ const holds = footprint !== null &&
2637
+ [
2638
+ [minX, minZ],
2639
+ [maxX, minZ],
2640
+ [maxX, maxZ],
2641
+ [minX, maxZ],
2642
+ ].every((point) => containsPoint([{ outer: footprint, holes: [] }], point));
2643
+ node.polygon = { type: 'polygon', points: holds ? footprint : extent };
2644
+ }
2645
+ }
2236
2646
  progress('Simplifying converted scene...', 94);
2237
- const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions);
2647
+ const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions, {
2648
+ wallConnections,
2649
+ wallLinings,
2650
+ });
2238
2651
  if (simplificationStats.removedTinyWalls > 0 ||
2239
2652
  simplificationStats.removedMergedWalls > 0 ||
2240
- simplificationStats.removedDuplicateOpenings > 0) {
2653
+ simplificationStats.removedDuplicateOpenings > 0 ||
2654
+ simplificationStats.joinedWallEnds > 0) {
2241
2655
  console.log('[IFC→Pascal] Simplification:', simplificationStats);
2242
2656
  }
2657
+ const roomFirstStats = applyRoomFirstStructure(nodes, wallBodySides);
2658
+ console.log('[IFC→Pascal] Room-first structure:', roomFirstStats);
2659
+ fitSitePolygons();
2660
+ const collections = importCollections(ifcApi, modelID, nodes, expressIdToNodeId);
2243
2661
  ifcApi.CloseModel(modelID);
2244
2662
  progress('Building scene graph...', 95);
2245
2663
  const totalNodes = Object.keys(nodes).length;
@@ -2264,5 +2682,6 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
2264
2682
  return {
2265
2683
  nodes,
2266
2684
  rootNodeIds: rootNodeIds,
2685
+ collections,
2267
2686
  };
2268
2687
  }