@ifc-lite/create 1.15.0 → 1.16.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.
Files changed (49) hide show
  1. package/dist/ifc-creator.js +1 -1
  2. package/dist/ifc-creator.js.map +1 -1
  3. package/dist/in-store/_emit-helpers.d.ts +8 -2
  4. package/dist/in-store/_emit-helpers.d.ts.map +1 -1
  5. package/dist/in-store/_emit-helpers.js +10 -2
  6. package/dist/in-store/_emit-helpers.js.map +1 -1
  7. package/dist/in-store/anchor.d.ts +23 -2
  8. package/dist/in-store/anchor.d.ts.map +1 -1
  9. package/dist/in-store/anchor.js +11 -1
  10. package/dist/in-store/anchor.js.map +1 -1
  11. package/dist/in-store/auto-space-detect.d.ts.map +1 -1
  12. package/dist/in-store/auto-space-detect.js +150 -57
  13. package/dist/in-store/auto-space-detect.js.map +1 -1
  14. package/dist/in-store/beam.d.ts.map +1 -1
  15. package/dist/in-store/beam.js +3 -2
  16. package/dist/in-store/beam.js.map +1 -1
  17. package/dist/in-store/column.d.ts.map +1 -1
  18. package/dist/in-store/column.js +4 -3
  19. package/dist/in-store/column.js.map +1 -1
  20. package/dist/in-store/extract-walls.d.ts +28 -1
  21. package/dist/in-store/extract-walls.d.ts.map +1 -1
  22. package/dist/in-store/extract-walls.js +400 -65
  23. package/dist/in-store/extract-walls.js.map +1 -1
  24. package/dist/in-store/generate-spaces-all.d.ts +72 -0
  25. package/dist/in-store/generate-spaces-all.d.ts.map +1 -0
  26. package/dist/in-store/generate-spaces-all.js +84 -0
  27. package/dist/in-store/generate-spaces-all.js.map +1 -0
  28. package/dist/in-store/generate-spaces.d.ts +33 -1
  29. package/dist/in-store/generate-spaces.d.ts.map +1 -1
  30. package/dist/in-store/generate-spaces.js +258 -3
  31. package/dist/in-store/generate-spaces.js.map +1 -1
  32. package/dist/in-store/resolve-anchor.d.ts.map +1 -1
  33. package/dist/in-store/resolve-anchor.js +58 -6
  34. package/dist/in-store/resolve-anchor.js.map +1 -1
  35. package/dist/in-store/slab.d.ts.map +1 -1
  36. package/dist/in-store/slab.js +3 -2
  37. package/dist/in-store/slab.js.map +1 -1
  38. package/dist/in-store/space.d.ts +28 -1
  39. package/dist/in-store/space.d.ts.map +1 -1
  40. package/dist/in-store/space.js +91 -8
  41. package/dist/in-store/space.js.map +1 -1
  42. package/dist/in-store/wall.d.ts.map +1 -1
  43. package/dist/in-store/wall.js +10 -7
  44. package/dist/in-store/wall.js.map +1 -1
  45. package/dist/index.d.ts +3 -2
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -2
  48. package/dist/index.js.map +1 -1
  49. package/package.json +8 -8
