@pascal-app/ifc-converter 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1899 @@
1
+ import { BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, LevelNode, RoofNode, SiteNode, SlabNode, StairNode, WallNode, WindowNode, } from '@pascal-app/core';
2
+ import { customAlphabet } from 'nanoid';
3
+ import * as WebIFC from 'web-ifc';
4
+ function meta(node) {
5
+ return (node?.metadata ?? {});
6
+ }
7
+ // Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
8
+ // type that doesn't accept `undefined` (JSON has `null`, not undefined).
9
+ // The converter pulls many fields from optional IFC properties that
10
+ // often return `undefined`; stripping them at the boundary keeps the
11
+ // schemas happy without spraying `?? null` through every assignment.
12
+ function buildMetadata(input) {
13
+ const out = {};
14
+ for (const [key, value] of Object.entries(input)) {
15
+ if (value === undefined)
16
+ continue;
17
+ out[key] = value;
18
+ }
19
+ return out;
20
+ }
21
+ // Wraps a Zod `.parse()` call and surfaces a single-line readable error
22
+ // instead of the framework's JSON-stringified issue list. The first
23
+ // issue is usually the actionable one; we mention the count if there
24
+ // are more so the user knows there's a deeper problem.
25
+ function tryParse(schema, kind, input) {
26
+ try {
27
+ return schema.parse(input);
28
+ }
29
+ catch (err) {
30
+ const issues = err.issues;
31
+ if (Array.isArray(issues) && issues.length > 0) {
32
+ const first = issues[0];
33
+ const path = first && Array.isArray(first.path) && first.path.length > 0
34
+ ? ` at "${first.path.join('.')}"`
35
+ : '';
36
+ const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : '';
37
+ throw new Error(`Could not build ${kind} node${path}: ${first?.message ?? 'schema mismatch'}${more}`);
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+ const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
43
+ function generateId(prefix) {
44
+ return `${prefix}_${nanoid()}`;
45
+ }
46
+ // --- Unit detection ---
47
+ function getLengthUnitFactor(ifcApi, modelID) {
48
+ const prefixFactors = {
49
+ EXA: 1e18,
50
+ PETA: 1e15,
51
+ TERA: 1e12,
52
+ GIGA: 1e9,
53
+ MEGA: 1e6,
54
+ KILO: 1e3,
55
+ HECTO: 1e2,
56
+ DECA: 1e1,
57
+ DECI: 1e-1,
58
+ CENTI: 1e-2,
59
+ MILLI: 1e-3,
60
+ MICRO: 1e-6,
61
+ NANO: 1e-9,
62
+ PICO: 1e-12,
63
+ FEMTO: 1e-15,
64
+ ATTO: 1e-18,
65
+ };
66
+ try {
67
+ const projects = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCPROJECT);
68
+ if (projects.size() === 0)
69
+ return 1;
70
+ const proj = ifcApi.GetLine(modelID, projects.get(0));
71
+ if (!proj.UnitsInContext?.value)
72
+ return 1;
73
+ const unitAssign = ifcApi.GetLine(modelID, proj.UnitsInContext.value);
74
+ if (!unitAssign.Units)
75
+ return 1;
76
+ for (const unitRef of unitAssign.Units) {
77
+ const unit = ifcApi.GetLine(modelID, unitRef.value);
78
+ if (unit.UnitType?.value !== 'LENGTHUNIT')
79
+ continue;
80
+ if (unit.Name?.value === 'METRE' || unit.Name?.value === 'METER') {
81
+ const prefix = unit.Prefix?.value;
82
+ return prefix ? (prefixFactors[prefix] ?? 1) : 1;
83
+ }
84
+ if (unit.Name?.value === 'FOOT' || unit.Name?.value === 'FEET') {
85
+ return 0.3048;
86
+ }
87
+ if (unit.Name?.value === 'INCH') {
88
+ return 0.0254;
89
+ }
90
+ // ConversionBasedUnit — read the conversion factor
91
+ if (unit.ConversionFactor?.value) {
92
+ const factor = ifcApi.GetLine(modelID, unit.ConversionFactor.value);
93
+ if (factor.ValueComponent?.value) {
94
+ return factor.ValueComponent.value;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ catch {
100
+ // fall through
101
+ }
102
+ return 1;
103
+ }
104
+ function identity() {
105
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
106
+ }
107
+ function multiply(a, b) {
108
+ const r = new Array(16).fill(0);
109
+ for (let row = 0; row < 4; row++) {
110
+ for (let col = 0; col < 4; col++) {
111
+ for (let k = 0; k < 4; k++) {
112
+ r[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col];
113
+ }
114
+ }
115
+ }
116
+ return r;
117
+ }
118
+ function transformPoint3(m, p) {
119
+ return [
120
+ m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
121
+ m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
122
+ m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
123
+ ];
124
+ }
125
+ function buildAxis2Placement3DMatrix(ifcApi, modelID, axis2Id) {
126
+ const axis2 = ifcApi.GetLine(modelID, axis2Id);
127
+ const num = (v) => {
128
+ if (v == null)
129
+ return undefined;
130
+ if (typeof v === 'number')
131
+ return v;
132
+ if (v.value != null)
133
+ return v.value;
134
+ return undefined;
135
+ };
136
+ let ox = 0, oy = 0, oz = 0;
137
+ if (axis2.Location?.value) {
138
+ const loc = ifcApi.GetLine(modelID, axis2.Location.value);
139
+ const coords = loc.Coordinates.map((c) => num(c));
140
+ ox = coords[0] ?? 0;
141
+ oy = coords[1] ?? 0;
142
+ oz = coords[2] ?? 0;
143
+ }
144
+ // Z-axis (Axis direction) — default (0,0,1)
145
+ let zx = 0, zy = 0, zz = 1;
146
+ if (axis2.Axis?.value) {
147
+ const ax = ifcApi.GetLine(modelID, axis2.Axis.value);
148
+ const d = ax.DirectionRatios.map((c) => num(c));
149
+ if (d[0] != null) {
150
+ zx = d[0];
151
+ zy = d[1] ?? 0;
152
+ zz = d[2] ?? 0;
153
+ }
154
+ }
155
+ // X-axis (RefDirection) — default (1,0,0)
156
+ let xx = 1, xy = 0, xz = 0;
157
+ if (axis2.RefDirection?.value) {
158
+ const rd = ifcApi.GetLine(modelID, axis2.RefDirection.value);
159
+ const d = rd.DirectionRatios.map((c) => num(c));
160
+ if (d[0] != null) {
161
+ xx = d[0];
162
+ xy = d[1] ?? 0;
163
+ xz = d[2] ?? 0;
164
+ }
165
+ }
166
+ // Normalize Z
167
+ const zLen = Math.sqrt(zx * zx + zy * zy + zz * zz) || 1;
168
+ zx /= zLen;
169
+ zy /= zLen;
170
+ zz /= zLen;
171
+ // Y = Z cross X, then normalize
172
+ let yx = zy * xz - zz * xy;
173
+ let yy = zz * xx - zx * xz;
174
+ let yz = zx * xy - zy * xx;
175
+ const yLen = Math.sqrt(yx * yx + yy * yy + yz * yz) || 1;
176
+ yx /= yLen;
177
+ yy /= yLen;
178
+ yz /= yLen;
179
+ // Recompute X = Y cross Z to ensure orthonormal
180
+ xx = yy * zz - yz * zy;
181
+ xy = yz * zx - yx * zz;
182
+ xz = yx * zy - yy * zx;
183
+ // Row-major 4x4
184
+ return [xx, yx, zx, ox, xy, yy, zy, oy, xz, yz, zz, oz, 0, 0, 0, 1];
185
+ }
186
+ // --- Placement chain resolver ---
187
+ function resolveWorldTransform(ifcApi, modelID, placementId) {
188
+ const chain = [];
189
+ let current = placementId;
190
+ while (current) {
191
+ const placement = ifcApi.GetLine(modelID, current);
192
+ if (placement.RelativePlacement?.value) {
193
+ chain.push(placement.RelativePlacement.value);
194
+ }
195
+ current = placement.PlacementRelTo?.value ?? null;
196
+ }
197
+ // Multiply from root to leaf
198
+ let result = identity();
199
+ for (let i = chain.length - 1; i >= 0; i--) {
200
+ const mat = buildAxis2Placement3DMatrix(ifcApi, modelID, chain[i]);
201
+ result = multiply(result, mat);
202
+ }
203
+ return result;
204
+ }
205
+ // --- Geometry extraction helpers ---
206
+ function getAxisPolyline(ifcApi, modelID, element) {
207
+ try {
208
+ if (!element.Representation?.value)
209
+ return null;
210
+ const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
211
+ for (const repRef of prodRep.Representations) {
212
+ const rep = ifcApi.GetLine(modelID, repRef.value);
213
+ if (rep.RepresentationIdentifier?.value !== 'Axis')
214
+ continue;
215
+ for (const itemRef of rep.Items) {
216
+ const item = ifcApi.GetLine(modelID, itemRef.value);
217
+ // IFCPOLYLINE — Points is an array of CartesianPoint references
218
+ if (Array.isArray(item.Points)) {
219
+ const points = [];
220
+ for (const ptRef of item.Points) {
221
+ const pt = ifcApi.GetLine(modelID, ptRef.value);
222
+ const coords = pt.Coordinates.map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
223
+ points.push([coords[0] ?? 0, coords[1] ?? 0, coords[2] ?? 0]);
224
+ }
225
+ if (points.length >= 2)
226
+ return points;
227
+ }
228
+ // IFCINDEXEDPOLYCURVE — Points is a reference to a point list entity
229
+ if (item.Points?.value && !Array.isArray(item.Points)) {
230
+ const ptList = ifcApi.GetLine(modelID, item.Points.value);
231
+ if (ptList.CoordList) {
232
+ const points = [];
233
+ for (const coords of ptList.CoordList) {
234
+ const c = Array.isArray(coords)
235
+ ? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
236
+ : [];
237
+ points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0]);
238
+ }
239
+ if (points.length >= 2)
240
+ return points;
241
+ }
242
+ }
243
+ // IFCGEOMETRICSET — unwrap to find an inner curve
244
+ if (item.Elements) {
245
+ for (const elemRef of item.Elements) {
246
+ const inner = ifcApi.GetLine(modelID, elemRef.value);
247
+ if (inner.Points?.value) {
248
+ const ptList = ifcApi.GetLine(modelID, inner.Points.value);
249
+ if (ptList.CoordList) {
250
+ const points = [];
251
+ for (const coords of ptList.CoordList) {
252
+ const c = Array.isArray(coords)
253
+ ? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
254
+ : [];
255
+ points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0]);
256
+ }
257
+ if (points.length >= 2)
258
+ return points;
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ catch {
267
+ // fall through
268
+ }
269
+ return null;
270
+ }
271
+ function extractFromExtrusionItem(ifcApi, modelID, item, result) {
272
+ // Unwrap BooleanClippingResult → follow FirstOperand chain to find extrusion
273
+ let current = item;
274
+ for (let guard = 0; guard < 10; guard++) {
275
+ if (current.Depth?.value)
276
+ break;
277
+ if (current.FirstOperand?.value) {
278
+ current = ifcApi.GetLine(modelID, current.FirstOperand.value);
279
+ }
280
+ else {
281
+ break;
282
+ }
283
+ }
284
+ if (current.Depth?.value) {
285
+ result.depth = current.Depth.value;
286
+ }
287
+ if (current.SweptArea?.value) {
288
+ const profile = ifcApi.GetLine(modelID, current.SweptArea.value);
289
+ // Detect the profile shape by the fields present rather than the IFC
290
+ // type id — robust across web-ifc versions. IfcCircleProfileDef has a
291
+ // Radius; IfcRectangleProfileDef has XDim/YDim.
292
+ if (profile.Radius?.value != null) {
293
+ result.profileShape = 'round';
294
+ result.radius = profile.Radius.value;
295
+ }
296
+ if (profile.XDim?.value) {
297
+ result.xDim = profile.XDim.value;
298
+ result.yDim = profile.YDim?.value ?? null;
299
+ if (result.profileShape === null)
300
+ result.profileShape = 'rectangular';
301
+ }
302
+ // Extract profile points — OuterCurve for ArbitraryClosedProfileDef
303
+ const curveRef = profile.OuterCurve?.value;
304
+ if (curveRef) {
305
+ const curve = ifcApi.GetLine(modelID, curveRef);
306
+ if (curve.Points) {
307
+ const pts = [];
308
+ // IFCPOLYLINE — Points is array of CartesianPoint refs
309
+ if (Array.isArray(curve.Points) &&
310
+ curve.Points.length > 0 &&
311
+ curve.Points[0]?.value != null) {
312
+ for (const ptRef of curve.Points) {
313
+ const pt = ifcApi.GetLine(modelID, ptRef.value);
314
+ const coords = pt.Coordinates.map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
315
+ pts.push([coords[0] ?? 0, coords[1] ?? 0]);
316
+ }
317
+ }
318
+ // IFCINDEXEDPOLYCURVE — Points is a reference to a point list
319
+ else if (curve.Points?.value) {
320
+ const ptList = ifcApi.GetLine(modelID, curve.Points.value);
321
+ if (ptList.CoordList) {
322
+ for (const coords of ptList.CoordList) {
323
+ const c = Array.isArray(coords)
324
+ ? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
325
+ : [];
326
+ pts.push([c[0] ?? 0, c[1] ?? 0]);
327
+ }
328
+ }
329
+ }
330
+ if (pts.length >= 3)
331
+ result.profilePoints = pts;
332
+ }
333
+ }
334
+ }
335
+ return result.depth !== null;
336
+ }
337
+ function getBodyExtrusionData(ifcApi, modelID, element) {
338
+ const result = {
339
+ depth: null,
340
+ xDim: null,
341
+ yDim: null,
342
+ profilePoints: null,
343
+ profileShape: null,
344
+ radius: null,
345
+ };
346
+ try {
347
+ if (!element.Representation?.value)
348
+ return result;
349
+ const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
350
+ for (const repRef of prodRep.Representations) {
351
+ const rep = ifcApi.GetLine(modelID, repRef.value);
352
+ if (rep.RepresentationIdentifier?.value !== 'Body')
353
+ continue;
354
+ for (const itemRef of rep.Items) {
355
+ const item = ifcApi.GetLine(modelID, itemRef.value);
356
+ // Direct extrusion or BooleanClippingResult
357
+ if (extractFromExtrusionItem(ifcApi, modelID, item, result))
358
+ return result;
359
+ // MappedRepresentation → unwrap to inner items
360
+ if (item.MappingSource?.value) {
361
+ const src = ifcApi.GetLine(modelID, item.MappingSource.value);
362
+ if (src.MappedRepresentation?.value) {
363
+ const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value);
364
+ if (mapped.Items) {
365
+ for (const mItemRef of mapped.Items) {
366
+ const mItem = ifcApi.GetLine(modelID, mItemRef.value);
367
+ if (extractFromExtrusionItem(ifcApi, modelID, mItem, result))
368
+ return result;
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ catch {
377
+ // fall through
378
+ }
379
+ return result;
380
+ }
381
+ function findExtrusionPosition(ifcApi, modelID, item) {
382
+ let current = item;
383
+ for (let guard = 0; guard < 10; guard++) {
384
+ if (current.Position?.value) {
385
+ return buildAxis2Placement3DMatrix(ifcApi, modelID, current.Position.value);
386
+ }
387
+ if (current.FirstOperand?.value) {
388
+ current = ifcApi.GetLine(modelID, current.FirstOperand.value);
389
+ }
390
+ else {
391
+ break;
392
+ }
393
+ }
394
+ return null;
395
+ }
396
+ // Mesh extents of an element measured in the WALL'S OWN frame: along the
397
+ // wall axis (length), horizontally perpendicular to it (thickness), and
398
+ // vertical (height). Used to recover height/thickness for plain IFCWALL
399
+ // whose Brep/mapped geometry getBodyExtrusionData can't read.
400
+ //
401
+ // Each vertex is transformed to world space (flatTransformation) and
402
+ // then projected onto the known wall-axis direction — NOT read as a raw
403
+ // world AABB. A world AABB conflates length and thickness for any wall
404
+ // the placement rotates (a 37°-rotated 0.2m wall would read ~1.9m
405
+ // thick); projecting onto the actual axis is rotation-invariant.
406
+ // (axisX, axisY) is the unit wall direction in the converter's
407
+ // horizontal frame, which is parallel to web-ifc world XY — both are IFC
408
+ // world coords, differing only by origin/scale, which cancel in extents.
409
+ // Returns extents in the geometry's native units (caller resolves
410
+ // scale), or null on any failure.
411
+ function measureWallLocalExtents(ifcApi, modelID, expressID, axisX, axisY) {
412
+ const perpX = -axisY;
413
+ const perpY = axisX;
414
+ let mesh;
415
+ try {
416
+ mesh = ifcApi.GetFlatMesh(modelID, expressID);
417
+ }
418
+ catch {
419
+ return null;
420
+ }
421
+ try {
422
+ const geoms = mesh.geometries;
423
+ let minA = Number.POSITIVE_INFINITY;
424
+ let maxA = Number.NEGATIVE_INFINITY;
425
+ let minP = Number.POSITIVE_INFINITY;
426
+ let maxP = Number.NEGATIVE_INFINITY;
427
+ let minV = Number.POSITIVE_INFINITY;
428
+ let maxV = Number.NEGATIVE_INFINITY;
429
+ let any = false;
430
+ for (let g = 0; g < geoms.size(); g++) {
431
+ const pg = geoms.get(g);
432
+ const m = pg.flatTransformation;
433
+ const geo = ifcApi.GetGeometry(modelID, pg.geometryExpressID);
434
+ try {
435
+ const verts = ifcApi.GetVertexArray(geo.GetVertexData(), geo.GetVertexDataSize());
436
+ // 6 floats per vertex: local position xyz + normal xyz.
437
+ for (let v = 0; v + 2 < verts.length; v += 6) {
438
+ const x = verts[v];
439
+ const y = verts[v + 1];
440
+ const z = verts[v + 2];
441
+ // flatTransformation is column-major 4x4 → world position.
442
+ const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
443
+ const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
444
+ const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
445
+ const a = wx * axisX + wy * axisY;
446
+ const p = wx * perpX + wy * perpY;
447
+ if (a < minA)
448
+ minA = a;
449
+ if (a > maxA)
450
+ maxA = a;
451
+ if (p < minP)
452
+ minP = p;
453
+ if (p > maxP)
454
+ maxP = p;
455
+ if (wz < minV)
456
+ minV = wz;
457
+ if (wz > maxV)
458
+ maxV = wz;
459
+ any = true;
460
+ }
461
+ }
462
+ finally {
463
+ ;
464
+ geo.delete?.();
465
+ }
466
+ }
467
+ if (!any)
468
+ return null;
469
+ return { along: maxA - minA, across: maxP - minP, vertical: maxV - minV };
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ finally {
475
+ mesh.delete?.();
476
+ }
477
+ }
478
+ // Resolve wall height + thickness from the wall-frame extents. The along
479
+ // extent must match the wall's already-known length (within tolerance)
480
+ // for the measurement to be trusted — that gate confirms both the unit
481
+ // scale (tried raw and unit-scaled) and frame alignment. Height is the
482
+ // vertical extent (rotation-invariant); thickness is the perpendicular
483
+ // extent. Returns null (→ caller uses defaults) when the gate fails or
484
+ // the dims are implausible.
485
+ function wallHeightThicknessFromExtents(extents, knownLengthMeters, unitFactor) {
486
+ if (knownLengthMeters <= 1e-6)
487
+ return null;
488
+ const tol = Math.max(0.3, 0.15 * knownLengthMeters);
489
+ for (const scale of [unitFactor, 1]) {
490
+ const along = extents.along * scale;
491
+ if (Math.abs(along - knownLengthMeters) > tol)
492
+ continue;
493
+ const height = extents.vertical * scale;
494
+ const thickness = extents.across * scale;
495
+ if (height >= 0.2 && height <= 20 && thickness >= 0.02 && thickness <= 2) {
496
+ return { height, thickness };
497
+ }
498
+ }
499
+ return null;
500
+ }
501
+ function getExtrusionPosition(ifcApi, modelID, element) {
502
+ try {
503
+ if (!element.Representation?.value)
504
+ return null;
505
+ const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
506
+ for (const repRef of prodRep.Representations) {
507
+ const rep = ifcApi.GetLine(modelID, repRef.value);
508
+ if (rep.RepresentationIdentifier?.value !== 'Body')
509
+ continue;
510
+ for (const itemRef of rep.Items) {
511
+ const item = ifcApi.GetLine(modelID, itemRef.value);
512
+ const pos = findExtrusionPosition(ifcApi, modelID, item);
513
+ if (pos)
514
+ return pos;
515
+ if (item.MappingSource?.value) {
516
+ const src = ifcApi.GetLine(modelID, item.MappingSource.value);
517
+ if (src.MappedRepresentation?.value) {
518
+ const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value);
519
+ if (mapped.Items) {
520
+ for (const mItemRef of mapped.Items) {
521
+ const mItem = ifcApi.GetLine(modelID, mItemRef.value);
522
+ const mPos = findExtrusionPosition(ifcApi, modelID, mItem);
523
+ if (mPos)
524
+ return mPos;
525
+ }
526
+ }
527
+ }
528
+ }
529
+ }
530
+ }
531
+ }
532
+ catch {
533
+ // fall through
534
+ }
535
+ return null;
536
+ }
537
+ export const VARIANT_PRESETS = {
538
+ A: {
539
+ swapYZ: true,
540
+ extrusionDepthIsHeight: true,
541
+ swapProfileDimensions: false,
542
+ label: 'Default (Y-up, depth=height)',
543
+ },
544
+ B: {
545
+ swapYZ: false,
546
+ extrusionDepthIsHeight: true,
547
+ swapProfileDimensions: false,
548
+ label: 'Z-Up (no axis swap)',
549
+ },
550
+ };
551
+ export async function convertIfcToPascal(ifcData, onProgress, options) {
552
+ const opts = {
553
+ swapYZ: options?.swapYZ ?? true,
554
+ extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
555
+ swapProfileDimensions: options?.swapProfileDimensions ?? false,
556
+ };
557
+ const progress = (msg, pct) => {
558
+ console.log(`[IFC→Pascal] ${msg} (${pct}%)`);
559
+ onProgress?.(msg, pct);
560
+ };
561
+ progress('Initializing IFC parser...', 0);
562
+ const ifcApi = new WebIFC.IfcAPI();
563
+ ifcApi.SetWasmPath('/', true);
564
+ await ifcApi.Init();
565
+ progress('Opening IFC model...', 10);
566
+ const modelID = ifcApi.OpenModel(ifcData);
567
+ console.log(`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`);
568
+ const nodes = {};
569
+ const rootNodeIds = [];
570
+ // Maps to track relationships
571
+ const parentMap = new Map();
572
+ const childrenMap = new Map();
573
+ const expressIdToNodeId = new Map();
574
+ progress('Analyzing spatial relationships...', 20);
575
+ // Detect length unit → meters conversion factor
576
+ const unitFactor = getLengthUnitFactor(ifcApi, modelID);
577
+ // Compute scene origin offset to center georeferenced models near (0,0,0)
578
+ let originOffset = [0, 0, 0];
579
+ try {
580
+ const siteIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
581
+ const anchorId = siteIds.size() > 0 ? siteIds.get(0) : null;
582
+ if (anchorId) {
583
+ const anchor = ifcApi.GetLine(modelID, anchorId);
584
+ if (anchor.ObjectPlacement?.value) {
585
+ const mat = resolveWorldTransform(ifcApi, modelID, anchor.ObjectPlacement.value);
586
+ originOffset = transformPoint3(mat, [0, 0, 0]);
587
+ }
588
+ }
589
+ }
590
+ catch {
591
+ /* keep zero offset */
592
+ }
593
+ function worldToScene(worldPt) {
594
+ return [
595
+ (worldPt[0] - originOffset[0]) * unitFactor,
596
+ (worldPt[1] - originOffset[1]) * unitFactor,
597
+ (worldPt[2] - originOffset[2]) * unitFactor,
598
+ ];
599
+ }
600
+ // Collect storey expressIDs for level mapping
601
+ const storeyExpressIds = new Set();
602
+ const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
603
+ for (let i = 0; i < storeyIds.size(); i++) {
604
+ storeyExpressIds.add(storeyIds.get(i));
605
+ }
606
+ // First pass: collect spatial hierarchy relationships
607
+ const relAggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES);
608
+ for (let i = 0; i < relAggregates.size(); i++) {
609
+ const relID = relAggregates.get(i);
610
+ const rel = ifcApi.GetLine(modelID, relID);
611
+ if (rel.RelatingObject && rel.RelatedObjects) {
612
+ const parentExpressID = rel.RelatingObject.value;
613
+ const children = rel.RelatedObjects.map((obj) => obj.value);
614
+ children.forEach((childID) => {
615
+ parentMap.set(childID, parentExpressID);
616
+ });
617
+ if (!childrenMap.has(parentExpressID)) {
618
+ childrenMap.set(parentExpressID, []);
619
+ }
620
+ childrenMap.get(parentExpressID)?.push(...children);
621
+ }
622
+ }
623
+ // Second pass: collect spatial containment
624
+ const relContained = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE);
625
+ for (let i = 0; i < relContained.size(); i++) {
626
+ const relID = relContained.get(i);
627
+ const rel = ifcApi.GetLine(modelID, relID);
628
+ if (rel.RelatingStructure && rel.RelatedElements) {
629
+ const parentExpressID = rel.RelatingStructure.value;
630
+ const children = rel.RelatedElements.map((obj) => obj.value);
631
+ children.forEach((childID) => {
632
+ parentMap.set(childID, parentExpressID);
633
+ });
634
+ if (!childrenMap.has(parentExpressID)) {
635
+ childrenMap.set(parentExpressID, []);
636
+ }
637
+ childrenMap.get(parentExpressID)?.push(...children);
638
+ }
639
+ }
640
+ // Resolve containing storey for an element by walking the parent chain
641
+ function findStoreyForElement(expressId) {
642
+ let current = expressId;
643
+ for (let guard = 0; guard < 20 && current != null; guard++) {
644
+ if (storeyExpressIds.has(current))
645
+ return current;
646
+ current = parentMap.get(current);
647
+ }
648
+ return null;
649
+ }
650
+ progress('Processing sites...', 30);
651
+ // Process sites
652
+ const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
653
+ console.log(`[IFC→Pascal] Found ${sites.size()} sites`);
654
+ for (let i = 0; i < sites.size(); i++) {
655
+ const siteExpressID = sites.get(i);
656
+ const site = ifcApi.GetLine(modelID, siteExpressID);
657
+ const nodeId = generateId('site');
658
+ expressIdToNodeId.set(siteExpressID, nodeId);
659
+ rootNodeIds.push(nodeId);
660
+ const siteNode = tryParse(SiteNode, 'site', {
661
+ object: 'node',
662
+ id: nodeId,
663
+ type: 'site',
664
+ name: site.Name?.value || site.LongName?.value || 'Site',
665
+ parentId: null,
666
+ visible: true,
667
+ polygon: {
668
+ // Pascal SiteNode requires a property-line polygon. The
669
+ // converter doesn't read IFC site geometry yet, so seed the
670
+ // editor's default 30x30 square here.
671
+ // TODO(ifc-fix): derive from IfcSite.SiteAddress or building footprints.
672
+ type: 'polygon',
673
+ points: [
674
+ [-15, -15],
675
+ [15, -15],
676
+ [15, 15],
677
+ [-15, 15],
678
+ ],
679
+ },
680
+ children: [],
681
+ metadata: buildMetadata({
682
+ ifcType: 'IFCSITE',
683
+ expressID: siteExpressID,
684
+ globalId: site.GlobalId?.value,
685
+ }),
686
+ });
687
+ nodes[nodeId] = siteNode;
688
+ }
689
+ progress('Processing buildings...', 40);
690
+ // Process buildings
691
+ const buildings = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDING);
692
+ console.log(`[IFC→Pascal] Found ${buildings.size()} buildings`);
693
+ for (let i = 0; i < buildings.size(); i++) {
694
+ const buildingExpressID = buildings.get(i);
695
+ const building = ifcApi.GetLine(modelID, buildingExpressID);
696
+ const nodeId = generateId('building');
697
+ expressIdToNodeId.set(buildingExpressID, nodeId);
698
+ const parentExpressID = parentMap.get(buildingExpressID);
699
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
700
+ const buildingNode = tryParse(BuildingNode, 'building', {
701
+ object: 'node',
702
+ id: nodeId,
703
+ type: 'building',
704
+ name: building.Name?.value || building.LongName?.value || 'Building',
705
+ parentId: parentNodeId || null,
706
+ visible: true,
707
+ position: [0, 0, 0],
708
+ rotation: [0, 0, 0],
709
+ children: [],
710
+ metadata: buildMetadata({
711
+ ifcType: 'IFCBUILDING',
712
+ expressID: buildingExpressID,
713
+ globalId: building.GlobalId?.value,
714
+ }),
715
+ });
716
+ nodes[nodeId] = buildingNode;
717
+ if (parentNodeId && nodes[parentNodeId]) {
718
+ ;
719
+ nodes[parentNodeId].children?.push(nodeId);
720
+ }
721
+ }
722
+ progress('Processing levels...', 50);
723
+ // Process building storeys (levels)
724
+ const storeys = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
725
+ console.log(`[IFC→Pascal] Found ${storeys.size()} levels`);
726
+ for (let i = 0; i < storeys.size(); i++) {
727
+ const storeyExpressID = storeys.get(i);
728
+ const storey = ifcApi.GetLine(modelID, storeyExpressID);
729
+ const nodeId = generateId('level');
730
+ expressIdToNodeId.set(storeyExpressID, nodeId);
731
+ const parentExpressID = parentMap.get(storeyExpressID);
732
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
733
+ // Resolve storey elevation from placement chain
734
+ let elevation = storey.Elevation?.value ?? 0;
735
+ if (storey.ObjectPlacement?.value) {
736
+ try {
737
+ const worldMat = resolveWorldTransform(ifcApi, modelID, storey.ObjectPlacement.value);
738
+ const worldOrigin = transformPoint3(worldMat, [0, 0, 0]);
739
+ elevation = worldToScene(worldOrigin)[2];
740
+ }
741
+ catch {
742
+ elevation = (storey.Elevation?.value ?? 0) * unitFactor;
743
+ }
744
+ }
745
+ else {
746
+ elevation *= unitFactor;
747
+ }
748
+ const levelNode = tryParse(LevelNode, 'level', {
749
+ object: 'node',
750
+ id: nodeId,
751
+ type: 'level',
752
+ name: storey.Name?.value || storey.LongName?.value || `Level ${i}`,
753
+ level: i,
754
+ parentId: parentNodeId || null,
755
+ visible: true,
756
+ children: [],
757
+ metadata: buildMetadata({
758
+ ifcType: 'IFCBUILDINGSTOREY',
759
+ expressID: storeyExpressID,
760
+ globalId: storey.GlobalId?.value,
761
+ elevation,
762
+ }),
763
+ });
764
+ nodes[nodeId] = levelNode;
765
+ if (parentNodeId && nodes[parentNodeId]) {
766
+ ;
767
+ nodes[parentNodeId].children?.push(nodeId);
768
+ }
769
+ }
770
+ progress('Processing walls...', 60);
771
+ // Build void/fill relationship maps for doors and windows
772
+ // IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement)
773
+ const wallToOpenings = new Map();
774
+ const relVoids = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELVOIDSELEMENT);
775
+ for (let i = 0; i < relVoids.size(); i++) {
776
+ const rel = ifcApi.GetLine(modelID, relVoids.get(i));
777
+ const wallId = rel.RelatingBuildingElement?.value;
778
+ const openingId = rel.RelatedOpeningElement?.value;
779
+ if (wallId && openingId) {
780
+ if (!wallToOpenings.has(wallId))
781
+ wallToOpenings.set(wallId, []);
782
+ wallToOpenings.get(wallId).push(openingId);
783
+ }
784
+ }
785
+ // IFCRELFILLSELEMENT: opening (RelatingOpeningElement) → door/window (RelatedBuildingElement)
786
+ const openingToFill = new Map();
787
+ const relFills = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELFILLSELEMENT);
788
+ for (let i = 0; i < relFills.size(); i++) {
789
+ const rel = ifcApi.GetLine(modelID, relFills.get(i));
790
+ const openingId = rel.RelatingOpeningElement?.value;
791
+ const fillId = rel.RelatedBuildingElement?.value;
792
+ if (openingId && fillId) {
793
+ openingToFill.set(openingId, fillId);
794
+ }
795
+ }
796
+ // Collect all door and window expressIDs for type checking
797
+ const doorExpressIds = new Set();
798
+ const windowExpressIds = new Set();
799
+ for (const doorType of [WebIFC.IFCDOOR, WebIFC.IFCDOORSTANDARDCASE]) {
800
+ const ids = ifcApi.GetLineIDsWithType(modelID, doorType);
801
+ for (let i = 0; i < ids.size(); i++)
802
+ doorExpressIds.add(ids.get(i));
803
+ }
804
+ for (const winType of [WebIFC.IFCWINDOW, WebIFC.IFCWINDOWSTANDARDCASE]) {
805
+ const ids = ifcApi.GetLineIDsWithType(modelID, winType);
806
+ for (let i = 0; i < ids.size(); i++)
807
+ windowExpressIds.add(ids.get(i));
808
+ }
809
+ // Process walls (both IFCWALL and IFCWALLSTANDARDCASE)
810
+ const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE];
811
+ for (const wallType of wallTypes) {
812
+ const walls = ifcApi.GetLineIDsWithType(modelID, wallType);
813
+ for (let i = 0; i < walls.size(); i++) {
814
+ const wallExpressID = walls.get(i);
815
+ if (expressIdToNodeId.has(wallExpressID))
816
+ continue;
817
+ const wall = ifcApi.GetLine(modelID, wallExpressID);
818
+ const nodeId = generateId('wall');
819
+ expressIdToNodeId.set(wallExpressID, nodeId);
820
+ const parentExpressID = parentMap.get(wallExpressID);
821
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
822
+ let start = [0, 0];
823
+ let end = null;
824
+ let thickness;
825
+ let height;
826
+ try {
827
+ // Resolve world placement
828
+ const worldMat = wall.ObjectPlacement?.value
829
+ ? resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
830
+ : identity();
831
+ // Try to get axis polyline (local 2D line along wall)
832
+ const axisPts = getAxisPolyline(ifcApi, modelID, wall);
833
+ if (axisPts && axisPts.length >= 2) {
834
+ const s0 = worldToScene(transformPoint3(worldMat, axisPts[0]));
835
+ const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1]));
836
+ start = [s0[0], s0[1]];
837
+ end = [s1[0], s1[1]];
838
+ }
839
+ else {
840
+ // Fallback: use placement origin + body XDim for length
841
+ const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
842
+ start = [s[0], s[1]];
843
+ // Will try to get length from body below
844
+ }
845
+ // Get body extrusion data for thickness/height
846
+ const body = getBodyExtrusionData(ifcApi, modelID, wall);
847
+ if (body.depth) {
848
+ if (opts.extrusionDepthIsHeight) {
849
+ height = body.depth * unitFactor;
850
+ }
851
+ else {
852
+ thickness = body.depth * unitFactor;
853
+ }
854
+ }
855
+ const dimForThickness = opts.swapProfileDimensions ? body.xDim : body.yDim;
856
+ if (dimForThickness) {
857
+ thickness = dimForThickness * unitFactor;
858
+ }
859
+ else if (body.profilePoints && body.profilePoints.length >= 3) {
860
+ const ys = body.profilePoints.map((p) => p[1]);
861
+ thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor;
862
+ }
863
+ // If no axis polyline, derive wall length from profile or XDim
864
+ if (!axisPts) {
865
+ let wallLength = body.xDim;
866
+ if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
867
+ const xs = body.profilePoints.map((p) => p[0]);
868
+ wallLength = Math.max(...xs) - Math.min(...xs);
869
+ }
870
+ if (wallLength) {
871
+ const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]));
872
+ end = [se[0], se[1]];
873
+ }
874
+ }
875
+ }
876
+ catch {
877
+ // keep defaults
878
+ }
879
+ // Skip walls where we couldn't determine geometry
880
+ if (!end)
881
+ continue;
882
+ // Plain IFCWALL frequently carries Brep / mapped geometry rather
883
+ // than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
884
+ // read its height/thickness (only IFCWALLSTANDARDCASE reliably
885
+ // works). First try to recover real dims from the element's mesh
886
+ // bounding box; only then fall back to the editor's wall defaults
887
+ // so the wall renders at a sensible size instead of collapsing to
888
+ // a zero-height sliver (which also breaks the door/window CSG
889
+ // cutouts punched into it).
890
+ if (height === undefined || thickness === undefined) {
891
+ const wallLenM = Math.hypot(end[0] - start[0], end[1] - start[1]);
892
+ if (wallLenM > 1e-6) {
893
+ // Project the wall mesh onto its own axis so length, thickness
894
+ // and height are read in the wall frame (rotation-invariant) —
895
+ // a world-space AABB conflates length and thickness on rotated
896
+ // walls. Axis is the normalised start→end direction in the
897
+ // IFC ground plane, which is also the mapping used for the
898
+ // wall's start/end above.
899
+ const axisX = (end[0] - start[0]) / wallLenM;
900
+ const axisY = (end[1] - start[1]) / wallLenM;
901
+ const extents = measureWallLocalExtents(ifcApi, modelID, wallExpressID, axisX, axisY);
902
+ const geom = extents
903
+ ? wallHeightThicknessFromExtents(extents, wallLenM, unitFactor)
904
+ : null;
905
+ if (geom) {
906
+ if (height === undefined)
907
+ height = geom.height;
908
+ if (thickness === undefined)
909
+ thickness = geom.thickness;
910
+ }
911
+ }
912
+ }
913
+ if (height === undefined)
914
+ height = DEFAULT_WALL_HEIGHT;
915
+ if (thickness === undefined)
916
+ thickness = DEFAULT_WALL_THICKNESS;
917
+ const wallNode = tryParse(WallNode, 'wall', {
918
+ object: 'node',
919
+ id: nodeId,
920
+ type: 'wall',
921
+ name: wall.Name?.value || `Wall ${i + 1}`,
922
+ parentId: parentNodeId || null,
923
+ visible: true,
924
+ start,
925
+ end,
926
+ thickness,
927
+ height,
928
+ frontSide: 'unknown',
929
+ backSide: 'unknown',
930
+ children: [],
931
+ metadata: buildMetadata({
932
+ ifcType: wallType === WebIFC.IFCWALL ? 'IFCWALL' : 'IFCWALLSTANDARDCASE',
933
+ expressID: wallExpressID,
934
+ globalId: wall.GlobalId?.value,
935
+ }),
936
+ });
937
+ nodes[nodeId] = wallNode;
938
+ if (parentNodeId && nodes[parentNodeId]) {
939
+ ;
940
+ nodes[parentNodeId].children?.push(nodeId);
941
+ }
942
+ }
943
+ }
944
+ // Process doors and windows via void/fill relationships
945
+ for (const [wallExpressID, openingIds] of wallToOpenings) {
946
+ const wallNodeId = expressIdToNodeId.get(wallExpressID);
947
+ if (!wallNodeId)
948
+ continue;
949
+ const wallNode = nodes[wallNodeId];
950
+ if (!wallNode || wallNode.type !== 'wall')
951
+ continue;
952
+ const wallDx = wallNode.end[0] - wallNode.start[0];
953
+ const wallDy = wallNode.end[1] - wallNode.start[1];
954
+ const wallLength = Math.sqrt(wallDx * wallDx + wallDy * wallDy);
955
+ // Wall world matrix for projecting opening positions
956
+ let wallWorldMat = null;
957
+ try {
958
+ const wall = ifcApi.GetLine(modelID, wallExpressID);
959
+ if (wall.ObjectPlacement?.value) {
960
+ wallWorldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value);
961
+ }
962
+ }
963
+ catch {
964
+ /* ignore */
965
+ }
966
+ for (const openingId of openingIds) {
967
+ const fillId = openingToFill.get(openingId);
968
+ if (!fillId)
969
+ continue;
970
+ const isDoor = doorExpressIds.has(fillId);
971
+ const isWindow = windowExpressIds.has(fillId);
972
+ if (!isDoor && !isWindow)
973
+ continue;
974
+ try {
975
+ const element = ifcApi.GetLine(modelID, fillId);
976
+ // Get dimensions from OverallWidth/OverallHeight
977
+ let width;
978
+ let height;
979
+ if (element.OverallWidth?.value)
980
+ width = element.OverallWidth.value * unitFactor;
981
+ if (element.OverallHeight?.value)
982
+ height = element.OverallHeight.value * unitFactor;
983
+ // Compute position along wall from opening placement
984
+ let position;
985
+ try {
986
+ const opening = ifcApi.GetLine(modelID, openingId);
987
+ if (opening.ObjectPlacement?.value) {
988
+ const openingWorldMat = resolveWorldTransform(ifcApi, modelID, opening.ObjectPlacement.value);
989
+ const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]));
990
+ const ox = openingScene[0] - wallNode.start[0];
991
+ const oy = openingScene[1] - wallNode.start[1];
992
+ if (wallLength > 1e-6) {
993
+ const dot = (ox * wallDx + oy * wallDy) / wallLength;
994
+ // Clamp so the opening's [pos - w/2, pos + w/2] footprint
995
+ // stays inside the wall — an overflowing CSG cutout breaks
996
+ // the wall mesh.
997
+ const half = (width ?? 0) / 2;
998
+ const lo = Math.min(half, wallLength / 2);
999
+ const hi = Math.max(wallLength - half, wallLength / 2);
1000
+ position = Math.max(lo, Math.min(hi, dot));
1001
+ }
1002
+ }
1003
+ }
1004
+ catch {
1005
+ /* ignore */
1006
+ }
1007
+ // Get sill height for windows from opening placement Z relative to wall
1008
+ let sillHeight;
1009
+ if (isWindow) {
1010
+ try {
1011
+ const opening = ifcApi.GetLine(modelID, openingId);
1012
+ if (opening.ObjectPlacement?.value) {
1013
+ const openingWorldMat = resolveWorldTransform(ifcApi, modelID, opening.ObjectPlacement.value);
1014
+ const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]));
1015
+ if (wallWorldMat) {
1016
+ const wallScene = worldToScene(transformPoint3(wallWorldMat, [0, 0, 0]));
1017
+ sillHeight = openingScene[2] - wallScene[2];
1018
+ }
1019
+ else {
1020
+ sillHeight = openingScene[2];
1021
+ }
1022
+ if (sillHeight !== undefined && sillHeight < 0.01)
1023
+ sillHeight = undefined;
1024
+ }
1025
+ }
1026
+ catch {
1027
+ /* ignore */
1028
+ }
1029
+ }
1030
+ if (isDoor) {
1031
+ const nodeId = generateId('door');
1032
+ expressIdToNodeId.set(fillId, nodeId);
1033
+ // Vertical centering is now handled: door center Y = height/2 so the
1034
+ // opening sits at the correct position. Remaining caveat: door bottom
1035
+ // is assumed at floor y=0 (i.e. the door starts at the wall's base).
1036
+ // Defaults match @pascal-app/core door schema fallbacks.
1037
+ const doorPosition = [position ?? 0, (height ?? 2.1) / 2, 0];
1038
+ const doorNode = tryParse(DoorNode, 'door', {
1039
+ object: 'node',
1040
+ id: nodeId,
1041
+ type: 'door',
1042
+ name: element.Name?.value || `Door`,
1043
+ parentId: wallNodeId,
1044
+ visible: true,
1045
+ width: width ?? 0.9,
1046
+ height: height ?? 2.1,
1047
+ position: doorPosition,
1048
+ metadata: buildMetadata({
1049
+ ifcType: 'IFCDOOR',
1050
+ expressID: fillId,
1051
+ globalId: element.GlobalId?.value,
1052
+ hostWallExpressID: wallExpressID,
1053
+ }),
1054
+ });
1055
+ nodes[nodeId] = doorNode;
1056
+ wallNode.children.push(nodeId);
1057
+ }
1058
+ else {
1059
+ const nodeId = generateId('window');
1060
+ expressIdToNodeId.set(fillId, nodeId);
1061
+ // TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
1062
+ // sillHeight stays read-only metadata until we resolve the window
1063
+ // schema (Pascal's WindowNode doesn't have sillHeight today —
1064
+ // moved to metadata for now so we don't lose the value).
1065
+ const windowPosition = [
1066
+ position ?? 0,
1067
+ (sillHeight ?? 0) + (height ?? 1.2) / 2,
1068
+ 0,
1069
+ ];
1070
+ const windowNode = tryParse(WindowNode, 'window', {
1071
+ object: 'node',
1072
+ id: nodeId,
1073
+ type: 'window',
1074
+ name: element.Name?.value || `Window`,
1075
+ parentId: wallNodeId,
1076
+ visible: true,
1077
+ width: width ?? 1.0,
1078
+ height: height ?? 1.2,
1079
+ position: windowPosition,
1080
+ metadata: buildMetadata({
1081
+ ifcType: 'IFCWINDOW',
1082
+ expressID: fillId,
1083
+ globalId: element.GlobalId?.value,
1084
+ hostWallExpressID: wallExpressID,
1085
+ sillHeight,
1086
+ }),
1087
+ });
1088
+ nodes[nodeId] = windowNode;
1089
+ wallNode.children.push(nodeId);
1090
+ }
1091
+ }
1092
+ catch {
1093
+ // skip this opening
1094
+ }
1095
+ }
1096
+ }
1097
+ // Process any doors/windows NOT linked via the void/fill chain. Some
1098
+ // exporters (e.g. the Paris sample) carve openings with
1099
+ // IFCRELVOIDSELEMENT but omit IFCRELFILLSELEMENT, so the
1100
+ // door/window → wall link is only implicit in the element's world
1101
+ // placement. We recover it by projecting the element's world position
1102
+ // onto the nearest wall segment. Anything farther than
1103
+ // HOST_WALL_MAX_DIST from every wall stays parented to its spatial
1104
+ // container at the origin — we have no basis to place it on a wall.
1105
+ const HOST_WALL_MAX_DIST = 1.0; // metres
1106
+ const wallInfos = [];
1107
+ for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
1108
+ const node = nodes[wallNodeId];
1109
+ if (!node || node.type !== 'wall')
1110
+ continue;
1111
+ const w = node;
1112
+ const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1]);
1113
+ if (length < 1e-6)
1114
+ continue;
1115
+ let baseY = 0;
1116
+ try {
1117
+ const wall = ifcApi.GetLine(modelID, wallExpressId);
1118
+ if (wall.ObjectPlacement?.value) {
1119
+ const m = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value);
1120
+ baseY = worldToScene(transformPoint3(m, [0, 0, 0]))[2];
1121
+ }
1122
+ }
1123
+ catch {
1124
+ /* keep baseY = 0 */
1125
+ }
1126
+ wallInfos.push({ nodeId: wallNodeId, start: w.start, end: w.end, length, baseY });
1127
+ }
1128
+ // Pick the host wall for an opening at ground-plane point [x, y] with
1129
+ // the given width. Among walls within HOST_WALL_MAX_DIST
1130
+ // perpendicular, prefer one long enough to contain the opening — the
1131
+ // castle's IFCWALL decomposition leaves tiny stub walls near corners,
1132
+ // and a plain nearest-by-distance match would snap (say) a 0.7m door
1133
+ // onto a 0.4m stub, so its CSG cutout overflows and breaks the wall.
1134
+ // Returns the along-wall position already clamped so the opening
1135
+ // footprint stays inside the wall.
1136
+ const findHostWall = (x, y, width) => {
1137
+ let best = null;
1138
+ for (const info of wallInfos) {
1139
+ const dx = info.end[0] - info.start[0];
1140
+ const dy = info.end[1] - info.start[1];
1141
+ const lenSq = dx * dx + dy * dy;
1142
+ if (lenSq < 1e-9)
1143
+ continue;
1144
+ const t = Math.max(0, Math.min(1, ((x - info.start[0]) * dx + (y - info.start[1]) * dy) / lenSq));
1145
+ const footX = info.start[0] + t * dx;
1146
+ const footY = info.start[1] + t * dy;
1147
+ const dist = Math.hypot(x - footX, y - footY);
1148
+ if (dist > HOST_WALL_MAX_DIST)
1149
+ continue;
1150
+ // Keep the [along - width/2, along + width/2] span inside the wall.
1151
+ const half = width / 2;
1152
+ const lo = Math.min(half, info.length / 2);
1153
+ const hi = Math.max(info.length - half, info.length / 2);
1154
+ const along = Math.max(lo, Math.min(hi, t * info.length));
1155
+ const fits = info.length + 1e-6 >= width;
1156
+ const cand = { info, along, dist, fits };
1157
+ if (!best) {
1158
+ best = cand;
1159
+ }
1160
+ else if (cand.fits !== best.fits) {
1161
+ if (cand.fits)
1162
+ best = cand;
1163
+ }
1164
+ else if (cand.dist < best.dist) {
1165
+ best = cand;
1166
+ }
1167
+ }
1168
+ return best;
1169
+ };
1170
+ for (const fillId of [...doorExpressIds, ...windowExpressIds]) {
1171
+ if (expressIdToNodeId.has(fillId))
1172
+ continue;
1173
+ try {
1174
+ const element = ifcApi.GetLine(modelID, fillId);
1175
+ const isDoor = doorExpressIds.has(fillId);
1176
+ let width;
1177
+ let height;
1178
+ if (element.OverallWidth?.value)
1179
+ width = element.OverallWidth.value * unitFactor;
1180
+ if (element.OverallHeight?.value)
1181
+ height = element.OverallHeight.value * unitFactor;
1182
+ // Element world placement → scene point. Ground plane is [x, y]
1183
+ // (IFC X/Y, matching wall start/end); vertical is component [2].
1184
+ let scene = null;
1185
+ try {
1186
+ if (element.ObjectPlacement?.value) {
1187
+ const m = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value);
1188
+ scene = worldToScene(transformPoint3(m, [0, 0, 0]));
1189
+ }
1190
+ }
1191
+ catch {
1192
+ /* no placement */
1193
+ }
1194
+ const effWidth = width ?? (isDoor ? 0.9 : 1.0);
1195
+ const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null;
1196
+ // When hosted, parent to (and live inside) the wall — same as the
1197
+ // void/fill path. Otherwise fall back to the spatial container.
1198
+ const containerExpressID = parentMap.get(fillId);
1199
+ const containerNodeId = containerExpressID
1200
+ ? (expressIdToNodeId.get(containerExpressID) ?? null)
1201
+ : null;
1202
+ const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId;
1203
+ if (isDoor) {
1204
+ const h = height ?? 2.1;
1205
+ const nodeId = generateId('door');
1206
+ expressIdToNodeId.set(fillId, nodeId);
1207
+ const doorNode = tryParse(DoorNode, 'door', {
1208
+ object: 'node',
1209
+ id: nodeId,
1210
+ type: 'door',
1211
+ name: element.Name?.value || `Door`,
1212
+ parentId: parentNodeId,
1213
+ visible: true,
1214
+ width: width ?? 0.9,
1215
+ height: h,
1216
+ // Placed by nearest-wall projection; [0,0,0] only when no wall
1217
+ // is within range (then it sits on its spatial container).
1218
+ position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
1219
+ ...(hosted ? { wallId: hosted.info.nodeId } : {}),
1220
+ metadata: buildMetadata({
1221
+ ifcType: 'IFCDOOR',
1222
+ expressID: fillId,
1223
+ globalId: element.GlobalId?.value,
1224
+ }),
1225
+ });
1226
+ nodes[nodeId] = doorNode;
1227
+ if (parentNodeId && nodes[parentNodeId]) {
1228
+ ;
1229
+ nodes[parentNodeId].children?.push(nodeId);
1230
+ }
1231
+ }
1232
+ else {
1233
+ const h = height ?? 1.2;
1234
+ const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
1235
+ const nodeId = generateId('window');
1236
+ expressIdToNodeId.set(fillId, nodeId);
1237
+ const windowNode = tryParse(WindowNode, 'window', {
1238
+ object: 'node',
1239
+ id: nodeId,
1240
+ type: 'window',
1241
+ name: element.Name?.value || `Window`,
1242
+ parentId: parentNodeId,
1243
+ visible: true,
1244
+ width: width ?? 1.0,
1245
+ height: h,
1246
+ position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
1247
+ ...(hosted ? { wallId: hosted.info.nodeId } : {}),
1248
+ metadata: buildMetadata({
1249
+ ifcType: 'IFCWINDOW',
1250
+ expressID: fillId,
1251
+ globalId: element.GlobalId?.value,
1252
+ ...(hosted ? { sillHeight: sill } : {}),
1253
+ }),
1254
+ });
1255
+ nodes[nodeId] = windowNode;
1256
+ if (parentNodeId && nodes[parentNodeId]) {
1257
+ ;
1258
+ nodes[parentNodeId].children?.push(nodeId);
1259
+ }
1260
+ }
1261
+ }
1262
+ catch {
1263
+ // skip
1264
+ }
1265
+ }
1266
+ // Process slabs
1267
+ const slabs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSLAB);
1268
+ console.log(`[IFC→Pascal] Found ${slabs.size()} slabs`);
1269
+ for (let i = 0; i < slabs.size(); i++) {
1270
+ const slabExpressID = slabs.get(i);
1271
+ const slab = ifcApi.GetLine(modelID, slabExpressID);
1272
+ const nodeId = generateId('slab');
1273
+ expressIdToNodeId.set(slabExpressID, nodeId);
1274
+ const parentExpressID = parentMap.get(slabExpressID);
1275
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1276
+ let polygon = null;
1277
+ let elevation = 0;
1278
+ let thickness;
1279
+ try {
1280
+ // Resolve world placement for the slab element
1281
+ const worldMat = slab.ObjectPlacement?.value
1282
+ ? resolveWorldTransform(ifcApi, modelID, slab.ObjectPlacement.value)
1283
+ : identity();
1284
+ // Get elevation from placement Z
1285
+ const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1286
+ elevation = s[2];
1287
+ // Get body extrusion data
1288
+ const body = getBodyExtrusionData(ifcApi, modelID, slab);
1289
+ // Extrusion depth is slab thickness
1290
+ if (body.depth) {
1291
+ thickness = body.depth * unitFactor;
1292
+ }
1293
+ // Extrusion Position provides an additional local offset for the profile
1294
+ const extrusionMat = getExtrusionPosition(ifcApi, modelID, slab);
1295
+ if (body.profilePoints && body.profilePoints.length >= 3) {
1296
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1297
+ polygon = body.profilePoints.map((pt) => {
1298
+ const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]));
1299
+ return [sc[0], sc[1]];
1300
+ });
1301
+ const first = polygon[0];
1302
+ const last = polygon[polygon.length - 1];
1303
+ if (polygon.length > 3 &&
1304
+ Math.abs(first[0] - last[0]) < 1e-6 &&
1305
+ Math.abs(first[1] - last[1]) < 1e-6) {
1306
+ polygon.pop();
1307
+ }
1308
+ }
1309
+ else if (body.xDim && body.yDim) {
1310
+ const hw = body.xDim / 2;
1311
+ const hh = body.yDim / 2;
1312
+ const corners = [
1313
+ [-hw, -hh, 0],
1314
+ [hw, -hh, 0],
1315
+ [hw, hh, 0],
1316
+ [-hw, hh, 0],
1317
+ ];
1318
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1319
+ polygon = corners.map((c) => {
1320
+ const sc = worldToScene(transformPoint3(combinedMat, c));
1321
+ return [sc[0], sc[1]];
1322
+ });
1323
+ }
1324
+ }
1325
+ catch {
1326
+ // keep defaults
1327
+ }
1328
+ // Skip slabs where we couldn't extract a polygon
1329
+ if (!polygon || polygon.length < 3)
1330
+ continue;
1331
+ const slabNode = tryParse(SlabNode, 'slab', {
1332
+ object: 'node',
1333
+ id: nodeId,
1334
+ type: 'slab',
1335
+ name: slab.Name?.value || `Slab ${i + 1}`,
1336
+ parentId: parentNodeId || null,
1337
+ visible: true,
1338
+ polygon,
1339
+ holes: [],
1340
+ elevation,
1341
+ // TODO(ifc-fix): Pascal SlabNode has no `thickness` field — moved
1342
+ // to metadata so the IFC value isn't lost.
1343
+ metadata: buildMetadata({
1344
+ ifcType: 'IFCSLAB',
1345
+ expressID: slabExpressID,
1346
+ globalId: slab.GlobalId?.value,
1347
+ thickness,
1348
+ }),
1349
+ });
1350
+ nodes[nodeId] = slabNode;
1351
+ if (parentNodeId && nodes[parentNodeId]) {
1352
+ ;
1353
+ nodes[parentNodeId].children?.push(nodeId);
1354
+ }
1355
+ }
1356
+ // Process stairs
1357
+ const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR);
1358
+ for (let i = 0; i < stairs.size(); i++) {
1359
+ const stairExpressID = stairs.get(i);
1360
+ if (expressIdToNodeId.has(stairExpressID))
1361
+ continue;
1362
+ const stair = ifcApi.GetLine(modelID, stairExpressID);
1363
+ const nodeId = generateId('stair');
1364
+ expressIdToNodeId.set(stairExpressID, nodeId);
1365
+ const parentExpressID = parentMap.get(stairExpressID);
1366
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1367
+ let position = [0, 0, 0];
1368
+ let boundingBox;
1369
+ try {
1370
+ const worldMat = stair.ObjectPlacement?.value
1371
+ ? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
1372
+ : identity();
1373
+ const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1374
+ position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
1375
+ // Try stair's own body first
1376
+ const body = getBodyExtrusionData(ifcApi, modelID, stair);
1377
+ if (body.xDim && body.yDim && body.depth) {
1378
+ boundingBox = opts.swapYZ
1379
+ ? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
1380
+ : [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor];
1381
+ }
1382
+ // If no body, try to derive from stair flight children
1383
+ if (!boundingBox) {
1384
+ const stairChildren = childrenMap.get(stairExpressID) ?? [];
1385
+ for (const childId of stairChildren) {
1386
+ try {
1387
+ const child = ifcApi.GetLine(modelID, childId);
1388
+ // Check for NumberOfRisers / RiserHeight / TreadLength
1389
+ const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value;
1390
+ const riserHeight = child.RiserHeight?.value;
1391
+ const treadLength = child.TreadLength?.value;
1392
+ if (nRisers && riserHeight && treadLength) {
1393
+ const totalHeight = nRisers * riserHeight * unitFactor;
1394
+ const totalRun = (nRisers - 1) * treadLength * unitFactor;
1395
+ const width = 1.0; // Default stair width
1396
+ const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
1397
+ const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width;
1398
+ boundingBox = opts.swapYZ
1399
+ ? [totalRun || 1, totalHeight, stairWidth]
1400
+ : [totalRun || 1, stairWidth, totalHeight];
1401
+ break;
1402
+ }
1403
+ // Fallback: try flight body extrusion
1404
+ const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
1405
+ if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
1406
+ boundingBox = opts.swapYZ
1407
+ ? [
1408
+ flightBody.xDim * unitFactor,
1409
+ flightBody.depth * unitFactor,
1410
+ flightBody.yDim * unitFactor,
1411
+ ]
1412
+ : [
1413
+ flightBody.xDim * unitFactor,
1414
+ flightBody.yDim * unitFactor,
1415
+ flightBody.depth * unitFactor,
1416
+ ];
1417
+ break;
1418
+ }
1419
+ }
1420
+ catch {
1421
+ /* skip child */
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ catch {
1427
+ /* keep defaults */
1428
+ }
1429
+ const stairNode = tryParse(StairNode, 'stair', {
1430
+ object: 'node',
1431
+ id: nodeId,
1432
+ type: 'stair',
1433
+ name: stair.Name?.value || `Stair ${i + 1}`,
1434
+ parentId: parentNodeId || null,
1435
+ visible: true,
1436
+ position,
1437
+ children: [],
1438
+ // TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
1439
+ // risers). The converter only knows the bounding box right now;
1440
+ // keep it in metadata until we map IFC stairs onto the parametric
1441
+ // shape (or extend StairNode with a raw-geometry escape hatch).
1442
+ metadata: buildMetadata({
1443
+ ifcType: 'IFCSTAIR',
1444
+ expressID: stairExpressID,
1445
+ globalId: stair.GlobalId?.value,
1446
+ predefinedType: stair.PredefinedType?.value,
1447
+ boundingBox,
1448
+ }),
1449
+ });
1450
+ nodes[nodeId] = stairNode;
1451
+ if (parentNodeId && nodes[parentNodeId]) {
1452
+ ;
1453
+ nodes[parentNodeId].children?.push(nodeId);
1454
+ }
1455
+ }
1456
+ // Process roofs
1457
+ const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF);
1458
+ for (let i = 0; i < roofs.size(); i++) {
1459
+ const roofExpressID = roofs.get(i);
1460
+ if (expressIdToNodeId.has(roofExpressID))
1461
+ continue;
1462
+ const roof = ifcApi.GetLine(modelID, roofExpressID);
1463
+ const nodeId = generateId('roof');
1464
+ expressIdToNodeId.set(roofExpressID, nodeId);
1465
+ const parentExpressID = parentMap.get(roofExpressID);
1466
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1467
+ let polygon;
1468
+ let elevation;
1469
+ let height;
1470
+ try {
1471
+ const worldMat = roof.ObjectPlacement?.value
1472
+ ? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
1473
+ : identity();
1474
+ const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1475
+ elevation = s[2];
1476
+ const body = getBodyExtrusionData(ifcApi, modelID, roof);
1477
+ if (body.depth)
1478
+ height = body.depth * unitFactor;
1479
+ const extrusionMat = getExtrusionPosition(ifcApi, modelID, roof);
1480
+ if (body.profilePoints && body.profilePoints.length >= 3) {
1481
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1482
+ polygon = body.profilePoints.map((pt) => {
1483
+ const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]));
1484
+ return [sc[0], sc[1]];
1485
+ });
1486
+ const first = polygon[0];
1487
+ const last = polygon[polygon.length - 1];
1488
+ if (polygon.length > 3 &&
1489
+ Math.abs(first[0] - last[0]) < 1e-6 &&
1490
+ Math.abs(first[1] - last[1]) < 1e-6) {
1491
+ polygon.pop();
1492
+ }
1493
+ }
1494
+ else if (body.xDim && body.yDim) {
1495
+ const hw = body.xDim / 2;
1496
+ const hh = body.yDim / 2;
1497
+ const corners = [
1498
+ [-hw, -hh, 0],
1499
+ [hw, -hh, 0],
1500
+ [hw, hh, 0],
1501
+ [-hw, hh, 0],
1502
+ ];
1503
+ const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
1504
+ polygon = corners.map((c) => {
1505
+ const sc = worldToScene(transformPoint3(combinedMat, c));
1506
+ return [sc[0], sc[1]];
1507
+ });
1508
+ }
1509
+ }
1510
+ catch {
1511
+ /* keep defaults */
1512
+ }
1513
+ const roofNode = tryParse(RoofNode, 'roof', {
1514
+ object: 'node',
1515
+ id: nodeId,
1516
+ type: 'roof',
1517
+ name: roof.Name?.value || `Roof ${i + 1}`,
1518
+ parentId: parentNodeId || null,
1519
+ visible: true,
1520
+ elevation,
1521
+ // TODO(ifc-fix): Pascal RoofNode is composed of roof-segments. The
1522
+ // converter only has the flat polygon + height; pass them through
1523
+ // metadata until we map the IFC roof onto the segment-based shape.
1524
+ metadata: buildMetadata({
1525
+ ifcType: 'IFCROOF',
1526
+ expressID: roofExpressID,
1527
+ globalId: roof.GlobalId?.value,
1528
+ predefinedType: roof.PredefinedType?.value,
1529
+ polygon,
1530
+ height,
1531
+ }),
1532
+ });
1533
+ nodes[nodeId] = roofNode;
1534
+ if (parentNodeId && nodes[parentNodeId]) {
1535
+ ;
1536
+ nodes[parentNodeId].children?.push(nodeId);
1537
+ }
1538
+ }
1539
+ // Process columns
1540
+ const columnTypes = [WebIFC.IFCCOLUMN];
1541
+ try {
1542
+ columnTypes.push(WebIFC.IFCCOLUMNSTANDARDCASE);
1543
+ }
1544
+ catch {
1545
+ /* not in all versions */
1546
+ }
1547
+ for (const colType of columnTypes) {
1548
+ let cols;
1549
+ try {
1550
+ cols = ifcApi.GetLineIDsWithType(modelID, colType);
1551
+ }
1552
+ catch {
1553
+ continue;
1554
+ }
1555
+ for (let i = 0; i < cols.size(); i++) {
1556
+ const colExpressID = cols.get(i);
1557
+ if (expressIdToNodeId.has(colExpressID))
1558
+ continue;
1559
+ const col = ifcApi.GetLine(modelID, colExpressID);
1560
+ const nodeId = generateId('column');
1561
+ expressIdToNodeId.set(colExpressID, nodeId);
1562
+ const parentExpressID = parentMap.get(colExpressID);
1563
+ const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
1564
+ let position = [0, 0, 0];
1565
+ let width;
1566
+ let depth;
1567
+ let height;
1568
+ let profileShape = null;
1569
+ let profileRadius;
1570
+ try {
1571
+ const worldMat = col.ObjectPlacement?.value
1572
+ ? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
1573
+ : identity();
1574
+ const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
1575
+ position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
1576
+ const body = getBodyExtrusionData(ifcApi, modelID, col);
1577
+ if (body.depth)
1578
+ height = body.depth * unitFactor;
1579
+ if (opts.swapProfileDimensions) {
1580
+ if (body.yDim)
1581
+ width = body.yDim * unitFactor;
1582
+ if (body.xDim)
1583
+ depth = body.xDim * unitFactor;
1584
+ }
1585
+ else {
1586
+ if (body.xDim)
1587
+ width = body.xDim * unitFactor;
1588
+ if (body.yDim)
1589
+ depth = body.yDim * unitFactor;
1590
+ }
1591
+ profileShape = body.profileShape;
1592
+ if (body.radius != null)
1593
+ profileRadius = body.radius * unitFactor;
1594
+ }
1595
+ catch {
1596
+ /* keep defaults */
1597
+ }
1598
+ // Structural IFC columns are plain shafts. The ColumnNode defaults
1599
+ // are decorative (round-rings base + simple capital + a necked
1600
+ // shaft → a classical/Greek look), and the default round
1601
+ // cross-section ignores width/depth in favour of `radius`. Strip
1602
+ // the ornament, keep the shaft full-width, and size from the IFC
1603
+ // profile: use the real swept-area profile type when known
1604
+ // (IfcCircleProfileDef → round + radius, IfcRectangleProfileDef →
1605
+ // rectangular + width/depth), falling back to the width/depth
1606
+ // ratio when the profile type isn't recognised.
1607
+ const isRect = profileShape === 'rectangular' ||
1608
+ (profileShape === null &&
1609
+ width !== undefined &&
1610
+ depth !== undefined &&
1611
+ Math.abs(width - depth) > 0.15 * Math.max(width, depth));
1612
+ const columnNode = tryParse(ColumnNode, 'column', {
1613
+ object: 'node',
1614
+ id: nodeId,
1615
+ type: 'column',
1616
+ name: col.Name?.value || `Column ${i + 1}`,
1617
+ parentId: parentNodeId || null,
1618
+ visible: true,
1619
+ position,
1620
+ width,
1621
+ depth,
1622
+ height,
1623
+ crossSection: isRect ? 'rectangular' : 'round',
1624
+ radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2,
1625
+ style: 'plain',
1626
+ shaftProfile: 'straight',
1627
+ shaftStartScale: 1,
1628
+ shaftEndScale: 1,
1629
+ shaftSegmentCount: 1,
1630
+ baseStyle: 'none',
1631
+ capitalStyle: 'none',
1632
+ baseHeight: 0,
1633
+ capitalHeight: 0,
1634
+ metadata: buildMetadata({
1635
+ ifcType: 'IFCCOLUMN',
1636
+ expressID: colExpressID,
1637
+ globalId: col.GlobalId?.value,
1638
+ }),
1639
+ });
1640
+ nodes[nodeId] = columnNode;
1641
+ if (parentNodeId && nodes[parentNodeId]) {
1642
+ ;
1643
+ nodes[parentNodeId].children?.push(nodeId);
1644
+ }
1645
+ }
1646
+ }
1647
+ // Beams: skipped for now — Pascal has no `beam` node type yet. When it
1648
+ // lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
1649
+ // (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
1650
+ // extrusion depth → axis length). Reference implementation lives in
1651
+ // git history of this file. We still walk the entities to log how
1652
+ // many beams the IFC contained so the conversion summary is accurate.
1653
+ let skippedBeamCount = 0;
1654
+ const beamTypes = [WebIFC.IFCBEAM];
1655
+ try {
1656
+ beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE);
1657
+ }
1658
+ catch {
1659
+ /* not in all versions */
1660
+ }
1661
+ for (const beamType of beamTypes) {
1662
+ try {
1663
+ const beams = ifcApi.GetLineIDsWithType(modelID, beamType);
1664
+ skippedBeamCount += beams.size();
1665
+ }
1666
+ catch {
1667
+ /* type not present in this file */
1668
+ }
1669
+ }
1670
+ if (skippedBeamCount > 0) {
1671
+ console.warn(`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`);
1672
+ }
1673
+ // Items: skipped for now — Pascal's ItemNode requires a full `asset`
1674
+ // (catalog reference with id/src/dimensions/etc.) that the converter
1675
+ // can't synthesise from raw IFC geometry. When the editor grows a
1676
+ // raw-geometry escape hatch (or we add a placeholder-asset registry),
1677
+ // restore the mapping from the pre-migration git history. We still
1678
+ // walk the entities to log a count for diagnostics.
1679
+ let skippedItemCount = 0;
1680
+ const itemTypeKeys = [
1681
+ WebIFC.IFCFURNISHINGELEMENT,
1682
+ WebIFC.IFCBUILDINGELEMENTPROXY,
1683
+ WebIFC.IFCRAILING,
1684
+ WebIFC.IFCCOVERING,
1685
+ WebIFC.IFCCURTAINWALL,
1686
+ WebIFC.IFCPLATE,
1687
+ WebIFC.IFCMEMBER,
1688
+ WebIFC.IFCFOOTING,
1689
+ ];
1690
+ for (const itemType of itemTypeKeys) {
1691
+ try {
1692
+ const items = ifcApi.GetLineIDsWithType(modelID, itemType);
1693
+ skippedItemCount += items.size();
1694
+ }
1695
+ catch {
1696
+ /* type not present in this file */
1697
+ }
1698
+ }
1699
+ if (skippedItemCount > 0) {
1700
+ console.warn(`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`);
1701
+ }
1702
+ // Post-process: resolve levelId for all element nodes
1703
+ for (const node of Object.values(nodes)) {
1704
+ const m = meta(node);
1705
+ if (!m.expressID)
1706
+ continue;
1707
+ const storeyExpId = findStoreyForElement(m.expressID);
1708
+ if (storeyExpId != null) {
1709
+ m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined;
1710
+ }
1711
+ }
1712
+ // Post-process: extract property sets and materials
1713
+ const elementExpressIds = new Set();
1714
+ const expressIdToNode = new Map();
1715
+ for (const node of Object.values(nodes)) {
1716
+ const m = meta(node);
1717
+ if (m.expressID != null) {
1718
+ elementExpressIds.add(m.expressID);
1719
+ expressIdToNode.set(m.expressID, node);
1720
+ }
1721
+ }
1722
+ // Property sets via IFCRELDEFINESBYPROPERTIES
1723
+ try {
1724
+ const relDefines = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
1725
+ for (let i = 0; i < relDefines.size(); i++) {
1726
+ try {
1727
+ const rel = ifcApi.GetLine(modelID, relDefines.get(i));
1728
+ if (!rel.RelatedObjects || !rel.RelatingPropertyDefinition?.value)
1729
+ continue;
1730
+ const psetId = rel.RelatingPropertyDefinition.value;
1731
+ const pset = ifcApi.GetLine(modelID, psetId);
1732
+ const psetName = pset.Name?.value ?? 'Properties';
1733
+ const props = {};
1734
+ if (pset.HasProperties) {
1735
+ for (const propRef of pset.HasProperties) {
1736
+ try {
1737
+ const prop = ifcApi.GetLine(modelID, propRef.value);
1738
+ const name = prop.Name?.value;
1739
+ const val = prop.NominalValue?.value;
1740
+ if (name != null && val != null)
1741
+ props[name] = val;
1742
+ }
1743
+ catch {
1744
+ /* skip */
1745
+ }
1746
+ }
1747
+ }
1748
+ if (pset.Quantities) {
1749
+ for (const qRef of pset.Quantities) {
1750
+ try {
1751
+ const q = ifcApi.GetLine(modelID, qRef.value);
1752
+ const name = q.Name?.value;
1753
+ const val = q.LengthValue?.value ??
1754
+ q.AreaValue?.value ??
1755
+ q.VolumeValue?.value ??
1756
+ q.WeightValue?.value ??
1757
+ q.CountValue?.value;
1758
+ if (name != null && val != null)
1759
+ props[name] = val;
1760
+ }
1761
+ catch {
1762
+ /* skip */
1763
+ }
1764
+ }
1765
+ }
1766
+ if (Object.keys(props).length === 0)
1767
+ continue;
1768
+ for (const objRef of rel.RelatedObjects) {
1769
+ const node = expressIdToNode.get(objRef.value);
1770
+ if (!node)
1771
+ continue;
1772
+ const m = meta(node);
1773
+ if (!m.properties)
1774
+ m.properties = {};
1775
+ m.properties[psetName] = props;
1776
+ }
1777
+ }
1778
+ catch {
1779
+ /* skip rel */
1780
+ }
1781
+ }
1782
+ }
1783
+ catch {
1784
+ /* no property rels */
1785
+ }
1786
+ // Materials via IFCRELASSOCIATESMATERIAL
1787
+ try {
1788
+ const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
1789
+ for (let i = 0; i < relMat.size(); i++) {
1790
+ try {
1791
+ const rel = ifcApi.GetLine(modelID, relMat.get(i));
1792
+ if (!rel.RelatedObjects || !rel.RelatingMaterial?.value)
1793
+ continue;
1794
+ const mat = ifcApi.GetLine(modelID, rel.RelatingMaterial.value);
1795
+ let materialName = null;
1796
+ const layers = [];
1797
+ const extractLayers = (layersArr) => {
1798
+ for (const lRef of layersArr) {
1799
+ try {
1800
+ const layer = ifcApi.GetLine(modelID, lRef.value);
1801
+ const layerMat = layer.Material?.value
1802
+ ? ifcApi.GetLine(modelID, layer.Material.value)
1803
+ : null;
1804
+ layers.push({
1805
+ name: layerMat?.Name?.value ?? 'Unknown',
1806
+ thickness: layer.LayerThickness?.value != null
1807
+ ? layer.LayerThickness.value * unitFactor
1808
+ : undefined,
1809
+ });
1810
+ }
1811
+ catch {
1812
+ /* skip */
1813
+ }
1814
+ }
1815
+ };
1816
+ if (mat.ForLayerSet?.value) {
1817
+ const layerSet = ifcApi.GetLine(modelID, mat.ForLayerSet.value);
1818
+ materialName = layerSet.LayerSetName?.value ?? null;
1819
+ if (layerSet.MaterialLayers)
1820
+ extractLayers(layerSet.MaterialLayers);
1821
+ }
1822
+ else if (mat.MaterialLayers) {
1823
+ materialName = mat.LayerSetName?.value ?? null;
1824
+ extractLayers(mat.MaterialLayers);
1825
+ }
1826
+ else if (mat.Name?.value) {
1827
+ materialName = mat.Name.value;
1828
+ }
1829
+ if (!materialName && layers.length === 0)
1830
+ continue;
1831
+ for (const objRef of rel.RelatedObjects) {
1832
+ const node = expressIdToNode.get(objRef.value);
1833
+ if (!node)
1834
+ continue;
1835
+ const m = meta(node);
1836
+ if (materialName)
1837
+ m.material = materialName;
1838
+ if (layers.length > 0)
1839
+ m.materialLayers = layers;
1840
+ }
1841
+ }
1842
+ catch {
1843
+ /* skip rel */
1844
+ }
1845
+ }
1846
+ }
1847
+ catch {
1848
+ /* no material rels */
1849
+ }
1850
+ // Type names via IFCRELDEFINESBYTYPE
1851
+ try {
1852
+ const relDefType = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYTYPE);
1853
+ for (let i = 0; i < relDefType.size(); i++) {
1854
+ try {
1855
+ const rel = ifcApi.GetLine(modelID, relDefType.get(i));
1856
+ if (!rel.RelatedObjects || !rel.RelatingType?.value)
1857
+ continue;
1858
+ const type = ifcApi.GetLine(modelID, rel.RelatingType.value);
1859
+ const typeName = type.Name?.value;
1860
+ if (!typeName)
1861
+ continue;
1862
+ for (const objRef of rel.RelatedObjects) {
1863
+ const node = expressIdToNode.get(objRef.value);
1864
+ if (node)
1865
+ meta(node).typeName = typeName;
1866
+ }
1867
+ }
1868
+ catch {
1869
+ /* skip */
1870
+ }
1871
+ }
1872
+ }
1873
+ catch {
1874
+ /* no type rels */
1875
+ }
1876
+ ifcApi.CloseModel(modelID);
1877
+ progress('Building scene graph...', 95);
1878
+ const totalNodes = Object.keys(nodes).length;
1879
+ console.log(`[IFC→Pascal] Conversion complete! Generated ${totalNodes} nodes`);
1880
+ console.log(`[IFC→Pascal] Node breakdown:`, {
1881
+ sites: Object.values(nodes).filter((n) => n.type === 'site').length,
1882
+ buildings: Object.values(nodes).filter((n) => n.type === 'building').length,
1883
+ levels: Object.values(nodes).filter((n) => n.type === 'level').length,
1884
+ walls: Object.values(nodes).filter((n) => n.type === 'wall').length,
1885
+ slabs: Object.values(nodes).filter((n) => n.type === 'slab').length,
1886
+ doors: Object.values(nodes).filter((n) => n.type === 'door').length,
1887
+ windows: Object.values(nodes).filter((n) => n.type === 'window').length,
1888
+ stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
1889
+ roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
1890
+ columns: Object.values(nodes).filter((n) => n.type === 'column').length,
1891
+ skippedBeams: skippedBeamCount,
1892
+ skippedItems: skippedItemCount,
1893
+ });
1894
+ progress('Complete!', 100);
1895
+ return {
1896
+ nodes,
1897
+ rootNodeIds: rootNodeIds,
1898
+ };
1899
+ }