@pascal-app/ifc-converter 1.0.6 → 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 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,EAoChB,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,CAm1CrE;AAiCD,wDAAwD;AACxD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAE9D"}
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 cutDepth = (cut?.top ?? nominalBottom + opening.height) - (cut?.bottom ?? nominalBottom);
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 && opening.width > 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(opening.width, thickness + 0.2);
701
- openingPlacement = model.localPlacement(wallPlacement, relativeFrame(frame, openingFrame));
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, IDENTITY_FRAME)
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
@@ -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;AAGzB,OAAO,EAAE,KAAK,kCAAkC,EAA+B,MAAM,WAAW,CAAA;AAQhG,YAAY,EACV,kCAAkC,EAClC,gCAAgC,GACjC,MAAM,WAAW,CAAA;AAElB,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;AAiyBD,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,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,CA8/D3B"}
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 indices = Array.from(sourceIndices);
598
- if (positions.length >= 9 && indices.length >= 3) {
604
+ const mesh = keepValidTriangles({ positions, normals, indices: Array.from(sourceIndices) }, report);
605
+ if (mesh) {
599
606
  primitives.push({
600
- positions,
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
- await ifcApi.Init();
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 unitFactor = getLengthUnitFactor(ifcApi, modelID);
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
- originOffset = transformPoint3(mat, [0, 0, 0]);
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 primitives = extractImportedMeshPrimitives(ifcApi, modelID, expressId, unitFactor, originOffset, elementLevelElevation(expressId), opts.swapYZ);
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 && importedMeshPrimitivesFor(slabExpressID).length > 0)
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
- nodes,
2684
- rootNodeIds: rootNodeIds,
2685
- collections,
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.6",
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.6",
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.3",
39
+ "@types/bun": "^1.4.4",
37
40
  "typescript": "7.0.2"
38
41
  },
39
42
  "repository": {