@pascal-app/ifc-converter 1.0.5 → 1.0.7
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/README.md +1 -0
- package/dist/export/scene-to-ifc.d.ts.map +1 -1
- package/dist/export/scene-to-ifc.js +18 -6
- package/dist/geometry-validity.d.ts +45 -0
- package/dist/geometry-validity.d.ts.map +1 -0
- package/dist/geometry-validity.js +138 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55 -17
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ The importer writes intent for Pascal's room-first model; the scene load
|
|
|
35
35
|
migrations (room adoption, floor plates) and the structure kernel turn it into
|
|
36
36
|
rooms, floor plates and ceilings. `tests/room-first-load.test.ts` converts the
|
|
37
37
|
reference files and runs that load chain.
|
|
38
|
+
|
|
38
39
|
- **Walls.** Merged collinear fragments, then every straight wall end moves
|
|
39
40
|
along its axis onto the centreline it meets (L and T joins; ends inside
|
|
40
41
|
another wall's body reach its centreline, `IfcRelConnectsPathElements` widens
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scene-to-ifc.d.ts","sourceRoot":"","sources":["../../src/export/scene-to-ifc.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EAIZ,KAAK,UAAU,
|
|
1
|
+
{"version":3,"file":"scene-to-ifc.d.ts","sourceRoot":"","sources":["../../src/export/scene-to-ifc.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EAIZ,KAAK,UAAU,EAsChB,MAAM,kBAAkB,CAAA;AAyBzB,0FAA0F;AAC1F,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IAC5B,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IAC3B,+BAA+B;IAC/B,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAA;IAClD,oGAAoG;IACpG,MAAM,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAA;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,sDAAsD;IACtD,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB,qFAAqF;IACrF,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACtC;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,aAAa,GAAG,YAAY,GAAG,UAAU,CAAA;CAClD;AAED,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,OAAO,EAAE,aAAa,EAAE,CAAA;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,gBAAgB,CAAA;CAC1B;AAoMD,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,eAAe,CA+1CrE;AAiCD,wDAAwD;AACxD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAE9D"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { calculateLevelMiters, DEFAULT_WALL_THICKNESS, difference, getLevelElevations, getOpeningWallCut, getRenderableSlabPolygon, getWallAssemblyPreset, getWallBodyCenterOffset, getWallCurveFrameAt, getWallCurveLength, getWallFaceOffsets, getWallPlanFootprint, getWallTop, hidesDescendants, intersection, isCurvedWall, liftedManualSlab, measureStair, prepareSlabPolygonContext, resolveCeilingHeight, resolveWallExteriorSide, sampleWallCenterline, union, wallSupportForNodes, } from '@pascal-app/core';
|
|
1
|
+
import { calculateLevelMiters, DEFAULT_WALL_THICKNESS, difference, getDoorOpeningSize, getLevelElevations, getOpeningWallCut, getRenderableSlabPolygon, getWallAssemblyPreset, getWallBodyCenterOffset, getWallCurveFrameAt, getWallCurveLength, getWallFaceOffsets, getWallPlanFootprint, getWallTop, hidesDescendants, intersection, isCurvedWall, liftedManualSlab, measureStair, openingHostFacing, prepareSlabPolygonContext, resolveCeilingHeight, resolveWallExteriorSide, sampleWallCenterline, union, wallSupportForNodes, } from '@pascal-app/core';
|
|
2
2
|
import { doorGlazingFraction, doorOperationForIfc } from './door-operation';
|
|
3
3
|
import { bool, cleanRing, DERIVED, enumValue, frameToLocal, IDENTITY_FRAME, IfcModel, identifier, int, label, logical, ratio, relativeFrame, } from './ifc-model';
|
|
4
4
|
import { typed } from './step';
|
|
@@ -682,10 +682,14 @@ export function buildIfcExport(input) {
|
|
|
682
682
|
angle: Math.atan2(-curve.tangent.y, curve.tangent.x),
|
|
683
683
|
};
|
|
684
684
|
const cutBottom = (cut?.bottom ?? nominalBottom) - nominalBottom;
|
|
685
|
-
const
|
|
685
|
+
const openingSize = getDoorOpeningSize(opening);
|
|
686
|
+
const cutDepth = (cut?.top ?? nominalBottom + openingSize.height) - (cut?.bottom ?? nominalBottom);
|
|
687
|
+
const cutOffset = isCurvedWall(wall)
|
|
688
|
+
? 0
|
|
689
|
+
: openingSize.leafOffsetX * openingHostFacing(opening);
|
|
686
690
|
let openingRef = null;
|
|
687
691
|
let openingPlacement = null;
|
|
688
|
-
if (cutDepth > 1e-4 &&
|
|
692
|
+
if (cutDepth > 1e-4 && openingSize.width > 1e-4) {
|
|
689
693
|
let profile;
|
|
690
694
|
const band = isCurvedWall(wall) && cut
|
|
691
695
|
? cleanRing(cut.band.map(([x, z]) => {
|
|
@@ -697,8 +701,16 @@ export function buildIfcExport(input) {
|
|
|
697
701
|
if (band.length > 0)
|
|
698
702
|
profile = model.polygonProfile(band);
|
|
699
703
|
else
|
|
700
|
-
profile = model.rectangleProfile(
|
|
701
|
-
|
|
704
|
+
profile = model.rectangleProfile(openingSize.width, thickness + 0.2);
|
|
705
|
+
const cutFrame = {
|
|
706
|
+
...openingFrame,
|
|
707
|
+
origin: [
|
|
708
|
+
openingFrame.origin[0] - cutOffset * curve.tangent.x,
|
|
709
|
+
openingFrame.origin[1] + cutOffset * curve.tangent.y,
|
|
710
|
+
nominalBottom,
|
|
711
|
+
],
|
|
712
|
+
};
|
|
713
|
+
openingPlacement = model.localPlacement(wallPlacement, relativeFrame(frame, cutFrame));
|
|
702
714
|
openingRef = step.add('IFCOPENINGELEMENT', model.guid(`${opening.id}:opening`), ownerHistory, `${nodeName(opening, opening.type === 'door' ? 'Door' : 'Window')} opening`, null, null, openingPlacement, model.shape([
|
|
703
715
|
model.bodyRepresentation('SweptSolid', [
|
|
704
716
|
model.extrusion(profile, cutBottom, cutDepth),
|
|
@@ -720,7 +732,7 @@ export function buildIfcExport(input) {
|
|
|
720
732
|
]);
|
|
721
733
|
}
|
|
722
734
|
const elementPlacement = openingPlacement
|
|
723
|
-
? model.localPlacement(openingPlacement,
|
|
735
|
+
? model.localPlacement(openingPlacement, { origin: [cutOffset, 0, 0], angle: 0 })
|
|
724
736
|
: model.localPlacement(storey.placement, openingFrame);
|
|
725
737
|
const fill = emitElement(meshClassFor(opening), opening.id, opening, nodeName(opening, opening.type === 'door' ? 'Door' : 'Window'), elementPlacement, body);
|
|
726
738
|
storey.contained.push(fill);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a conversion could not read and left out or replaced, instead of failing the whole file
|
|
3
|
+
* on one bad number (a NaN vertex used to reject every imported mesh with
|
|
4
|
+
* "Could not build imported mesh node at primitives.0.positions.0").
|
|
5
|
+
*/
|
|
6
|
+
export interface IfcConversionReport {
|
|
7
|
+
skippedGeometry: {
|
|
8
|
+
/** Placed geometries whose placement matrix had a non-finite entry. */
|
|
9
|
+
transforms: number;
|
|
10
|
+
/** Vertices with a non-finite position. */
|
|
11
|
+
vertices: number;
|
|
12
|
+
/** Triangles that used a skipped or missing vertex. */
|
|
13
|
+
triangles: number;
|
|
14
|
+
/** Mesh pieces left with no valid triangle. */
|
|
15
|
+
primitives: number;
|
|
16
|
+
/** Elements left out because none of their geometry could be read. */
|
|
17
|
+
elements: number;
|
|
18
|
+
};
|
|
19
|
+
/** Values that were not finite and fell back to a neutral default. */
|
|
20
|
+
replaced: {
|
|
21
|
+
unitFactor: boolean;
|
|
22
|
+
originOffset: boolean;
|
|
23
|
+
levelElevations: number;
|
|
24
|
+
/** Vertex normals recomputed from the faces around them: shading only, nothing lost. */
|
|
25
|
+
normals: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function emptyConversionReport(): IfcConversionReport;
|
|
29
|
+
/** True when the report has anything to tell the person (a recomputed normal loses nothing). */
|
|
30
|
+
export declare function conversionReportHasIssues(report: IfcConversionReport): boolean;
|
|
31
|
+
export declare function isFiniteMatrix(matrix: ArrayLike<number> | null | undefined): boolean;
|
|
32
|
+
export interface MeshBuffers {
|
|
33
|
+
positions: number[];
|
|
34
|
+
normals: number[];
|
|
35
|
+
indices: number[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Drops vertices with a non-finite position and every triangle that uses one (or an index past
|
|
39
|
+
* the vertex count), then compacts the vertex buffers to the vertices still used. A non-finite
|
|
40
|
+
* normal on a kept vertex is shading, not shape: it is recomputed from the kept triangles around
|
|
41
|
+
* it (zero when they have no area). Returns null when no triangle survives; a source with
|
|
42
|
+
* triangles that all fail counts as a skipped primitive.
|
|
43
|
+
*/
|
|
44
|
+
export declare function keepValidTriangles(mesh: MeshBuffers, report: IfcConversionReport): MeshBuffers | null;
|
|
45
|
+
//# sourceMappingURL=geometry-validity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geometry-validity.d.ts","sourceRoot":"","sources":["../src/geometry-validity.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE;QACf,uEAAuE;QACvE,UAAU,EAAE,MAAM,CAAA;QAClB,2CAA2C;QAC3C,QAAQ,EAAE,MAAM,CAAA;QAChB,uDAAuD;QACvD,SAAS,EAAE,MAAM,CAAA;QACjB,+CAA+C;QAC/C,UAAU,EAAE,MAAM,CAAA;QAClB,sEAAsE;QACtE,QAAQ,EAAE,MAAM,CAAA;KACjB,CAAA;IACD,sEAAsE;IACtE,QAAQ,EAAE;QACR,UAAU,EAAE,OAAO,CAAA;QACnB,YAAY,EAAE,OAAO,CAAA;QACrB,eAAe,EAAE,MAAM,CAAA;QACvB,wFAAwF;QACxF,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;CACF;AAED,wBAAgB,qBAAqB,IAAI,mBAAmB,CAK3D;AAED,gGAAgG;AAChG,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAa9E;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAIpF;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,mBAAmB,GAC1B,WAAW,GAAG,IAAI,CAqDpB"}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export function emptyConversionReport() {
|
|
2
|
+
return {
|
|
3
|
+
skippedGeometry: { transforms: 0, vertices: 0, triangles: 0, primitives: 0, elements: 0 },
|
|
4
|
+
replaced: { unitFactor: false, originOffset: false, levelElevations: 0, normals: 0 },
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
/** True when the report has anything to tell the person (a recomputed normal loses nothing). */
|
|
8
|
+
export function conversionReportHasIssues(report) {
|
|
9
|
+
const { skippedGeometry, replaced } = report;
|
|
10
|
+
return (skippedGeometry.transforms +
|
|
11
|
+
skippedGeometry.vertices +
|
|
12
|
+
skippedGeometry.triangles +
|
|
13
|
+
skippedGeometry.primitives +
|
|
14
|
+
skippedGeometry.elements +
|
|
15
|
+
replaced.levelElevations >
|
|
16
|
+
0 ||
|
|
17
|
+
replaced.unitFactor ||
|
|
18
|
+
replaced.originOffset);
|
|
19
|
+
}
|
|
20
|
+
export function isFiniteMatrix(matrix) {
|
|
21
|
+
if (!matrix || matrix.length < 16)
|
|
22
|
+
return false;
|
|
23
|
+
for (let i = 0; i < 16; i++)
|
|
24
|
+
if (!Number.isFinite(matrix[i]))
|
|
25
|
+
return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Drops vertices with a non-finite position and every triangle that uses one (or an index past
|
|
30
|
+
* the vertex count), then compacts the vertex buffers to the vertices still used. A non-finite
|
|
31
|
+
* normal on a kept vertex is shading, not shape: it is recomputed from the kept triangles around
|
|
32
|
+
* it (zero when they have no area). Returns null when no triangle survives; a source with
|
|
33
|
+
* triangles that all fail counts as a skipped primitive.
|
|
34
|
+
*/
|
|
35
|
+
export function keepValidTriangles(mesh, report) {
|
|
36
|
+
const skipped = report.skippedGeometry;
|
|
37
|
+
const triangleCount = Math.floor(mesh.indices.length / 3);
|
|
38
|
+
if (triangleCount === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const vertexCount = Math.floor(mesh.positions.length / 3);
|
|
41
|
+
const valid = new Uint8Array(vertexCount);
|
|
42
|
+
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
|
43
|
+
if (finiteTriple(mesh.positions, vertex))
|
|
44
|
+
valid[vertex] = 1;
|
|
45
|
+
else
|
|
46
|
+
skipped.vertices++;
|
|
47
|
+
}
|
|
48
|
+
const remap = new Int32Array(vertexCount).fill(-1);
|
|
49
|
+
const positions = [];
|
|
50
|
+
const normals = [];
|
|
51
|
+
const indices = [];
|
|
52
|
+
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
53
|
+
const corners = [
|
|
54
|
+
mesh.indices[triangle * 3],
|
|
55
|
+
mesh.indices[triangle * 3 + 1],
|
|
56
|
+
mesh.indices[triangle * 3 + 2],
|
|
57
|
+
];
|
|
58
|
+
if (!corners.every((index) => Number.isInteger(index) && index >= 0 && index < vertexCount && valid[index])) {
|
|
59
|
+
skipped.triangles++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const index of corners) {
|
|
63
|
+
if (remap[index] === -1) {
|
|
64
|
+
remap[index] = positions.length / 3;
|
|
65
|
+
positions.push(mesh.positions[index * 3], mesh.positions[index * 3 + 1], mesh.positions[index * 3 + 2]);
|
|
66
|
+
normals.push(mesh.normals[index * 3], mesh.normals[index * 3 + 1], mesh.normals[index * 3 + 2]);
|
|
67
|
+
}
|
|
68
|
+
indices.push(remap[index]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (indices.length === 0) {
|
|
72
|
+
skipped.primitives++;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
report.replaced.normals += recomputeNonFiniteNormals(positions, normals, indices);
|
|
76
|
+
return { positions, normals, indices };
|
|
77
|
+
}
|
|
78
|
+
function finiteTriple(values, vertex) {
|
|
79
|
+
return (Number.isFinite(values[vertex * 3]) &&
|
|
80
|
+
Number.isFinite(values[vertex * 3 + 1]) &&
|
|
81
|
+
Number.isFinite(values[vertex * 3 + 2]));
|
|
82
|
+
}
|
|
83
|
+
// A vertex whose faces have no area has no direction to take; a unit normal keeps lighting sane.
|
|
84
|
+
const FALLBACK_NORMAL = [0, 0, 1];
|
|
85
|
+
/**
|
|
86
|
+
* Each non-finite normal becomes the area-weighted normal of the triangles at its vertex, or the
|
|
87
|
+
* fallback when they have no area. Returns how many were recomputed. Accumulates in typed arrays:
|
|
88
|
+
* a whole mesh can need it (__bench__/normal-recompute.bench.ts).
|
|
89
|
+
*/
|
|
90
|
+
function recomputeNonFiniteNormals(positions, normals, indices) {
|
|
91
|
+
const vertexCount = positions.length / 3;
|
|
92
|
+
const slotOf = new Int32Array(vertexCount).fill(-1);
|
|
93
|
+
let broken = 0;
|
|
94
|
+
for (let vertex = 0; vertex < vertexCount; vertex++)
|
|
95
|
+
if (!finiteTriple(normals, vertex))
|
|
96
|
+
slotOf[vertex] = broken++;
|
|
97
|
+
if (broken === 0)
|
|
98
|
+
return 0;
|
|
99
|
+
const sums = new Float64Array(broken * 3);
|
|
100
|
+
for (let t = 0; t + 2 < indices.length; t += 3) {
|
|
101
|
+
const a = indices[t];
|
|
102
|
+
const b = indices[t + 1];
|
|
103
|
+
const c = indices[t + 2];
|
|
104
|
+
if (slotOf[a] < 0 && slotOf[b] < 0 && slotOf[c] < 0)
|
|
105
|
+
continue;
|
|
106
|
+
const ux = positions[b * 3] - positions[a * 3];
|
|
107
|
+
const uy = positions[b * 3 + 1] - positions[a * 3 + 1];
|
|
108
|
+
const uz = positions[b * 3 + 2] - positions[a * 3 + 2];
|
|
109
|
+
const vx = positions[c * 3] - positions[a * 3];
|
|
110
|
+
const vy = positions[c * 3 + 1] - positions[a * 3 + 1];
|
|
111
|
+
const vz = positions[c * 3 + 2] - positions[a * 3 + 2];
|
|
112
|
+
const fx = uy * vz - uz * vy;
|
|
113
|
+
const fy = uz * vx - ux * vz;
|
|
114
|
+
const fz = ux * vy - uy * vx;
|
|
115
|
+
for (const corner of [a, b, c]) {
|
|
116
|
+
const slot = slotOf[corner];
|
|
117
|
+
if (slot < 0)
|
|
118
|
+
continue;
|
|
119
|
+
sums[slot * 3] += fx;
|
|
120
|
+
sums[slot * 3 + 1] += fy;
|
|
121
|
+
sums[slot * 3 + 2] += fz;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
|
125
|
+
const slot = slotOf[vertex];
|
|
126
|
+
if (slot < 0)
|
|
127
|
+
continue;
|
|
128
|
+
const x = sums[slot * 3];
|
|
129
|
+
const y = sums[slot * 3 + 1];
|
|
130
|
+
const z = sums[slot * 3 + 2];
|
|
131
|
+
// Coordinates past ~1e154 overflow the summed cross products: Infinity, not a direction.
|
|
132
|
+
const length = Math.hypot(x, y, z);
|
|
133
|
+
const normal = length > 0 && Number.isFinite(length) ? [x / length, y / length, z / length] : FALLBACK_NORMAL;
|
|
134
|
+
for (let axis = 0; axis < 3; axis++)
|
|
135
|
+
normals[vertex * 3 + axis] = Math.round(normal[axis] * 1000) / 1000 || 0;
|
|
136
|
+
}
|
|
137
|
+
return broken;
|
|
138
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type AnyNode, type AnyNodeId, type Collection } from '@pascal-app/core';
|
|
2
2
|
import { type IfcConversionSimplificationOptions } from './cleanup';
|
|
3
|
+
import { type IfcConversionReport } from './geometry-validity';
|
|
3
4
|
export type { IfcConversionSimplificationOptions, IfcConversionSimplificationStats, } from './cleanup';
|
|
5
|
+
export { conversionReportHasIssues, type IfcConversionReport } from './geometry-validity';
|
|
4
6
|
export type PascalNode = AnyNode;
|
|
5
7
|
export interface PascalSceneGraph {
|
|
6
8
|
nodes: Record<AnyNodeId, AnyNode>;
|
|
@@ -12,9 +14,20 @@ export interface ConversionOptions {
|
|
|
12
14
|
extrusionDepthIsHeight?: boolean;
|
|
13
15
|
swapProfileDimensions?: boolean;
|
|
14
16
|
wasmPath?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The URL to load one of web-ifc's wasm files from (`web-ifc.wasm`, or `web-ifc-mt.wasm` when
|
|
19
|
+
* the page is cross-origin isolated). Wins over `wasmPath`; lets a host serve a versioned or
|
|
20
|
+
* cache-busted URL.
|
|
21
|
+
*/
|
|
22
|
+
locateWasmFile?: (fileName: string) => string;
|
|
15
23
|
simplify?: boolean | IfcConversionSimplificationOptions;
|
|
16
24
|
label?: string;
|
|
17
25
|
}
|
|
18
26
|
export declare const VARIANT_PRESETS: Record<string, ConversionOptions>;
|
|
19
27
|
export declare function convertIfcToPascal(ifcData: Uint8Array, onProgress?: (message: string, percent: number) => void, options?: ConversionOptions): Promise<PascalSceneGraph>;
|
|
28
|
+
/** `convertIfcToPascal`, plus what the conversion had to skip or replace to finish. */
|
|
29
|
+
export declare function convertIfcToPascalWithReport(ifcData: Uint8Array, onProgress?: (message: string, percent: number) => void, options?: ConversionOptions): Promise<{
|
|
30
|
+
scene: PascalSceneGraph;
|
|
31
|
+
report: IfcConversionReport;
|
|
32
|
+
}>;
|
|
20
33
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAId,KAAK,UAAU,EAmBhB,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAId,KAAK,UAAU,EAmBhB,MAAM,kBAAkB,CAAA;AAIzB,OAAO,EAAE,KAAK,kCAAkC,EAA+B,MAAM,WAAW,CAAA;AAGhG,OAAO,EAEL,KAAK,mBAAmB,EAGzB,MAAM,qBAAqB,CAAA;AAM5B,YAAY,EACV,kCAAkC,EAClC,gCAAgC,GACjC,MAAM,WAAW,CAAA;AAClB,OAAO,EAAE,yBAAyB,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAEzF,MAAM,MAAM,UAAU,GAAG,OAAO,CAAA;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IACjC,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;CACzC;AAuyBD,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAA;IAC7C,QAAQ,CAAC,EAAE,OAAO,GAAG,kCAAkC,CAAA;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAa7D,CAAA;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,UAAU,EACnB,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,EACvD,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,gBAAgB,CAAC,CAE3B;AAED,uFAAuF;AACvF,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,UAAU,EACnB,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,EACvD,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAC,CAshEnE"}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
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';
|
|
2
|
+
import { NEW_DOOR_FRAME_THICKNESS } from '@pascal-app/core/building';
|
|
2
3
|
import * as WebIFC from 'web-ifc';
|
|
3
4
|
import { extractBeamGeometry } from './beam-geometry';
|
|
4
5
|
import { simplifyConvertedSceneGraph } from './cleanup';
|
|
5
6
|
import { importCollections } from './collections';
|
|
6
7
|
import { doorGlazingStyle, doorStyleFromIfcOperation } from './door-semantics';
|
|
8
|
+
import { emptyConversionReport, isFiniteMatrix, keepValidTriangles, } from './geometry-validity';
|
|
7
9
|
import { nextId, seedIds } from './ids';
|
|
8
10
|
import { applyRoomFirstStructure } from './room-first';
|
|
9
11
|
import { selectStoreyForElevation } from './storey-semantics';
|
|
10
12
|
import { redundantWallIds } from './wall-joins';
|
|
13
|
+
export { conversionReportHasIssues } from './geometry-validity';
|
|
11
14
|
function meta(node) {
|
|
12
15
|
return (node?.metadata ?? {});
|
|
13
16
|
}
|
|
@@ -534,7 +537,7 @@ function roundMeshNormal(value) {
|
|
|
534
537
|
const rounded = Math.round(value * 1000) / 1000;
|
|
535
538
|
return rounded === 0 ? 0 : rounded;
|
|
536
539
|
}
|
|
537
|
-
function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, originOffset, levelElevation, swapYZ) {
|
|
540
|
+
function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, originOffset, levelElevation, swapYZ, report) {
|
|
538
541
|
let flatMesh;
|
|
539
542
|
try {
|
|
540
543
|
flatMesh = ifcApi.GetFlatMesh(modelID, expressID);
|
|
@@ -547,6 +550,10 @@ function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, o
|
|
|
547
550
|
for (let geometryIndex = 0; geometryIndex < flatMesh.geometries.size(); geometryIndex++) {
|
|
548
551
|
const placed = flatMesh.geometries.get(geometryIndex);
|
|
549
552
|
const matrix = placed.flatTransformation;
|
|
553
|
+
if (!isFiniteMatrix(matrix)) {
|
|
554
|
+
report.skippedGeometry.transforms++;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
550
557
|
const geometry = ifcApi.GetGeometry(modelID, placed.geometryExpressID);
|
|
551
558
|
try {
|
|
552
559
|
const vertices = ifcApi.GetVertexArray(geometry.GetVertexData(), geometry.GetVertexDataSize());
|
|
@@ -594,12 +601,10 @@ function extractImportedMeshPrimitives(ifcApi, modelID, expressID, unitFactor, o
|
|
|
594
601
|
const normalLength = Math.hypot(...mappedNormal) || 1;
|
|
595
602
|
normals.push(roundMeshNormal(mappedNormal[0] / normalLength), roundMeshNormal(mappedNormal[1] / normalLength), roundMeshNormal(mappedNormal[2] / normalLength));
|
|
596
603
|
}
|
|
597
|
-
const
|
|
598
|
-
if (
|
|
604
|
+
const mesh = keepValidTriangles({ positions, normals, indices: Array.from(sourceIndices) }, report);
|
|
605
|
+
if (mesh) {
|
|
599
606
|
primitives.push({
|
|
600
|
-
|
|
601
|
-
normals,
|
|
602
|
-
indices,
|
|
607
|
+
...mesh,
|
|
603
608
|
color: sourceColor(placed.color),
|
|
604
609
|
opacity: Math.max(0, Math.min(1, placed.color?.w ?? 1)),
|
|
605
610
|
});
|
|
@@ -704,6 +709,11 @@ export const VARIANT_PRESETS = {
|
|
|
704
709
|
},
|
|
705
710
|
};
|
|
706
711
|
export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
712
|
+
return (await convertIfcToPascalWithReport(ifcData, onProgress, options)).scene;
|
|
713
|
+
}
|
|
714
|
+
/** `convertIfcToPascal`, plus what the conversion had to skip or replace to finish. */
|
|
715
|
+
export async function convertIfcToPascalWithReport(ifcData, onProgress, options) {
|
|
716
|
+
const report = emptyConversionReport();
|
|
707
717
|
const opts = {
|
|
708
718
|
swapYZ: options?.swapYZ ?? true,
|
|
709
719
|
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
@@ -722,7 +732,8 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
722
732
|
progress('Initializing IFC parser...', 0);
|
|
723
733
|
const ifcApi = new WebIFC.IfcAPI();
|
|
724
734
|
ifcApi.SetWasmPath(opts.wasmPath, true);
|
|
725
|
-
|
|
735
|
+
const locateWasmFile = options?.locateWasmFile;
|
|
736
|
+
await ifcApi.Init(locateWasmFile ? (fileName) => locateWasmFile(fileName) : undefined);
|
|
726
737
|
progress('Opening IFC model...', 10);
|
|
727
738
|
const modelID = ifcApi.OpenModel(ifcData);
|
|
728
739
|
seedIds(ifcData);
|
|
@@ -744,7 +755,9 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
744
755
|
const expressIdToNodeId = new Map();
|
|
745
756
|
progress('Analyzing spatial relationships...', 20);
|
|
746
757
|
// Detect length unit → meters conversion factor
|
|
747
|
-
const
|
|
758
|
+
const detectedUnitFactor = getLengthUnitFactor(ifcApi, modelID);
|
|
759
|
+
report.replaced.unitFactor = !(Number.isFinite(detectedUnitFactor) && detectedUnitFactor > 0);
|
|
760
|
+
const unitFactor = report.replaced.unitFactor ? 1 : detectedUnitFactor;
|
|
748
761
|
// Compute scene origin offset to center georeferenced models near (0,0,0)
|
|
749
762
|
let originOffset = [0, 0, 0];
|
|
750
763
|
try {
|
|
@@ -754,7 +767,11 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
754
767
|
const anchor = ifcApi.GetLine(modelID, anchorId);
|
|
755
768
|
if (anchor.ObjectPlacement?.value) {
|
|
756
769
|
const mat = resolveWorldTransform(ifcApi, modelID, anchor.ObjectPlacement.value);
|
|
757
|
-
|
|
770
|
+
const offset = transformPoint3(mat, [0, 0, 0]);
|
|
771
|
+
if (offset.every(Number.isFinite))
|
|
772
|
+
originOffset = offset;
|
|
773
|
+
else
|
|
774
|
+
report.replaced.originOffset = true;
|
|
758
775
|
}
|
|
759
776
|
}
|
|
760
777
|
}
|
|
@@ -949,11 +966,21 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
949
966
|
return storeyExpressId == null ? 0 : (storeyElevationByExpressId.get(storeyExpressId) ?? 0);
|
|
950
967
|
}
|
|
951
968
|
const importedPrimitivesByExpressId = new Map();
|
|
969
|
+
// Elements whose every piece of geometry was unreadable: omitted, never rebuilt as a native
|
|
970
|
+
// node from their placement data.
|
|
971
|
+
const unreadableGeometry = new Set();
|
|
952
972
|
function importedMeshPrimitivesFor(expressId) {
|
|
953
973
|
const cached = importedPrimitivesByExpressId.get(expressId);
|
|
954
974
|
if (cached)
|
|
955
975
|
return cached;
|
|
956
|
-
const
|
|
976
|
+
const { transforms, primitives: skippedPrimitives } = report.skippedGeometry;
|
|
977
|
+
const skippedBefore = transforms + skippedPrimitives;
|
|
978
|
+
const primitives = extractImportedMeshPrimitives(ifcApi, modelID, expressId, unitFactor, originOffset, elementLevelElevation(expressId), opts.swapYZ, report);
|
|
979
|
+
const skippedAfter = report.skippedGeometry.transforms + report.skippedGeometry.primitives;
|
|
980
|
+
if (primitives.length === 0 && skippedAfter > skippedBefore) {
|
|
981
|
+
unreadableGeometry.add(expressId);
|
|
982
|
+
report.skippedGeometry.elements++;
|
|
983
|
+
}
|
|
957
984
|
importedPrimitivesByExpressId.set(expressId, primitives);
|
|
958
985
|
return primitives;
|
|
959
986
|
}
|
|
@@ -1129,6 +1156,11 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1129
1156
|
else {
|
|
1130
1157
|
elevation *= unitFactor;
|
|
1131
1158
|
}
|
|
1159
|
+
if (!Number.isFinite(elevation)) {
|
|
1160
|
+
report.replaced.levelElevations++;
|
|
1161
|
+
const explicit = (storey.Elevation?.value ?? 0) * unitFactor;
|
|
1162
|
+
elevation = Number.isFinite(explicit) ? explicit : 0;
|
|
1163
|
+
}
|
|
1132
1164
|
storeyElevationByExpressId.set(storeyExpressID, elevation);
|
|
1133
1165
|
const levelNode = tryParse(LevelNode, 'level', {
|
|
1134
1166
|
object: 'node',
|
|
@@ -1575,6 +1607,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1575
1607
|
object: 'node',
|
|
1576
1608
|
id: nodeId,
|
|
1577
1609
|
type: 'door',
|
|
1610
|
+
frameThickness: NEW_DOOR_FRAME_THICKNESS,
|
|
1578
1611
|
name: element.Name?.value || `Door`,
|
|
1579
1612
|
parentId: wallNodeId,
|
|
1580
1613
|
visible: true,
|
|
@@ -1756,6 +1789,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1756
1789
|
object: 'node',
|
|
1757
1790
|
id: nodeId,
|
|
1758
1791
|
type: 'door',
|
|
1792
|
+
frameThickness: NEW_DOOR_FRAME_THICKNESS,
|
|
1759
1793
|
name: element.Name?.value || `Door`,
|
|
1760
1794
|
parentId: parentNodeId,
|
|
1761
1795
|
visible: true,
|
|
@@ -1824,11 +1858,12 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1824
1858
|
// Pascal's storey-wide wall-support calculation. Preserve roofs and stair
|
|
1825
1859
|
// landings as exact meshes: roofs may be sloped, while a local landing is
|
|
1826
1860
|
// not a storey floor and must not raise adjacent wall bases.
|
|
1861
|
+
const hasMesh = importedMeshPrimitivesFor(slabExpressID).length > 0;
|
|
1862
|
+
if (unreadableGeometry.has(slabExpressID))
|
|
1863
|
+
continue;
|
|
1827
1864
|
const slabPredefinedType = String(slab.PredefinedType?.value ?? '').toUpperCase();
|
|
1828
|
-
if ((slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') &&
|
|
1829
|
-
importedMeshPrimitivesFor(slabExpressID).length > 0) {
|
|
1865
|
+
if ((slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') && hasMesh)
|
|
1830
1866
|
continue;
|
|
1831
|
-
}
|
|
1832
1867
|
const levelElevation = elementLevelElevation(slabExpressID);
|
|
1833
1868
|
let plan = null;
|
|
1834
1869
|
try {
|
|
@@ -1838,7 +1873,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1838
1873
|
// mesh fallback below
|
|
1839
1874
|
}
|
|
1840
1875
|
// A sloped or tilted slab is not a Pascal floor; keep its exact mesh.
|
|
1841
|
-
if (plan?.flat === false &&
|
|
1876
|
+
if (plan?.flat === false && hasMesh)
|
|
1842
1877
|
continue;
|
|
1843
1878
|
if (!plan?.polygon)
|
|
1844
1879
|
continue;
|
|
@@ -2680,8 +2715,11 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
2680
2715
|
});
|
|
2681
2716
|
progress('Complete!', 100);
|
|
2682
2717
|
return {
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2718
|
+
scene: {
|
|
2719
|
+
nodes,
|
|
2720
|
+
rootNodeIds: rootNodeIds,
|
|
2721
|
+
collections,
|
|
2722
|
+
},
|
|
2723
|
+
report,
|
|
2686
2724
|
};
|
|
2687
2725
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/ifc-converter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,21 +19,24 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
+
"!dist/__bench__",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc --build",
|
|
27
|
+
"check-types": "tsc -p tsconfig.bench.json --pretty false",
|
|
26
28
|
"dev": "tsgo --build --watch",
|
|
27
29
|
"test": "bun test tests",
|
|
30
|
+
"bench:normals": "bun run src/__bench__/normal-recompute.bench.ts",
|
|
28
31
|
"prepublishOnly": "npm run build"
|
|
29
32
|
},
|
|
30
33
|
"dependencies": {
|
|
31
|
-
"@pascal-app/core": "^1.0.
|
|
34
|
+
"@pascal-app/core": "^1.0.7",
|
|
32
35
|
"web-ifc": "^0.0.78"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
38
|
"@pascal/typescript-config": "*",
|
|
36
|
-
"@types/bun": "^1.4.
|
|
39
|
+
"@types/bun": "^1.4.4",
|
|
37
40
|
"typescript": "7.0.2"
|
|
38
41
|
},
|
|
39
42
|
"repository": {
|