@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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/beam-geometry.d.ts +14 -0
- package/dist/beam-geometry.d.ts.map +1 -0
- package/dist/beam-geometry.js +92 -0
- package/dist/cleanup.d.ts.map +1 -1
- package/dist/cleanup.js +24 -1
- package/dist/door-semantics.d.ts +8 -0
- package/dist/door-semantics.d.ts.map +1 -0
- package/dist/door-semantics.js +56 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +578 -222
- package/dist/storey-semantics.d.ts +6 -0
- package/dist/storey-semantics.d.ts.map +1 -0
- package/dist/storey-semantics.js +5 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, LevelNode, RoofNode, SiteNode, SlabNode,
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 (
|
|
877
|
-
const
|
|
878
|
-
|
|
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
|
-
|
|
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
|
|
1110
|
-
//
|
|
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
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const parentNodeId = hosted
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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 =
|
|
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:
|
|
1253
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1648
|
-
;
|
|
1649
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
1650
|
-
}
|
|
1833
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
1651
1834
|
}
|
|
1652
1835
|
}
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1655
|
-
|
|
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
|
-
|
|
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
|
|
1927
|
+
/* IFC schema may not expose spaces */
|
|
1666
1928
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
1699
|
-
skippedItemCount += items.size();
|
|
2014
|
+
elements = ifcApi.GetLineIDsWithType(modelID, ifcType);
|
|
1700
2015
|
}
|
|
1701
2016
|
catch {
|
|
1702
|
-
|
|
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 =
|
|
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
|
-
|
|
2260
|
+
spaces: importedSpaceCount,
|
|
2261
|
+
importedMeshes: importedMeshCount,
|
|
1906
2262
|
});
|
|
1907
2263
|
progress('Complete!', 100);
|
|
1908
2264
|
return {
|