@pascal-app/ifc-converter 1.0.0 → 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/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/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 +560 -209
- 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 +3 -3
package/README.md
CHANGED
|
@@ -5,3 +5,28 @@ IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against
|
|
|
5
5
|
`@pascal-app/core` schemas.
|
|
6
6
|
|
|
7
7
|
No DOM, no React. The UI lives in `apps/ifc-converter`.
|
|
8
|
+
|
|
9
|
+
`IFCBEAM` and `IFCBEAMSTANDARDCASE` are imported as editable `block` nodes.
|
|
10
|
+
Their triangulated IFC geometry preserves cross-sections, rotations, and slopes;
|
|
11
|
+
positions are relative to the containing storey. IFC IDs, names, properties,
|
|
12
|
+
and material metadata are retained. Beams without renderable geometry are
|
|
13
|
+
reported in the conversion log. Imported beams use the block editor rather than
|
|
14
|
+
dedicated parametric beam controls.
|
|
15
|
+
|
|
16
|
+
Native Pascal nodes are produced when the converter can recover the required
|
|
17
|
+
parameters for sites, buildings, levels, walls, doors, windows, slabs, columns,
|
|
18
|
+
and IFC spaces (as room zones). IFC stair flights are retained as exact imported
|
|
19
|
+
meshes; roofs retain Pascal hierarchy and source metadata but are not yet a
|
|
20
|
+
complete parametric conversion. Railings, coverings, furnishings, proxies,
|
|
21
|
+
curtain walls, plates, members,
|
|
22
|
+
footings, and elements whose native parameters cannot be recovered are retained
|
|
23
|
+
as selectable `imported-mesh` nodes using their IFC triangle geometry and color.
|
|
24
|
+
Imported meshes are import-only and do not appear as empty objects in the editor
|
|
25
|
+
palette.
|
|
26
|
+
|
|
27
|
+
Door families are derived from `IfcDoor.OperationType`. Glazing is applied from
|
|
28
|
+
the standardized `Pset_DoorCommon.GlazingAreaFraction` property rather than
|
|
29
|
+
from element names or project-specific conventions.
|
|
30
|
+
|
|
31
|
+
Browser callers use the default WebIFC WASM path (`/`). Node callers can pass
|
|
32
|
+
`{ wasmPath: '/absolute/path/to/web-ifc/' }` in `ConversionOptions`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type BlockTopology } from '@pascal-app/core';
|
|
2
|
+
import type { IfcAPI } from 'web-ifc';
|
|
3
|
+
type Point = [number, number, number];
|
|
4
|
+
export declare function extractBeamGeometry(ifcApi: IfcAPI, modelID: number, expressID: number, options: {
|
|
5
|
+
origin: number[];
|
|
6
|
+
unitFactor: number;
|
|
7
|
+
swapYZ: boolean;
|
|
8
|
+
levelElevation: number;
|
|
9
|
+
}): {
|
|
10
|
+
position: Point;
|
|
11
|
+
topology: BlockTopology;
|
|
12
|
+
} | null;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=beam-geometry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"beam-geometry.d.ts","sourceRoot":"","sources":["../src/beam-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAA0B,MAAM,kBAAkB,CAAA;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAErC,KAAK,KAAK,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;AAErC,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GACzF;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,aAAa,CAAA;CAAE,GAAG,IAAI,CAyFrD"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { blockUndirectedEdgeKey } from '@pascal-app/core';
|
|
2
|
+
export function extractBeamGeometry(ifcApi, modelID, expressID, options) {
|
|
3
|
+
const mesh = ifcApi.GetFlatMesh(modelID, expressID);
|
|
4
|
+
const topology = { vertices: [], edges: [], faces: [] };
|
|
5
|
+
const vertexByPosition = new Map();
|
|
6
|
+
const edgeKeys = new Set();
|
|
7
|
+
try {
|
|
8
|
+
for (let g = 0; g < mesh.geometries.size(); g++) {
|
|
9
|
+
const placed = mesh.geometries.get(g);
|
|
10
|
+
const m = placed.flatTransformation;
|
|
11
|
+
const geometry = ifcApi.GetGeometry(modelID, placed.geometryExpressID);
|
|
12
|
+
try {
|
|
13
|
+
const vertices = ifcApi.GetVertexArray(geometry.GetVertexData(), geometry.GetVertexDataSize());
|
|
14
|
+
const indices = ifcApi.GetIndexArray(geometry.GetIndexData(), geometry.GetIndexDataSize());
|
|
15
|
+
const vertexIds = [];
|
|
16
|
+
for (let v = 0; v < vertices.length; v += 6) {
|
|
17
|
+
const x = vertices[v];
|
|
18
|
+
const y = vertices[v + 1];
|
|
19
|
+
const z = vertices[v + 2];
|
|
20
|
+
const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
21
|
+
const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
22
|
+
const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
23
|
+
// web-ifc meshes are already in meters and use (X, Z, -Y).
|
|
24
|
+
// Undo that basis before applying the converter's origin and axis preset.
|
|
25
|
+
const sx = wx - options.origin[0] * options.unitFactor;
|
|
26
|
+
const sy = -wz - options.origin[1] * options.unitFactor;
|
|
27
|
+
const sz = wy - options.origin[2] * options.unitFactor - options.levelElevation;
|
|
28
|
+
const position = options.swapYZ ? [sx, sz, sy] : [sx, sy, sz];
|
|
29
|
+
if (!position.every(Number.isFinite))
|
|
30
|
+
throw new Error('Non-finite beam vertex');
|
|
31
|
+
const key = position.join(',');
|
|
32
|
+
let id = vertexByPosition.get(key);
|
|
33
|
+
if (!id) {
|
|
34
|
+
id = `v${topology.vertices.length}`;
|
|
35
|
+
topology.vertices.push({ id, position });
|
|
36
|
+
vertexByPosition.set(key, id);
|
|
37
|
+
}
|
|
38
|
+
vertexIds.push(id);
|
|
39
|
+
}
|
|
40
|
+
const determinant = m[0] * (m[5] * m[10] - m[9] * m[6]) -
|
|
41
|
+
m[4] * (m[1] * m[10] - m[9] * m[2]) +
|
|
42
|
+
m[8] * (m[1] * m[6] - m[5] * m[2]);
|
|
43
|
+
// Baking a reflection into the vertices also reverses the outward face winding.
|
|
44
|
+
const reverseWinding = determinant < 0 !== options.swapYZ;
|
|
45
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
46
|
+
const ids = [vertexIds[indices[i]], vertexIds[indices[i + 1]], vertexIds[indices[i + 2]]];
|
|
47
|
+
if (ids.some((id) => id === undefined))
|
|
48
|
+
throw new Error('Invalid beam triangle index');
|
|
49
|
+
if (new Set(ids).size < 3)
|
|
50
|
+
continue;
|
|
51
|
+
if (reverseWinding)
|
|
52
|
+
ids.reverse();
|
|
53
|
+
topology.faces.push({
|
|
54
|
+
id: `f${topology.faces.length}`,
|
|
55
|
+
vertexIds: ids,
|
|
56
|
+
materialSlot: 'body',
|
|
57
|
+
});
|
|
58
|
+
for (let j = 0; j < 3; j++) {
|
|
59
|
+
const a = ids[j];
|
|
60
|
+
const b = ids[(j + 1) % 3];
|
|
61
|
+
const key = blockUndirectedEdgeKey(a, b);
|
|
62
|
+
if (edgeKeys.has(key))
|
|
63
|
+
continue;
|
|
64
|
+
edgeKeys.add(key);
|
|
65
|
+
topology.edges.push({ id: `e${topology.edges.length}`, vertexIds: [a, b] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
geometry.delete();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
mesh.delete?.();
|
|
76
|
+
}
|
|
77
|
+
if (topology.faces.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
const min = [Infinity, Infinity, Infinity];
|
|
80
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
81
|
+
for (const vertex of topology.vertices) {
|
|
82
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
83
|
+
min[axis] = Math.min(min[axis], vertex.position[axis]);
|
|
84
|
+
max[axis] = Math.max(max[axis], vertex.position[axis]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const position = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
88
|
+
for (const vertex of topology.vertices) {
|
|
89
|
+
vertex.position = vertex.position.map((value, axis) => value - position[axis]);
|
|
90
|
+
}
|
|
91
|
+
return { position, topology };
|
|
92
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DoorNode } from '@pascal-app/core';
|
|
2
|
+
type DoorStyle = Partial<DoorNode>;
|
|
3
|
+
/** Map standardized IfcDoor operation values to Pascal door families. */
|
|
4
|
+
export declare function doorStyleFromIfcOperation(operationType: unknown): DoorStyle;
|
|
5
|
+
/** Apply the standardized Pset_DoorCommon glazing fraction after psets load. */
|
|
6
|
+
export declare function doorGlazingStyle(door: DoorNode, glazingAreaFraction: unknown): DoorStyle;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=door-semantics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"door-semantics.d.ts","sourceRoot":"","sources":["../src/door-semantics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAEhD,KAAK,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAalC,yEAAyE;AACzE,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,OAAO,GAAG,SAAS,CAoC3E;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,QAAQ,EAAE,mBAAmB,EAAE,OAAO,GAAG,SAAS,CASxF"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const glazedSegments = [
|
|
2
|
+
{
|
|
3
|
+
type: 'glass',
|
|
4
|
+
heightRatio: 1,
|
|
5
|
+
columnRatios: [1],
|
|
6
|
+
dividerThickness: 0.025,
|
|
7
|
+
panelDepth: 0.008,
|
|
8
|
+
panelInset: 0.035,
|
|
9
|
+
},
|
|
10
|
+
];
|
|
11
|
+
/** Map standardized IfcDoor operation values to Pascal door families. */
|
|
12
|
+
export function doorStyleFromIfcOperation(operationType) {
|
|
13
|
+
const operation = String(operationType ?? '').toUpperCase();
|
|
14
|
+
const leafCount = operation.startsWith('DOUBLE_DOOR') ? 2 : 1;
|
|
15
|
+
if (operation.includes('SLIDING')) {
|
|
16
|
+
return {
|
|
17
|
+
doorType: 'sliding',
|
|
18
|
+
leafCount,
|
|
19
|
+
slideDirection: operation.includes('RIGHT') ? 'right' : 'left',
|
|
20
|
+
trackStyle: 'visible',
|
|
21
|
+
threshold: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (operation.includes('FOLDING')) {
|
|
25
|
+
return {
|
|
26
|
+
doorType: 'folding',
|
|
27
|
+
leafCount,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (operation === 'ROLLINGUP') {
|
|
31
|
+
return {
|
|
32
|
+
doorCategory: 'garage',
|
|
33
|
+
doorType: 'garage-rollup',
|
|
34
|
+
trackStyle: 'overhead',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (operation.startsWith('DOUBLE_DOOR')) {
|
|
38
|
+
return { doorType: 'double', leafCount: 2 };
|
|
39
|
+
}
|
|
40
|
+
if (operation.includes('SWING_RIGHT'))
|
|
41
|
+
return { hingesSide: 'right' };
|
|
42
|
+
if (operation.includes('SWING_LEFT'))
|
|
43
|
+
return { hingesSide: 'left' };
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
/** Apply the standardized Pset_DoorCommon glazing fraction after psets load. */
|
|
47
|
+
export function doorGlazingStyle(door, glazingAreaFraction) {
|
|
48
|
+
const fraction = Number(glazingAreaFraction);
|
|
49
|
+
if (!Number.isFinite(fraction) || fraction <= 0)
|
|
50
|
+
return {};
|
|
51
|
+
return {
|
|
52
|
+
doorType: door.doorType === 'double' ? 'french' : door.doorType,
|
|
53
|
+
segments: glazedSegments,
|
|
54
|
+
contentPadding: [0.04, 0.05],
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/index.d.ts
CHANGED
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,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAkBf,MAAM,kBAAkB,CAAA;AAIzB,OAAO,EAAE,KAAK,kCAAkC,EAA+B,MAAM,WAAW,CAAA;AAIhG,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,OAAO,CAAC,CAAA;CACtC;AAixBD,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,CA8nD3B"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
}
|
|
@@ -498,6 +501,146 @@ function wallHeightThicknessFromExtents(extents, knownLengthMeters, unitFactor)
|
|
|
498
501
|
}
|
|
499
502
|
return null;
|
|
500
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
|
+
}
|
|
501
644
|
function getExtrusionPosition(ifcApi, modelID, element) {
|
|
502
645
|
try {
|
|
503
646
|
if (!element.Representation?.value)
|
|
@@ -553,6 +696,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
553
696
|
swapYZ: options?.swapYZ ?? true,
|
|
554
697
|
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
555
698
|
swapProfileDimensions: options?.swapProfileDimensions ?? false,
|
|
699
|
+
wasmPath: options?.wasmPath ?? '/',
|
|
556
700
|
};
|
|
557
701
|
const simplificationOptions = options?.simplify === false
|
|
558
702
|
? { enabled: false }
|
|
@@ -565,13 +709,22 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
565
709
|
};
|
|
566
710
|
progress('Initializing IFC parser...', 0);
|
|
567
711
|
const ifcApi = new WebIFC.IfcAPI();
|
|
568
|
-
ifcApi.SetWasmPath(
|
|
712
|
+
ifcApi.SetWasmPath(opts.wasmPath, true);
|
|
569
713
|
await ifcApi.Init();
|
|
570
714
|
progress('Opening IFC model...', 10);
|
|
571
715
|
const modelID = ifcApi.OpenModel(ifcData);
|
|
572
716
|
console.log(`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`);
|
|
573
717
|
const nodes = {};
|
|
574
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
|
+
}
|
|
575
728
|
// Maps to track relationships
|
|
576
729
|
const parentMap = new Map();
|
|
577
730
|
const childrenMap = new Map();
|
|
@@ -602,6 +755,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
602
755
|
(worldPt[2] - originOffset[2]) * unitFactor,
|
|
603
756
|
];
|
|
604
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();
|
|
605
762
|
// Collect storey expressIDs for level mapping
|
|
606
763
|
const storeyExpressIds = new Set();
|
|
607
764
|
const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
|
|
@@ -654,6 +811,80 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
654
811
|
}
|
|
655
812
|
return null;
|
|
656
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
|
+
}
|
|
657
888
|
progress('Processing sites...', 30);
|
|
658
889
|
// Process sites
|
|
659
890
|
const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
|
|
@@ -721,10 +952,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
721
952
|
}),
|
|
722
953
|
});
|
|
723
954
|
nodes[nodeId] = buildingNode;
|
|
724
|
-
|
|
725
|
-
;
|
|
726
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
727
|
-
}
|
|
955
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
728
956
|
}
|
|
729
957
|
progress('Processing levels...', 50);
|
|
730
958
|
// Process building storeys (levels)
|
|
@@ -752,6 +980,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
752
980
|
else {
|
|
753
981
|
elevation *= unitFactor;
|
|
754
982
|
}
|
|
983
|
+
storeyElevationByExpressId.set(storeyExpressID, elevation);
|
|
755
984
|
const levelNode = tryParse(LevelNode, 'level', {
|
|
756
985
|
object: 'node',
|
|
757
986
|
id: nodeId,
|
|
@@ -769,9 +998,33 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
769
998
|
}),
|
|
770
999
|
});
|
|
771
1000
|
nodes[nodeId] = levelNode;
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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;
|
|
775
1028
|
}
|
|
776
1029
|
}
|
|
777
1030
|
progress('Processing walls...', 60);
|
|
@@ -824,8 +1077,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
824
1077
|
const wall = ifcApi.GetLine(modelID, wallExpressID);
|
|
825
1078
|
const nodeId = generateId('wall');
|
|
826
1079
|
expressIdToNodeId.set(wallExpressID, nodeId);
|
|
827
|
-
const
|
|
828
|
-
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1080
|
+
const parentNodeId = resolveElementParent(wallExpressID);
|
|
829
1081
|
let start = [0, 0];
|
|
830
1082
|
let end = null;
|
|
831
1083
|
let thickness;
|
|
@@ -867,16 +1119,33 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
867
1119
|
const ys = body.profilePoints.map((p) => p[1]);
|
|
868
1120
|
thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor;
|
|
869
1121
|
}
|
|
870
|
-
// 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).
|
|
871
1126
|
if (!axisPts) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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];
|
|
876
1139
|
}
|
|
877
|
-
if (
|
|
878
|
-
|
|
879
|
-
|
|
1140
|
+
else if (body.xDim) {
|
|
1141
|
+
localStart = [-body.xDim / 2, 0, 0];
|
|
1142
|
+
localEnd = [body.xDim / 2, 0, 0];
|
|
1143
|
+
}
|
|
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]];
|
|
880
1149
|
}
|
|
881
1150
|
}
|
|
882
1151
|
}
|
|
@@ -884,8 +1153,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
884
1153
|
// keep defaults
|
|
885
1154
|
}
|
|
886
1155
|
// Skip walls where we couldn't determine geometry
|
|
887
|
-
if (!end)
|
|
1156
|
+
if (!end) {
|
|
1157
|
+
expressIdToNodeId.delete(wallExpressID);
|
|
888
1158
|
continue;
|
|
1159
|
+
}
|
|
889
1160
|
// Plain IFCWALL frequently carries Brep / mapped geometry rather
|
|
890
1161
|
// than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
|
|
891
1162
|
// read its height/thickness (only IFCWALLSTANDARDCASE reliably
|
|
@@ -942,10 +1213,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
942
1213
|
}),
|
|
943
1214
|
});
|
|
944
1215
|
nodes[nodeId] = wallNode;
|
|
945
|
-
|
|
946
|
-
;
|
|
947
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
948
|
-
}
|
|
1216
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
949
1217
|
}
|
|
950
1218
|
}
|
|
951
1219
|
// Process doors and windows via void/fill relationships
|
|
@@ -1055,11 +1323,13 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1055
1323
|
width: width ?? 0.9,
|
|
1056
1324
|
height: height ?? 2.1,
|
|
1057
1325
|
position: doorPosition,
|
|
1326
|
+
...doorStyleFromIfcOperation(element.OperationType?.value),
|
|
1058
1327
|
metadata: buildMetadata({
|
|
1059
1328
|
ifcType: 'IFCDOOR',
|
|
1060
1329
|
expressID: fillId,
|
|
1061
1330
|
globalId: element.GlobalId?.value,
|
|
1062
1331
|
hostWallExpressID: wallExpressID,
|
|
1332
|
+
operationType: element.OperationType?.value,
|
|
1063
1333
|
}),
|
|
1064
1334
|
});
|
|
1065
1335
|
nodes[nodeId] = doorNode;
|
|
@@ -1111,8 +1381,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1111
1381
|
// door/window → wall link is only implicit in the element's world
|
|
1112
1382
|
// placement. We recover it by projecting the element's world position
|
|
1113
1383
|
// onto the nearest wall segment. Anything farther than
|
|
1114
|
-
// HOST_WALL_MAX_DIST from every wall
|
|
1115
|
-
//
|
|
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.
|
|
1116
1388
|
const HOST_WALL_MAX_DIST = 1.0; // metres
|
|
1117
1389
|
const wallInfos = [];
|
|
1118
1390
|
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
|
@@ -1184,6 +1456,12 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1184
1456
|
try {
|
|
1185
1457
|
const element = ifcApi.GetLine(modelID, fillId);
|
|
1186
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
|
+
}
|
|
1187
1465
|
let width;
|
|
1188
1466
|
let height;
|
|
1189
1467
|
if (element.OverallWidth?.value)
|
|
@@ -1204,13 +1482,13 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1204
1482
|
}
|
|
1205
1483
|
const effWidth = width ?? (isDoor ? 0.9 : 1.0);
|
|
1206
1484
|
const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null;
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
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;
|
|
1214
1492
|
if (isDoor) {
|
|
1215
1493
|
const h = height ?? 2.1;
|
|
1216
1494
|
const nodeId = generateId('door');
|
|
@@ -1223,14 +1501,14 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1223
1501
|
visible: true,
|
|
1224
1502
|
width: width ?? 0.9,
|
|
1225
1503
|
height: h,
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
1504
|
+
position: [hosted.along, h / 2, 0],
|
|
1505
|
+
wallId: hosted.info.nodeId,
|
|
1506
|
+
...doorStyleFromIfcOperation(element.OperationType?.value),
|
|
1230
1507
|
metadata: buildMetadata({
|
|
1231
1508
|
ifcType: 'IFCDOOR',
|
|
1232
1509
|
expressID: fillId,
|
|
1233
1510
|
globalId: element.GlobalId?.value,
|
|
1511
|
+
operationType: element.OperationType?.value,
|
|
1234
1512
|
}),
|
|
1235
1513
|
});
|
|
1236
1514
|
nodes[nodeId] = doorNode;
|
|
@@ -1242,7 +1520,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1242
1520
|
}
|
|
1243
1521
|
else {
|
|
1244
1522
|
const h = height ?? 1.2;
|
|
1245
|
-
const sill =
|
|
1523
|
+
const sill = scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
|
|
1246
1524
|
const nodeId = generateId('window');
|
|
1247
1525
|
const windowNode = tryParse(WindowNode, 'window', {
|
|
1248
1526
|
object: 'node',
|
|
@@ -1253,13 +1531,13 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1253
1531
|
visible: true,
|
|
1254
1532
|
width: width ?? 1.0,
|
|
1255
1533
|
height: h,
|
|
1256
|
-
position:
|
|
1257
|
-
|
|
1534
|
+
position: [hosted.along, sill + h / 2, 0],
|
|
1535
|
+
wallId: hosted.info.nodeId,
|
|
1258
1536
|
metadata: buildMetadata({
|
|
1259
1537
|
ifcType: 'IFCWINDOW',
|
|
1260
1538
|
expressID: fillId,
|
|
1261
1539
|
globalId: element.GlobalId?.value,
|
|
1262
|
-
|
|
1540
|
+
sillHeight: sill,
|
|
1263
1541
|
}),
|
|
1264
1542
|
});
|
|
1265
1543
|
nodes[nodeId] = windowNode;
|
|
@@ -1280,10 +1558,18 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1280
1558
|
for (let i = 0; i < slabs.size(); i++) {
|
|
1281
1559
|
const slabExpressID = slabs.get(i);
|
|
1282
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
|
+
}
|
|
1283
1570
|
const nodeId = generateId('slab');
|
|
1284
1571
|
expressIdToNodeId.set(slabExpressID, nodeId);
|
|
1285
|
-
const
|
|
1286
|
-
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1572
|
+
const parentNodeId = resolveElementParent(slabExpressID);
|
|
1287
1573
|
let polygon = null;
|
|
1288
1574
|
let elevation = 0;
|
|
1289
1575
|
let thickness;
|
|
@@ -1294,7 +1580,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1294
1580
|
: identity();
|
|
1295
1581
|
// Get elevation from placement Z
|
|
1296
1582
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1297
|
-
elevation = s[2];
|
|
1583
|
+
elevation = s[2] - elementLevelElevation(slabExpressID);
|
|
1298
1584
|
// Get body extrusion data
|
|
1299
1585
|
const body = getBodyExtrusionData(ifcApi, modelID, slab);
|
|
1300
1586
|
// Extrusion depth is slab thickness
|
|
@@ -1337,8 +1623,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1337
1623
|
// keep defaults
|
|
1338
1624
|
}
|
|
1339
1625
|
// Skip slabs where we couldn't extract a polygon
|
|
1340
|
-
if (!polygon || polygon.length < 3)
|
|
1626
|
+
if (!polygon || polygon.length < 3) {
|
|
1627
|
+
expressIdToNodeId.delete(slabExpressID);
|
|
1341
1628
|
continue;
|
|
1629
|
+
}
|
|
1342
1630
|
const slabNode = tryParse(SlabNode, 'slab', {
|
|
1343
1631
|
object: 'node',
|
|
1344
1632
|
id: nodeId,
|
|
@@ -1355,114 +1643,12 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1355
1643
|
ifcType: 'IFCSLAB',
|
|
1356
1644
|
expressID: slabExpressID,
|
|
1357
1645
|
globalId: slab.GlobalId?.value,
|
|
1646
|
+
predefinedType: slab.PredefinedType?.value,
|
|
1358
1647
|
thickness,
|
|
1359
1648
|
}),
|
|
1360
1649
|
});
|
|
1361
1650
|
nodes[nodeId] = slabNode;
|
|
1362
|
-
|
|
1363
|
-
;
|
|
1364
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
// Process stairs
|
|
1368
|
-
const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR);
|
|
1369
|
-
for (let i = 0; i < stairs.size(); i++) {
|
|
1370
|
-
const stairExpressID = stairs.get(i);
|
|
1371
|
-
if (expressIdToNodeId.has(stairExpressID))
|
|
1372
|
-
continue;
|
|
1373
|
-
const stair = ifcApi.GetLine(modelID, stairExpressID);
|
|
1374
|
-
const nodeId = generateId('stair');
|
|
1375
|
-
expressIdToNodeId.set(stairExpressID, nodeId);
|
|
1376
|
-
const parentExpressID = parentMap.get(stairExpressID);
|
|
1377
|
-
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1378
|
-
let position = [0, 0, 0];
|
|
1379
|
-
let boundingBox;
|
|
1380
|
-
try {
|
|
1381
|
-
const worldMat = stair.ObjectPlacement?.value
|
|
1382
|
-
? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
|
|
1383
|
-
: identity();
|
|
1384
|
-
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1385
|
-
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
|
|
1386
|
-
// Try stair's own body first
|
|
1387
|
-
const body = getBodyExtrusionData(ifcApi, modelID, stair);
|
|
1388
|
-
if (body.xDim && body.yDim && body.depth) {
|
|
1389
|
-
boundingBox = opts.swapYZ
|
|
1390
|
-
? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
|
|
1391
|
-
: [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor];
|
|
1392
|
-
}
|
|
1393
|
-
// If no body, try to derive from stair flight children
|
|
1394
|
-
if (!boundingBox) {
|
|
1395
|
-
const stairChildren = childrenMap.get(stairExpressID) ?? [];
|
|
1396
|
-
for (const childId of stairChildren) {
|
|
1397
|
-
try {
|
|
1398
|
-
const child = ifcApi.GetLine(modelID, childId);
|
|
1399
|
-
// Check for NumberOfRisers / RiserHeight / TreadLength
|
|
1400
|
-
const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value;
|
|
1401
|
-
const riserHeight = child.RiserHeight?.value;
|
|
1402
|
-
const treadLength = child.TreadLength?.value;
|
|
1403
|
-
if (nRisers && riserHeight && treadLength) {
|
|
1404
|
-
const totalHeight = nRisers * riserHeight * unitFactor;
|
|
1405
|
-
const totalRun = (nRisers - 1) * treadLength * unitFactor;
|
|
1406
|
-
const width = 1.0; // Default stair width
|
|
1407
|
-
const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
|
|
1408
|
-
const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width;
|
|
1409
|
-
boundingBox = opts.swapYZ
|
|
1410
|
-
? [totalRun || 1, totalHeight, stairWidth]
|
|
1411
|
-
: [totalRun || 1, stairWidth, totalHeight];
|
|
1412
|
-
break;
|
|
1413
|
-
}
|
|
1414
|
-
// Fallback: try flight body extrusion
|
|
1415
|
-
const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
|
|
1416
|
-
if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
|
|
1417
|
-
boundingBox = opts.swapYZ
|
|
1418
|
-
? [
|
|
1419
|
-
flightBody.xDim * unitFactor,
|
|
1420
|
-
flightBody.depth * unitFactor,
|
|
1421
|
-
flightBody.yDim * unitFactor,
|
|
1422
|
-
]
|
|
1423
|
-
: [
|
|
1424
|
-
flightBody.xDim * unitFactor,
|
|
1425
|
-
flightBody.yDim * unitFactor,
|
|
1426
|
-
flightBody.depth * unitFactor,
|
|
1427
|
-
];
|
|
1428
|
-
break;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
catch {
|
|
1432
|
-
/* skip child */
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
catch {
|
|
1438
|
-
/* keep defaults */
|
|
1439
|
-
}
|
|
1440
|
-
const stairNode = tryParse(StairNode, 'stair', {
|
|
1441
|
-
object: 'node',
|
|
1442
|
-
id: nodeId,
|
|
1443
|
-
type: 'stair',
|
|
1444
|
-
name: stair.Name?.value || `Stair ${i + 1}`,
|
|
1445
|
-
parentId: parentNodeId || null,
|
|
1446
|
-
visible: true,
|
|
1447
|
-
position,
|
|
1448
|
-
children: [],
|
|
1449
|
-
// TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
|
|
1450
|
-
// risers). The converter only knows the bounding box right now;
|
|
1451
|
-
// keep it in metadata until we map IFC stairs onto the parametric
|
|
1452
|
-
// shape (or extend StairNode with a raw-geometry escape hatch).
|
|
1453
|
-
metadata: buildMetadata({
|
|
1454
|
-
ifcType: 'IFCSTAIR',
|
|
1455
|
-
expressID: stairExpressID,
|
|
1456
|
-
globalId: stair.GlobalId?.value,
|
|
1457
|
-
predefinedType: stair.PredefinedType?.value,
|
|
1458
|
-
boundingBox,
|
|
1459
|
-
}),
|
|
1460
|
-
});
|
|
1461
|
-
nodes[nodeId] = stairNode;
|
|
1462
|
-
if (parentNodeId && nodes[parentNodeId]) {
|
|
1463
|
-
;
|
|
1464
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
1465
|
-
}
|
|
1651
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
1466
1652
|
}
|
|
1467
1653
|
// Process roofs
|
|
1468
1654
|
const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF);
|
|
@@ -1473,8 +1659,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1473
1659
|
const roof = ifcApi.GetLine(modelID, roofExpressID);
|
|
1474
1660
|
const nodeId = generateId('roof');
|
|
1475
1661
|
expressIdToNodeId.set(roofExpressID, nodeId);
|
|
1476
|
-
const
|
|
1477
|
-
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1662
|
+
const parentNodeId = resolveElementParent(roofExpressID);
|
|
1478
1663
|
let polygon;
|
|
1479
1664
|
let elevation;
|
|
1480
1665
|
let height;
|
|
@@ -1483,7 +1668,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1483
1668
|
? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
|
|
1484
1669
|
: identity();
|
|
1485
1670
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1486
|
-
elevation = s[2];
|
|
1671
|
+
elevation = s[2] - elementLevelElevation(roofExpressID);
|
|
1487
1672
|
const body = getBodyExtrusionData(ifcApi, modelID, roof);
|
|
1488
1673
|
if (body.depth)
|
|
1489
1674
|
height = body.depth * unitFactor;
|
|
@@ -1542,10 +1727,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1542
1727
|
}),
|
|
1543
1728
|
});
|
|
1544
1729
|
nodes[nodeId] = roofNode;
|
|
1545
|
-
|
|
1546
|
-
;
|
|
1547
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
1548
|
-
}
|
|
1730
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
1549
1731
|
}
|
|
1550
1732
|
// Process columns
|
|
1551
1733
|
const columnTypes = [WebIFC.IFCCOLUMN];
|
|
@@ -1570,8 +1752,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1570
1752
|
const col = ifcApi.GetLine(modelID, colExpressID);
|
|
1571
1753
|
const nodeId = generateId('column');
|
|
1572
1754
|
expressIdToNodeId.set(colExpressID, nodeId);
|
|
1573
|
-
const
|
|
1574
|
-
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1755
|
+
const parentNodeId = resolveElementParent(colExpressID);
|
|
1575
1756
|
let position = [0, 0, 0];
|
|
1576
1757
|
let width;
|
|
1577
1758
|
let depth;
|
|
@@ -1583,7 +1764,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1583
1764
|
? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
|
|
1584
1765
|
: identity();
|
|
1585
1766
|
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1586
|
-
position =
|
|
1767
|
+
position = toPascalPoint(s, elementLevelElevation(colExpressID));
|
|
1587
1768
|
const body = getBodyExtrusionData(ifcApi, modelID, col);
|
|
1588
1769
|
if (body.depth)
|
|
1589
1770
|
height = body.depth * unitFactor;
|
|
@@ -1649,73 +1830,232 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1649
1830
|
}),
|
|
1650
1831
|
});
|
|
1651
1832
|
nodes[nodeId] = columnNode;
|
|
1652
|
-
|
|
1653
|
-
;
|
|
1654
|
-
nodes[parentNodeId].children?.push(nodeId);
|
|
1655
|
-
}
|
|
1833
|
+
attachNodeToGraph(nodeId, parentNodeId);
|
|
1656
1834
|
}
|
|
1657
1835
|
}
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
|
|
1661
|
-
// extrusion depth → axis length). Reference implementation lives in
|
|
1662
|
-
// git history of this file. We still walk the entities to log how
|
|
1663
|
-
// many beams the IFC contained so the conversion summary is accurate.
|
|
1664
|
-
let skippedBeamCount = 0;
|
|
1665
|
-
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;
|
|
1666
1839
|
try {
|
|
1667
|
-
|
|
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
|
+
}
|
|
1668
1925
|
}
|
|
1669
1926
|
catch {
|
|
1670
|
-
/* not
|
|
1927
|
+
/* IFC schema may not expose spaces */
|
|
1671
1928
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
+
}
|
|
1679
1981
|
}
|
|
1680
1982
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
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;
|
|
1702
2013
|
try {
|
|
1703
|
-
|
|
1704
|
-
skippedItemCount += items.size();
|
|
2014
|
+
elements = ifcApi.GetLineIDsWithType(modelID, ifcType);
|
|
1705
2015
|
}
|
|
1706
2016
|
catch {
|
|
1707
|
-
|
|
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++;
|
|
1708
2051
|
}
|
|
1709
|
-
}
|
|
1710
|
-
if (skippedItemCount > 0) {
|
|
1711
|
-
console.warn(`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`);
|
|
1712
2052
|
}
|
|
1713
2053
|
// Post-process: resolve levelId for all element nodes
|
|
1714
2054
|
for (const node of Object.values(nodes)) {
|
|
1715
2055
|
const m = meta(node);
|
|
1716
2056
|
if (!m.expressID)
|
|
1717
2057
|
continue;
|
|
1718
|
-
const storeyExpId =
|
|
2058
|
+
const storeyExpId = resolveStoreyForElement(m.expressID);
|
|
1719
2059
|
if (storeyExpId != null) {
|
|
1720
2060
|
m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined;
|
|
1721
2061
|
}
|
|
@@ -1794,6 +2134,15 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1794
2134
|
catch {
|
|
1795
2135
|
/* no property rels */
|
|
1796
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
|
+
}
|
|
1797
2146
|
// Materials via IFCRELASSOCIATESMATERIAL
|
|
1798
2147
|
try {
|
|
1799
2148
|
const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
|
|
@@ -1906,8 +2255,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1906
2255
|
stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
|
|
1907
2256
|
roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
|
|
1908
2257
|
columns: Object.values(nodes).filter((n) => n.type === 'column').length,
|
|
2258
|
+
beams: convertedBeamCount,
|
|
1909
2259
|
skippedBeams: skippedBeamCount,
|
|
1910
|
-
|
|
2260
|
+
spaces: importedSpaceCount,
|
|
2261
|
+
importedMeshes: importedMeshCount,
|
|
1911
2262
|
});
|
|
1912
2263
|
progress('Complete!', 100);
|
|
1913
2264
|
return {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storey-semantics.d.ts","sourceRoot":"","sources":["../src/storey-semantics.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,gBAAgB,EAAE,MAAM,GACvB,MAAM,GAAG,IAAI,CAIf"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function selectStoreyForElevation(candidates, elementElevation) {
|
|
2
|
+
const ordered = [...candidates].sort((a, b) => a.elevation - b.elevation);
|
|
3
|
+
const atOrBelow = ordered.filter((candidate) => candidate.elevation <= elementElevation + 0.1);
|
|
4
|
+
return atOrBelow.at(-1)?.expressId ?? ordered.at(0)?.expressId ?? null;
|
|
5
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/ifc-converter",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "IFC
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"prepublishOnly": "npm run build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@pascal-app/core": "^1.0.
|
|
26
|
+
"@pascal-app/core": "^1.0.1",
|
|
27
27
|
"nanoid": "^5.1.6",
|
|
28
28
|
"web-ifc": "^0.0.77"
|
|
29
29
|
},
|