@pascal-app/ifc-converter 1.0.0 → 1.0.3

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
@@ -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
@@ -11,6 +11,7 @@ export interface ConversionOptions {
11
11
  swapYZ?: boolean;
12
12
  extrusionDepthIsHeight?: boolean;
13
13
  swapProfileDimensions?: boolean;
14
+ wasmPath?: string;
14
15
  simplify?: boolean | IfcConversionSimplificationOptions;
15
16
  label?: string;
16
17
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAaf,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EAAE,KAAK,kCAAkC,EAA+B,MAAM,WAAW,CAAA;AAEhG,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;AAimBD,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,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,CA65C3B"}
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, StairNode, WallNode, WindowNode, } from '@pascal-app/core';
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('/', true);
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
- if (parentNodeId && nodes[parentNodeId]) {
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
- if (parentNodeId && nodes[parentNodeId]) {
773
- ;
774
- nodes[parentNodeId].children?.push(nodeId);
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 parentExpressID = parentMap.get(wallExpressID);
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 wall length from profile or XDim
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
- let wallLength = body.xDim;
873
- if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
874
- const xs = body.profilePoints.map((p) => p[0]);
875
- wallLength = Math.max(...xs) - Math.min(...xs);
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 (wallLength) {
878
- const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]));
879
- end = [se[0], se[1]];
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
- if (parentNodeId && nodes[parentNodeId]) {
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 stays parented to its spatial
1115
- // container at the origin — we have no basis to place it on a wall.
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
- // When hosted, parent to (and live inside) the wall — same as the
1208
- // void/fill path. Otherwise fall back to the spatial container.
1209
- const containerExpressID = parentMap.get(fillId);
1210
- const containerNodeId = containerExpressID
1211
- ? (expressIdToNodeId.get(containerExpressID) ?? null)
1212
- : null;
1213
- const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId;
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
- // Placed by nearest-wall projection; [0,0,0] only when no wall
1227
- // is within range (then it sits on its spatial container).
1228
- position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
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 = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
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: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
1257
- ...(hosted ? { wallId: hosted.info.nodeId } : {}),
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
- ...(hosted ? { sillHeight: sill } : {}),
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 parentExpressID = parentMap.get(slabExpressID);
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
- if (parentNodeId && nodes[parentNodeId]) {
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 parentExpressID = parentMap.get(roofExpressID);
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
- if (parentNodeId && nodes[parentNodeId]) {
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 parentExpressID = parentMap.get(colExpressID);
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 = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
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
- if (parentNodeId && nodes[parentNodeId]) {
1653
- ;
1654
- nodes[parentNodeId].children?.push(nodeId);
1655
- }
1833
+ attachNodeToGraph(nodeId, parentNodeId);
1656
1834
  }
1657
1835
  }
1658
- // Beams: skipped for now — Pascal has no `beam` node type yet. When it
1659
- // lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
1660
- // (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
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
- beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE);
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 in all versions */
1927
+ /* IFC schema may not expose spaces */
1671
1928
  }
1672
- for (const beamType of beamTypes) {
1673
- try {
1674
- const beams = ifcApi.GetLineIDsWithType(modelID, beamType);
1675
- skippedBeamCount += beams.size();
1676
- }
1677
- catch {
1678
- /* type not present in this file */
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
- if (skippedBeamCount > 0) {
1682
- console.warn(`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`);
1683
- }
1684
- // Items: skipped for now — Pascal's ItemNode requires a full `asset`
1685
- // (catalog reference with id/src/dimensions/etc.) that the converter
1686
- // can't synthesise from raw IFC geometry. When the editor grows a
1687
- // raw-geometry escape hatch (or we add a placeholder-asset registry),
1688
- // restore the mapping from the pre-migration git history. We still
1689
- // walk the entities to log a count for diagnostics.
1690
- let skippedItemCount = 0;
1691
- const itemTypeKeys = [
1692
- WebIFC.IFCFURNISHINGELEMENT,
1693
- WebIFC.IFCBUILDINGELEMENTPROXY,
1694
- WebIFC.IFCRAILING,
1695
- WebIFC.IFCCOVERING,
1696
- WebIFC.IFCCURTAINWALL,
1697
- WebIFC.IFCPLATE,
1698
- WebIFC.IFCMEMBER,
1699
- WebIFC.IFCFOOTING,
1700
- ];
1701
- for (const itemType of itemTypeKeys) {
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
- const items = ifcApi.GetLineIDsWithType(modelID, itemType);
1704
- skippedItemCount += items.size();
2014
+ elements = ifcApi.GetLineIDsWithType(modelID, ifcType);
1705
2015
  }
1706
2016
  catch {
1707
- /* type not present in this file */
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 = findStoreyForElement(m.expressID);
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
- skippedItems: skippedItemCount,
2260
+ spaces: importedSpaceCount,
2261
+ importedMeshes: importedMeshCount,
1911
2262
  });
1912
2263
  progress('Complete!', 100);
1913
2264
  return {
@@ -0,0 +1,6 @@
1
+ export type StoreyElevation = {
2
+ expressId: number;
3
+ elevation: number;
4
+ };
5
+ export declare function selectStoreyForElevation(candidates: StoreyElevation[], elementElevation: number): number | null;
6
+ //# sourceMappingURL=storey-semantics.d.ts.map
@@ -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.0",
4
- "description": "IFC \u2192 Pascal scene-graph conversion. Pure logic \u2014 no DOM, no React.",
3
+ "version": "1.0.3",
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.0",
26
+ "@pascal-app/core": "^1.0.3",
27
27
  "nanoid": "^5.1.6",
28
28
  "web-ifc": "^0.0.77"
29
29
  },
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/pascalorg/editor.git",
37
+ "url": "git+https://github.com/pascalorg/editor.git",
38
38
  "directory": "packages/ifc-converter"
39
39
  },
40
40
  "license": "MIT",