@@ -28,7 +28,7 @@
28
28
  * callers (and the viewer's Auto Spaces UI) can surface why a wall
29
29
  * didn't contribute to the planar graph.
30
30
  */
31
- import { EntityExtractor, extractLengthUnitScale, } from '@ifc-lite/parser';
31
+ import { EntityExtractor, extractLengthUnitScale, extractMaterialsOnDemand, } from '@ifc-lite/parser';
32
32
  /**
33
33
  * Element types treated as "wall-like" dividers by default. Extends
34
34
  * the obvious walls with curtain walls (often the only divider on a
@@ -51,6 +51,7 @@ const AXIS_EPS = 1e-6;
51
51
  export function extractWallSegmentsForStorey(store, storeyExpressId, overlay, options = {}) {
52
52
  const segments = [];
53
53
  const contributing = [];
54
+ const wallThicknesses = [];
54
55
  const skipped = [];
55
56
  const debug = !!options.debug;
56
57
  const log = debug ? (...args) => console.debug('[extract-walls]', ...args) : () => { };
@@ -72,7 +73,7 @@ export function extractWallSegmentsForStorey(store, storeyExpressId, overlay, op
72
73
  log(`length unit scale = ${lengthUnitScale} (raw → metres)`);
73
74
  if (!store.source) {
74
75
  log('no source bytes on data store — extraction cannot run');
75
- return { segments, contributingWallIds: contributing, skipped, considered: 0, lengthUnitScale };
76
+ return { segments, contributingWallIds: contributing, wallThicknesses, skipped, considered: 0, lengthUnitScale };
76
77
  }
77
78
  const dividerTypes = new Set(DEFAULT_DIVIDER_TYPES);
78
79
  if (options.extraDividerTypes) {
@@ -80,13 +81,14 @@ export function extractWallSegmentsForStorey(store, storeyExpressId, overlay, op
80
81
  dividerTypes.add(t.toLowerCase());
81
82
  }
82
83
  const extractor = new EntityExtractor(store.source);
83
- const dividerIds = collectDividerIdsOnStorey(store, storeyExpressId, dividerTypes, log);
84
+ const dividerIds = collectDividerIdsOnStorey(store, extractor, storeyExpressId, dividerTypes, log);
84
85
  log(`storey #${storeyExpressId}: ${dividerIds.length} contained divider element(s)`);
85
86
  for (const id of dividerIds) {
86
87
  const result = extractWallAxisFromSource(store, extractor, id, log);
87
88
  if (result.segment) {
88
89
  segments.push(scaleSegment(result.segment, lengthUnitScale));
89
90
  contributing.push(id);
91
+ wallThicknesses.push(wallThicknessFromMaterial(store, id));
90
92
  }
91
93
  else {
92
94
  skipped.push({ wallId: id, reason: result.reason ?? 'no-axis-or-rect-profile' });
@@ -104,6 +106,7 @@ export function extractWallSegmentsForStorey(store, storeyExpressId, overlay, op
104
106
  // metre coords — don't double-scale.
105
107
  segments.push(result.segment);
106
108
  contributing.push(ent.expressId);
109
+ wallThicknesses.push(undefined); // overlay walls carry no material yet
107
110
  }
108
111
  else {
109
112
  skipped.push({ wallId: ent.expressId, reason: result.reason ?? 'no-axis-or-rect-profile' });
@@ -126,11 +129,27 @@ export function extractWallSegmentsForStorey(store, storeyExpressId, overlay, op
126
129
  return {
127
130
  segments,
128
131
  contributingWallIds: contributing,
132
+ wallThicknesses,
129
133
  skipped,
130
134
  considered: dividerIds.length + overlayCount,
131
135
  lengthUnitScale,
132
136
  };
133
137
  }
138
+ /**
139
+ * Total thickness (metres) of a wall's material layer set, or `undefined` when
140
+ * the wall has no resolvable layers. The material resolver already scales layer
141
+ * thicknesses to metres, so no further unit conversion is applied.
142
+ */
143
+ function wallThicknessFromMaterial(store, wallId) {
144
+ const info = extractMaterialsOnDemand(store, wallId);
145
+ const layers = info?.layers;
146
+ if (!layers || layers.length === 0)
147
+ return undefined;
148
+ let total = 0;
149
+ for (const layer of layers)
150
+ total += layer.thickness ?? 0;
151
+ return total > 0 ? total : undefined;
152
+ }
134
153
  function scaleSegment(seg, scale) {
135
154
  if (scale === 1)
136
155
  return seg;
@@ -142,12 +161,18 @@ function scaleSegment(seg, scale) {
142
161
  function isDividerType(type, dividerTypes) {
143
162
  return dividerTypes.has(type.toLowerCase());
144
163
  }
145
- function collectDividerIdsOnStorey(store, storeyId, dividerTypes, log) {
164
+ function collectDividerIdsOnStorey(store, extractor, storeyId, dividerTypes, log) {
146
165
  const ids = [];
147
166
  const seen = new Set();
148
167
  if (!store.source)
149
168
  return ids;
150
- const extractor = new EntityExtractor(store.source);
169
+ // Build relating related-children indices ONCE, then walk in O(1) per
170
+ // parent. Previously each `descendAggregate` / `walkContainmentInto`
171
+ // call walked every IfcRelAggregates / IfcRelContainedInSpatialStructure
172
+ // entity in the file looking for one with the right relating id —
173
+ // O(R·N) total in the number of rels and parents visited.
174
+ const aggregateChildren = buildRelatingChildrenIndex(store, extractor, 'IFCRELAGGREGATES', 4, 5);
175
+ const containmentChildren = buildRelatingChildrenIndex(store, extractor, 'IFCRELCONTAINEDINSPATIALSTRUCTURE', 5, 4);
151
176
  const visitMember = (memberId) => {
152
177
  if (seen.has(memberId))
153
178
  return;
@@ -169,80 +194,112 @@ function collectDividerIdsOnStorey(store, storeyId, dividerTypes, log) {
169
194
  seen.add(parentId);
170
195
  // Anything `IfcRelContainedInSpatialStructure`-anchored to this
171
196
  // sub-structure should still be reachable.
172
- walkContainmentInto(parentId);
197
+ const contained = containmentChildren.get(parentId);
198
+ if (contained)
199
+ for (const m of contained)
200
+ visitMember(m);
173
201
  // And recurse through aggregation.
174
- const aggRels = store.entityIndex.byType.get('IFCRELAGGREGATES') ?? [];
175
- for (const relId of aggRels) {
176
- const ref = store.entityIndex.byId.get(relId);
177
- if (!ref)
178
- continue;
179
- const rel = extractor.extractEntity(ref);
180
- if (!rel)
181
- continue;
182
- const relating = rel.attributes[4];
183
- if (typeof relating !== 'number' || relating !== parentId)
184
- continue;
185
- const related = rel.attributes[5];
186
- if (!Array.isArray(related))
187
- continue;
188
- for (const child of related) {
189
- if (typeof child !== 'number')
190
- continue;
191
- visitMember(child);
192
- }
193
- }
194
- };
195
- const walkContainmentInto = (parentId) => {
196
- const containedRels = store.entityIndex.byType.get('IFCRELCONTAINEDINSPATIALSTRUCTURE') ?? [];
197
- for (const relId of containedRels) {
198
- const ref = store.entityIndex.byId.get(relId);
199
- if (!ref)
200
- continue;
201
- const rel = extractor.extractEntity(ref);
202
- if (!rel)
203
- continue;
204
- const relating = rel.attributes[5];
205
- if (typeof relating !== 'number' || relating !== parentId)
206
- continue;
207
- const related = rel.attributes[4];
208
- if (!Array.isArray(related))
209
- continue;
210
- for (const member of related) {
211
- if (typeof member !== 'number')
212
- continue;
213
- visitMember(member);
214
- }
215
- }
202
+ const agg = aggregateChildren.get(parentId);
203
+ if (agg)
204
+ for (const c of agg)
205
+ visitMember(c);
216
206
  };
217
207
  // Mark the storey itself as seen but DON'T push it (we want its
218
208
  // children, not the storey id).
219
209
  seen.add(storeyId);
220
- walkContainmentInto(storeyId);
210
+ const storeyContained = containmentChildren.get(storeyId);
211
+ if (storeyContained)
212
+ for (const m of storeyContained)
213
+ visitMember(m);
221
214
  // Some authoring tools attach elements via IfcRelAggregates to the
222
215
  // storey instead of containment (or in addition). Walk both
223
216
  // unconditionally to keep coverage broad.
224
- const aggRels = store.entityIndex.byType.get('IFCRELAGGREGATES') ?? [];
225
- for (const relId of aggRels) {
217
+ const storeyAgg = aggregateChildren.get(storeyId);
218
+ if (storeyAgg)
219
+ for (const c of storeyAgg)
220
+ visitMember(c);
221
+ log(`collected ${ids.length} divider candidate(s) — types: ${[...dividerTypes].join(', ')}`);
222
+ return ids;
223
+ }
224
+ /**
225
+ * Footprint polygons (model-local metres, same frame as the extracted wall
226
+ * segments) of existing `IfcSpace` per storey — so generation can skip *only*
227
+ * the new rooms that overlap an already-present space (per-space dedup), while
228
+ * still adding rooms an empty part of the floor lacks. Keyed by storey
229
+ * expressId; storeys with no resolvable space footprints are omitted.
230
+ */
231
+ export function existingSpaceFootprintsByStorey(store) {
232
+ const out = new Map();
233
+ if (!store.source)
234
+ return out;
235
+ const extractor = new EntityExtractor(store.source);
236
+ const scale = extractLengthUnitScale(store.source, store.entityIndex) ?? 1;
237
+ const aggregated = buildRelatingChildrenIndex(store, extractor, 'IFCRELAGGREGATES', 4, 5);
238
+ const contained = buildRelatingChildrenIndex(store, extractor, 'IFCRELCONTAINEDINSPATIALSTRUCTURE', 5, 4);
239
+ for (const st of store.getEntitiesByType('IfcBuildingStorey')) {
240
+ const kids = [...(aggregated.get(st.expressId) ?? []), ...(contained.get(st.expressId) ?? [])];
241
+ const footprints = [];
242
+ for (const id of kids) {
243
+ if ((store.entities.getTypeName(id) ?? '').toUpperCase() !== 'IFCSPACE')
244
+ continue;
245
+ const ref = store.entityIndex.byId.get(id);
246
+ if (!ref)
247
+ continue;
248
+ const ent = extractor.extractEntity(ref);
249
+ if (!ent)
250
+ continue;
251
+ const placementId = numericAttr(ent.attributes[5]); // ObjectPlacement
252
+ const representationId = numericAttr(ent.attributes[6]); // Representation
253
+ if (placementId === null || representationId === null)
254
+ continue;
255
+ const frame = readPlacementFrame(store, extractor, undefined, placementId);
256
+ const localPts = gatherBodyFootprintPoints(store, extractor, undefined, representationId);
257
+ if (!frame || !localPts || localPts.length < 3)
258
+ continue;
259
+ footprints.push(localPts.map((p) => {
260
+ const w = applyFrame(frame, p);
261
+ return [w[0] * scale, w[1] * scale];
262
+ }));
263
+ }
264
+ if (footprints.length)
265
+ out.set(st.expressId, footprints);
266
+ }
267
+ return out;
268
+ }
269
+ /**
270
+ * Index every relationship of `relType` by its "relating" attribute, so a
271
+ * lookup of "what's anchored to id X" becomes O(1) instead of an O(R)
272
+ * scan of every relationship.
273
+ */
274
+ function buildRelatingChildrenIndex(store, extractor, relType, relatingIdx, relatedIdx) {
275
+ const out = new Map();
276
+ const relIds = store.entityIndex.byType.get(relType);
277
+ if (!relIds)
278
+ return out;
279
+ for (const relId of relIds) {
226
280
  const ref = store.entityIndex.byId.get(relId);
227
281
  if (!ref)
228
282
  continue;
229
283
  const rel = extractor.extractEntity(ref);
230
284
  if (!rel)
231
285
  continue;
232
- const relating = rel.attributes[4];
233
- if (typeof relating !== 'number' || relating !== storeyId)
286
+ const relating = rel.attributes[relatingIdx];
287
+ if (typeof relating !== 'number')
234
288
  continue;
235
- const related = rel.attributes[5];
289
+ const related = rel.attributes[relatedIdx];
236
290
  if (!Array.isArray(related))
237
291
  continue;
292
+ let bucket = out.get(relating);
293
+ if (!bucket) {
294
+ bucket = [];
295
+ out.set(relating, bucket);
296
+ }
238
297
  for (const child of related) {
239
- if (typeof child !== 'number')
240
- continue;
241
- visitMember(child);
298
+ if (typeof child === 'number')
299
+ bucket.push(child);
242
300
  }
243
301
  }
244
- log(`collected ${ids.length} divider candidate(s) — types: ${[...dividerTypes].join(', ')}`);
245
- return ids;
302
+ return out;
246
303
  }
247
304
  function extractWallAxisFromSource(store, extractor, wallId, log) {
248
305
  const ref = store.entityIndex.byId.get(wallId);
@@ -299,9 +356,278 @@ function computeWallSegment(store, extractor, placementId, representationId, ove
299
356
  ];
300
357
  return finaliseSegment(start, end, wallId, log, 'rect-profile');
301
358
  }
302
- log(`wall #${wallId}: no Axis representation and no IfcRectangleProfileDef body skipping`);
359
+ // Strategy 3 body-footprint centreline. For walls with only a meshed
360
+ // body (IfcTriangulatedFaceSet / IfcPolygonalFaceSet) and no Axis/rect
361
+ // profile, project the body vertices to the local ground plane (drop the
362
+ // local vertical Z) and take the principal axis of that footprint as the
363
+ // wall centreline. Covers tessellated exports that the two shape-specific
364
+ // strategies miss.
365
+ const footprint = gatherBodyFootprintPoints(store, extractor, overlay, representationId);
366
+ if (footprint && footprint.length >= 3) {
367
+ const centreline = principalAxisCentreline(footprint);
368
+ if (centreline) {
369
+ const start = applyFrame(frame, centreline[0]);
370
+ const end = applyFrame(frame, centreline[1]);
371
+ return finaliseSegment(start, end, wallId, log, 'body-footprint');
372
+ }
373
+ }
374
+ log(`wall #${wallId}: no Axis representation, no IfcRectangleProfileDef body, no meshed footprint — skipping`);
303
375
  return { segment: null, reason: 'no-axis-or-rect-profile' };
304
376
  }
377
+ /**
378
+ * Gather the wall body's ground-plane footprint points (local frame, raw
379
+ * units) from a meshed representation. Reads the vertex list of every
380
+ * `IfcTriangulatedFaceSet` / `IfcPolygonalFaceSet` item in a non-`Axis`
381
+ * representation and drops the local vertical (Z), which is up in the
382
+ * wall-local frame before placement. Returns null when there's no mesh body.
383
+ */
384
+ function gatherBodyFootprintPoints(store, extractor, overlay, representationId) {
385
+ const productShape = readEntity(store, extractor, overlay, representationId);
386
+ if (!productShape)
387
+ return null;
388
+ const reps = productShape.attributes[2];
389
+ if (!Array.isArray(reps))
390
+ return null;
391
+ const pts = [];
392
+ for (const repRef of reps) {
393
+ const repId = numericAttr(repRef);
394
+ if (repId === null)
395
+ continue;
396
+ const rep = readEntity(store, extractor, overlay, repId);
397
+ if (!rep)
398
+ continue;
399
+ const identifier = stringAttr(rep.attributes[1]);
400
+ if (identifier && identifier.toLowerCase() === 'axis')
401
+ continue; // handled by Strategy 1
402
+ const items = rep.attributes[3];
403
+ if (!Array.isArray(items))
404
+ continue;
405
+ for (const itemRef of items) {
406
+ const itemId = numericAttr(itemRef);
407
+ if (itemId === null)
408
+ continue;
409
+ const item = readEntity(store, extractor, overlay, itemId);
410
+ if (!item)
411
+ continue;
412
+ const itemType = resolveEntityTypeName(store, item, itemId);
413
+ if (itemType === 'ifctriangulatedfaceset' || itemType === 'ifcpolygonalfaceset') {
414
+ // Vertices live in an IfcCartesianPointList3D at attribute 0; its
415
+ // attribute 0 is the flat list of [x, y, z] triples (rep frame).
416
+ const coordId = numericAttr(item.attributes[0]);
417
+ if (coordId === null)
418
+ continue;
419
+ const coordList = readEntity(store, extractor, overlay, coordId);
420
+ if (!coordList || !Array.isArray(coordList.attributes[0]))
421
+ continue;
422
+ for (const p of coordList.attributes[0]) {
423
+ const v = readVec3(p);
424
+ if (v)
425
+ pts.push([v[0], v[1]]);
426
+ }
427
+ }
428
+ else if (itemType === 'ifcextrudedareasolid') {
429
+ gatherExtrudedFootprint(store, extractor, overlay, item, pts);
430
+ }
431
+ else if (itemType === 'ifcfacetedbrep') {
432
+ gatherBrepFootprint(store, extractor, overlay, item, pts);
433
+ }
434
+ }
435
+ }
436
+ return pts.length >= 3 ? pts : null;
437
+ }
438
+ const IDENTITY2 = { o: [0, 0], x: [1, 0], y: [0, 1] };
439
+ const apply2 = (f, p) => [
440
+ f.o[0] + f.x[0] * p[0] + f.y[0] * p[1],
441
+ f.o[1] + f.x[1] * p[0] + f.y[1] * p[1],
442
+ ];
443
+ /** Read an IfcAxis2Placement2D/3D into its XY affine frame (Z ignored). */
444
+ function readPlacement2(store, extractor, overlay, ref) {
445
+ const id = numericAttr(ref);
446
+ if (id === null)
447
+ return IDENTITY2;
448
+ const ent = readEntity(store, extractor, overlay, id);
449
+ if (!ent)
450
+ return IDENTITY2;
451
+ const o = readCartesianPoint2D(store, extractor, overlay, ent.attributes[0]) ?? [0, 0];
452
+ // RefDirection: attr 1 on the 2D placement, attr 2 on the 3D placement.
453
+ const is3d = resolveEntityTypeName(store, ent, id) === 'ifcaxis2placement3d';
454
+ const refDir = readCartesianPoint2D(store, extractor, overlay, ent.attributes[is3d ? 2 : 1]);
455
+ let xx = 1, xy = 0;
456
+ if (refDir) {
457
+ const l = Math.hypot(refDir[0], refDir[1]) || 1;
458
+ xx = refDir[0] / l;
459
+ xy = refDir[1] / l;
460
+ }
461
+ return { o, x: [xx, xy], y: [-xy, xx] };
462
+ }
463
+ /**
464
+ * Footprint of a vertically-extruded solid = its swept profile placed into the
465
+ * rep frame. Handles rectangle and arbitrary-(polyline-)closed profiles; skips
466
+ * non-vertical extrusions (the profile wouldn't be a plan footprint then).
467
+ */
468
+ function gatherExtrudedFootprint(store, extractor, overlay, solid, out) {
469
+ // IfcExtrudedAreaSolid = SweptArea(0), Position(1), ExtrudedDirection(2), Depth(3).
470
+ // The swept profile is a *plan* footprint only when the extrusion ends up
471
+ // world-vertical: ExtrudedDirection must be the solid's local Z AND the
472
+ // solid's Position must keep that Z world-up. Multi-layer walls (e.g. #218)
473
+ // extrude along their thickness via a Position rotated to a horizontal axis —
474
+ // their profile is an elevation, not a footprint, so skip rather than emit a
475
+ // bogus centreline.
476
+ const dirId = numericAttr(solid.attributes[2]);
477
+ if (dirId !== null) {
478
+ const d = readVec3(readEntity(store, extractor, overlay, dirId)?.attributes[0]);
479
+ if (d && Math.abs(d[2]) < 0.9)
480
+ return;
481
+ }
482
+ const posId = numericAttr(solid.attributes[1]);
483
+ if (posId !== null) {
484
+ const posEnt = readEntity(store, extractor, overlay, posId);
485
+ const axisId = posEnt ? numericAttr(posEnt.attributes[1]) : null;
486
+ if (axisId !== null) {
487
+ const az = readVec3(readEntity(store, extractor, overlay, axisId)?.attributes[0]);
488
+ if (az && Math.abs(az[2]) < 0.9)
489
+ return; // solid's local Z isn't world-up
490
+ }
491
+ }
492
+ const solidPos = readPlacement2(store, extractor, overlay, solid.attributes[1]);
493
+ const profileId = numericAttr(solid.attributes[0]);
494
+ if (profileId === null)
495
+ return;
496
+ const profile = readEntity(store, extractor, overlay, profileId);
497
+ if (!profile)
498
+ return;
499
+ const ptype = resolveEntityTypeName(store, profile, profileId);
500
+ if (ptype === 'ifcrectangleprofiledef') {
501
+ const ppos = readPlacement2(store, extractor, overlay, profile.attributes[2]);
502
+ const xd = numericAttr(profile.attributes[3]) ?? 0;
503
+ const yd = numericAttr(profile.attributes[4]) ?? 0;
504
+ if (xd <= 0 || yd <= 0)
505
+ return;
506
+ const corners = [[-xd / 2, -yd / 2], [xd / 2, -yd / 2], [xd / 2, yd / 2], [-xd / 2, yd / 2]];
507
+ for (const c of corners)
508
+ out.push(apply2(solidPos, apply2(ppos, c)));
509
+ }
510
+ else if (ptype === 'ifcarbitraryclosedprofiledef' || ptype === 'ifcarbitraryprofiledefwithvoids') {
511
+ // OuterCurve at attr 2 for both; read its points as the footprint cloud
512
+ // (PCA only needs the cloud, not edge order). Polyline or IndexedPolyCurve.
513
+ const ocId = numericAttr(profile.attributes[2]);
514
+ if (ocId === null)
515
+ return;
516
+ const oc = readEntity(store, extractor, overlay, ocId);
517
+ if (!oc)
518
+ return;
519
+ const octype = resolveEntityTypeName(store, oc, ocId);
520
+ if (octype === 'ifcpolyline' && Array.isArray(oc.attributes[0])) {
521
+ for (const ptRef of oc.attributes[0]) {
522
+ const v = readCartesianPoint2D(store, extractor, overlay, ptRef);
523
+ if (v)
524
+ out.push(apply2(solidPos, v));
525
+ }
526
+ }
527
+ else if (octype === 'ifcindexedpolycurve') {
528
+ // IfcIndexedPolyCurve.Points → IfcCartesianPointList2D (flat [x,y] list).
529
+ const listId = numericAttr(oc.attributes[0]);
530
+ if (listId === null)
531
+ return;
532
+ const list = readEntity(store, extractor, overlay, listId);
533
+ if (!list || !Array.isArray(list.attributes[0]))
534
+ return;
535
+ for (const p of list.attributes[0]) {
536
+ const v = readVec3(p);
537
+ if (v)
538
+ out.push(apply2(solidPos, [v[0], v[1]]));
539
+ }
540
+ }
541
+ }
542
+ }
543
+ /**
544
+ * Footprint of an IfcFacetedBrep = all its shell vertices projected to XY.
545
+ * Walks Outer (IfcClosedShell) → faces → bounds → IfcPolyLoop → points. The
546
+ * vertices are already in the rep frame, so no extra placement is needed.
547
+ */
548
+ function gatherBrepFootprint(store, extractor, overlay, brep, out) {
549
+ const shellId = numericAttr(brep.attributes[0]);
550
+ if (shellId === null)
551
+ return;
552
+ const shell = readEntity(store, extractor, overlay, shellId);
553
+ if (!shell || !Array.isArray(shell.attributes[0]))
554
+ return;
555
+ for (const faceRef of shell.attributes[0]) {
556
+ const faceId = numericAttr(faceRef);
557
+ if (faceId === null)
558
+ continue;
559
+ const face = readEntity(store, extractor, overlay, faceId);
560
+ if (!face || !Array.isArray(face.attributes[0]))
561
+ continue;
562
+ for (const boundRef of face.attributes[0]) {
563
+ const boundId = numericAttr(boundRef);
564
+ if (boundId === null)
565
+ continue;
566
+ const bound = readEntity(store, extractor, overlay, boundId);
567
+ if (!bound)
568
+ continue;
569
+ const loopId = numericAttr(bound.attributes[0]);
570
+ if (loopId === null)
571
+ continue;
572
+ const loop = readEntity(store, extractor, overlay, loopId);
573
+ if (!loop || !Array.isArray(loop.attributes[0]))
574
+ continue;
575
+ for (const ptRef of loop.attributes[0]) {
576
+ const v = readCartesianPoint2D(store, extractor, overlay, ptRef);
577
+ if (v)
578
+ out.push(v);
579
+ }
580
+ }
581
+ }
582
+ }
583
+ /**
584
+ * Principal-axis centreline of a 2D point cloud (PCA). A wall footprint is a
585
+ * thin elongated rectangle, so the largest-eigenvalue direction is the wall's
586
+ * length and the centroid sits on the centreline — exactly the axis we want.
587
+ * Returns the centroid-anchored segment spanning the footprint's extent along
588
+ * that axis, or null if it collapses to a point.
589
+ */
590
+ function principalAxisCentreline(points) {
591
+ const n = points.length;
592
+ let cx = 0, cy = 0;
593
+ for (const [x, y] of points) {
594
+ cx += x;
595
+ cy += y;
596
+ }
597
+ cx /= n;
598
+ cy /= n;
599
+ let sxx = 0, sxy = 0, syy = 0;
600
+ for (const [x, y] of points) {
601
+ const dx = x - cx, dy = y - cy;
602
+ sxx += dx * dx;
603
+ sxy += dx * dy;
604
+ syy += dy * dy;
605
+ }
606
+ const tr = sxx + syy;
607
+ const disc = Math.sqrt(Math.max(0, (tr * tr) / 4 - (sxx * syy - sxy * sxy)));
608
+ const lambda = tr / 2 + disc; // largest eigenvalue
609
+ let ex = sxy, ey = lambda - sxx;
610
+ if (Math.hypot(ex, ey) < 1e-12) {
611
+ ex = lambda - syy;
612
+ ey = sxy;
613
+ }
614
+ const elen = Math.hypot(ex, ey);
615
+ if (elen < 1e-12)
616
+ return null;
617
+ ex /= elen;
618
+ ey /= elen;
619
+ let tmin = Infinity, tmax = -Infinity;
620
+ for (const [x, y] of points) {
621
+ const t = (x - cx) * ex + (y - cy) * ey;
622
+ if (t < tmin)
623
+ tmin = t;
624
+ if (t > tmax)
625
+ tmax = t;
626
+ }
627
+ if (tmax - tmin < AXIS_EPS)
628
+ return null;
629
+ return [[cx + ex * tmin, cy + ey * tmin], [cx + ex * tmax, cy + ey * tmax]];
630
+ }
305
631
  function finaliseSegment(start, end, wallId, log, source) {
306
632
  const dx = end[0] - start[0];
307
633
  const dy = end[1] - start[1];
@@ -402,7 +728,7 @@ function readAxisRepresentationEndpoints(store, extractor, overlay, representati
402
728
  const item = readEntity(store, extractor, overlay, itemId);
403
729
  if (!item)
404
730
  continue;
405
- const itemType = (store.entities.getTypeName(itemId) || item.type || '').toLowerCase();
731
+ const itemType = resolveEntityTypeName(store, item, itemId);
406
732
  if (itemType !== 'ifcpolyline')
407
733
  continue;
408
734
  // IfcPolyline.Points = list of IfcCartesianPoint refs (attribute 0).
@@ -463,7 +789,7 @@ function readWallLength(store, extractor, overlay, representationId) {
463
789
  const profile = readEntity(store, extractor, overlay, profileId);
464
790
  if (!profile)
465
791
  continue;
466
- const profileType = profileTypeName(store, profile, profileId);
792
+ const profileType = resolveEntityTypeName(store, profile, profileId);
467
793
  if (profileType !== 'ifcrectangleprofiledef')
468
794
  continue;
469
795
  // IfcRectangleProfileDef.XDim = attribute index 3.
@@ -474,9 +800,18 @@ function readWallLength(store, extractor, overlay, representationId) {
474
800
  }
475
801
  return null;
476
802
  }
477
- function profileTypeName(store, profile, profileId) {
478
- const fromTable = store.entities.getTypeName(profileId);
479
- const name = (fromTable && fromTable !== 'Unknown' ? fromTable : profile.type) ?? '';
803
+ /**
804
+ * Robust IFC type-name for an entity read from source. The columnar entity
805
+ * table only indexes products/named entities, so `getTypeName` returns
806
+ * `'Unknown'` for geometry primitives (IfcPolyline, IfcCartesianPoint,
807
+ * profiles, solids). The extractor's `entity.type` is always reliable — so
808
+ * prefer the table only when it actually resolved, otherwise fall back to the
809
+ * extractor type. (Reading the table's `'Unknown'` literally was what made
810
+ * every `Curve2D` Axis polyline — e.g. all of AC20-FZK-Haus — get skipped.)
811
+ */
812
+ export function resolveEntityTypeName(store, entity, entityId) {
813
+ const fromTable = store.entities.getTypeName(entityId);
814
+ const name = (fromTable && fromTable !== 'Unknown' ? fromTable : entity.type) ?? '';
480
815
  return name.toLowerCase();
481
816
  }
482
817
  function readEntity(store, extractor, overlay, expressId) {