@ifc-lite/parser 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,10 +62,16 @@ const QUANTITY_TYPE_MAP = {
62
62
  const SPATIAL_TYPES = new Set([
63
63
  'IFCPROJECT', 'IFCSITE', 'IFCBUILDING', 'IFCBUILDINGSTOREY', 'IFCSPACE',
64
64
  ]);
65
- // Relationship types needed for hierarchy
65
+ // Relationship types needed for hierarchy and structural relationships
66
66
  const HIERARCHY_REL_TYPES = new Set([
67
67
  'IFCRELAGGREGATES', 'IFCRELCONTAINEDINSPATIALSTRUCTURE',
68
68
  'IFCRELDEFINESBYTYPE',
69
+ // Structural relationships (voids, fills, connections, groups)
70
+ 'IFCRELVOIDSELEMENT', 'IFCRELFILLSELEMENT',
71
+ 'IFCRELCONNECTSPATHELEMENTS', 'IFCRELCONNECTSELEMENTS',
72
+ 'IFCRELSPACEBOUNDARY',
73
+ 'IFCRELASSIGNSTOGROUP', 'IFCRELASSIGNSTOPRODUCT',
74
+ 'IFCRELREFERENCEDINSPATIALSTRUCTURE',
69
75
  ]);
70
76
  // Relationship types for on-demand property loading
71
77
  const PROPERTY_REL_TYPES = new Set([
@@ -90,10 +96,303 @@ const PROPERTY_ENTITY_TYPES = new Set([
90
96
  function isIfcTypeLikeEntity(typeUpper) {
91
97
  return typeUpper.endsWith('TYPE') || typeUpper.endsWith('STYLE');
92
98
  }
99
+ // ==========================================
100
+ // Byte-level helpers for fast extraction
101
+ // These avoid per-entity TextDecoder calls by working on raw bytes.
102
+ // ==========================================
93
103
  /**
94
- * Detect the IFC schema version from the STEP FILE_SCHEMA header.
95
- * Scans the first 2000 bytes for FILE_SCHEMA(('IFC2X3')), FILE_SCHEMA(('IFC4')), etc.
104
+ * Find the byte range of a quoted string at a specific attribute position in STEP entity bytes.
105
+ * Returns [start, end) byte offsets (excluding quotes), or null if not found.
106
+ *
107
+ * @param buffer - The IFC file buffer
108
+ * @param entityStart - byte offset of the entity
109
+ * @param entityLen - byte length of the entity
110
+ * @param attrIndex - 0-based attribute index (0=GlobalId, 2=Name)
96
111
  */
112
+ function findQuotedAttrRange(buffer, entityStart, entityLen, attrIndex) {
113
+ const end = entityStart + entityLen;
114
+ let pos = entityStart;
115
+ // Skip to opening paren '(' after TYPE name
116
+ while (pos < end && buffer[pos] !== 0x28 /* ( */)
117
+ pos++;
118
+ if (pos >= end)
119
+ return null;
120
+ pos++; // skip '('
121
+ // Skip commas to reach the target attribute
122
+ if (attrIndex > 0) {
123
+ let toSkip = attrIndex;
124
+ let depth = 0;
125
+ let inStr = false;
126
+ while (pos < end && toSkip > 0) {
127
+ const ch = buffer[pos];
128
+ if (ch === 0x27 /* ' */) {
129
+ if (inStr && pos + 1 < end && buffer[pos + 1] === 0x27) {
130
+ pos += 2;
131
+ continue;
132
+ }
133
+ inStr = !inStr;
134
+ }
135
+ else if (!inStr) {
136
+ if (ch === 0x28)
137
+ depth++;
138
+ else if (ch === 0x29)
139
+ depth--;
140
+ else if (ch === 0x2C && depth === 0)
141
+ toSkip--;
142
+ }
143
+ pos++;
144
+ }
145
+ }
146
+ // Skip whitespace
147
+ while (pos < end && (buffer[pos] === 0x20 || buffer[pos] === 0x09))
148
+ pos++;
149
+ // Check for quoted string
150
+ if (pos >= end || buffer[pos] !== 0x27 /* ' */)
151
+ return null;
152
+ pos++; // skip opening quote
153
+ const start = pos;
154
+ // Find closing quote (handle escaped quotes '')
155
+ while (pos < end) {
156
+ if (buffer[pos] === 0x27) {
157
+ if (pos + 1 < end && buffer[pos + 1] === 0x27) {
158
+ pos += 2;
159
+ continue;
160
+ }
161
+ break;
162
+ }
163
+ pos++;
164
+ }
165
+ return [start, pos];
166
+ }
167
+ /**
168
+ * Batch extract GlobalId (attr[0]) and Name (attr[2]) for many entities using
169
+ * only 2 TextDecoder.decode() calls total (one for all GlobalIds, one for all Names).
170
+ *
171
+ * This is ~100x faster than calling extractEntity() per entity for large batches
172
+ * because it eliminates per-entity TextDecoder overhead which is significant in Firefox.
173
+ *
174
+ * Returns a Map from expressId → { globalId, name }.
175
+ */
176
+ function batchExtractGlobalIdAndName(buffer, refs) {
177
+ const result = new Map();
178
+ if (refs.length === 0)
179
+ return result;
180
+ // Phase 1: Scan byte ranges for GlobalId and Name positions (no string allocation)
181
+ const gidRanges = []; // [start, end) for each entity
182
+ const nameRanges = [];
183
+ const validIndices = []; // indices into refs for entities with valid ranges
184
+ for (let i = 0; i < refs.length; i++) {
185
+ const ref = refs[i];
186
+ const gidRange = findQuotedAttrRange(buffer, ref.byteOffset, ref.byteLength, 0);
187
+ const nameRange = findQuotedAttrRange(buffer, ref.byteOffset, ref.byteLength, 2);
188
+ gidRanges.push(gidRange ?? [0, 0]);
189
+ nameRanges.push(nameRange ?? [0, 0]);
190
+ validIndices.push(i);
191
+ }
192
+ // Phase 2: Concatenate all GlobalId bytes into one buffer, decode once
193
+ // Use null byte (0x00) as separator (never appears in IFC string content)
194
+ let totalGidBytes = 0;
195
+ let totalNameBytes = 0;
196
+ for (let i = 0; i < validIndices.length; i++) {
197
+ const [gs, ge] = gidRanges[i];
198
+ const [ns, ne] = nameRanges[i];
199
+ totalGidBytes += (ge - gs) + 1; // +1 for separator
200
+ totalNameBytes += (ne - ns) + 1;
201
+ }
202
+ const gidBuf = new Uint8Array(totalGidBytes);
203
+ const nameBuf = new Uint8Array(totalNameBytes);
204
+ let gidOffset = 0;
205
+ let nameOffset = 0;
206
+ for (let i = 0; i < validIndices.length; i++) {
207
+ const [gs, ge] = gidRanges[i];
208
+ const [ns, ne] = nameRanges[i];
209
+ if (ge > gs) {
210
+ gidBuf.set(buffer.subarray(gs, ge), gidOffset);
211
+ gidOffset += ge - gs;
212
+ }
213
+ gidBuf[gidOffset++] = 0; // null separator
214
+ if (ne > ns) {
215
+ nameBuf.set(buffer.subarray(ns, ne), nameOffset);
216
+ nameOffset += ne - ns;
217
+ }
218
+ nameBuf[nameOffset++] = 0;
219
+ }
220
+ // Phase 3: Two TextDecoder calls for ALL entities
221
+ const decoder = new TextDecoder();
222
+ const allGids = decoder.decode(gidBuf.subarray(0, gidOffset));
223
+ const allNames = decoder.decode(nameBuf.subarray(0, nameOffset));
224
+ const gids = allGids.split('\0');
225
+ const names = allNames.split('\0');
226
+ // Phase 4: Build result map
227
+ for (let i = 0; i < validIndices.length; i++) {
228
+ const ref = refs[validIndices[i]];
229
+ result.set(ref.expressId, {
230
+ globalId: gids[i] || '',
231
+ name: names[i] || '',
232
+ });
233
+ }
234
+ return result;
235
+ }
236
+ // ==========================================
237
+ // Byte-level relationship scanners (numbers only, no TextDecoder)
238
+ // ==========================================
239
+ /**
240
+ * Skip N commas at depth 0 in STEP bytes.
241
+ */
242
+ function skipCommas(buffer, start, end, count) {
243
+ let pos = start;
244
+ let remaining = count;
245
+ let depth = 0;
246
+ let inString = false;
247
+ while (pos < end && remaining > 0) {
248
+ const ch = buffer[pos];
249
+ if (ch === 0x27) {
250
+ if (inString && pos + 1 < end && buffer[pos + 1] === 0x27) {
251
+ pos += 2;
252
+ continue;
253
+ }
254
+ inString = !inString;
255
+ }
256
+ else if (!inString) {
257
+ if (ch === 0x28)
258
+ depth++;
259
+ else if (ch === 0x29)
260
+ depth--;
261
+ else if (ch === 0x2C && depth === 0)
262
+ remaining--;
263
+ }
264
+ pos++;
265
+ }
266
+ return pos;
267
+ }
268
+ /** Read a #ID entity reference as a number. Returns -1 if not an entity ref. */
269
+ function readRefId(buffer, pos, end) {
270
+ while (pos < end && (buffer[pos] === 0x20 || buffer[pos] === 0x09))
271
+ pos++;
272
+ if (pos < end && buffer[pos] === 0x23) {
273
+ pos++;
274
+ let num = 0;
275
+ while (pos < end && buffer[pos] >= 0x30 && buffer[pos] <= 0x39) {
276
+ num = num * 10 + (buffer[pos] - 0x30);
277
+ pos++;
278
+ }
279
+ return [num, pos];
280
+ }
281
+ return [-1, pos];
282
+ }
283
+ /** Read a list of entity refs (#id1,#id2,...) or a single #id. Returns [ids, newPos]. */
284
+ function readRefList(buffer, pos, end) {
285
+ while (pos < end && (buffer[pos] === 0x20 || buffer[pos] === 0x09))
286
+ pos++;
287
+ const ids = [];
288
+ if (pos < end && buffer[pos] === 0x28) {
289
+ pos++;
290
+ while (pos < end && buffer[pos] !== 0x29) {
291
+ while (pos < end && (buffer[pos] === 0x20 || buffer[pos] === 0x09 || buffer[pos] === 0x2C))
292
+ pos++;
293
+ if (pos < end && buffer[pos] === 0x23) {
294
+ const [id, np] = readRefId(buffer, pos, end);
295
+ if (id >= 0)
296
+ ids.push(id);
297
+ pos = np;
298
+ }
299
+ else if (pos < end && buffer[pos] !== 0x29) {
300
+ pos++;
301
+ }
302
+ }
303
+ }
304
+ else if (pos < end && buffer[pos] === 0x23) {
305
+ const [id, np] = readRefId(buffer, pos, end);
306
+ if (id >= 0)
307
+ ids.push(id);
308
+ pos = np;
309
+ }
310
+ return [ids, pos];
311
+ }
312
+ /**
313
+ * Extract relatingObject and relatedObjects from a relationship entity using byte-level scanning.
314
+ * No TextDecoder needed - only extracts numeric entity IDs.
315
+ */
316
+ function extractRelFast(buffer, byteOffset, byteLength, typeUpper) {
317
+ const end = byteOffset + byteLength;
318
+ let pos = byteOffset;
319
+ while (pos < end && buffer[pos] !== 0x28)
320
+ pos++;
321
+ if (pos >= end)
322
+ return null;
323
+ pos++;
324
+ // Skip to attr[4] (all IfcRelationship subtypes have 4 shared IfcRoot+IfcRelationship attrs)
325
+ pos = skipCommas(buffer, pos, end, 4);
326
+ if (typeUpper === 'IFCRELCONTAINEDINSPATIALSTRUCTURE'
327
+ || typeUpper === 'IFCRELREFERENCEDINSPATIALSTRUCTURE'
328
+ || typeUpper === 'IFCRELDEFINESBYPROPERTIES'
329
+ || typeUpper === 'IFCRELDEFINESBYTYPE') {
330
+ // attr[4]=RelatedObjects, attr[5]=RelatingObject
331
+ const [related, rp] = readRefList(buffer, pos, end);
332
+ pos = rp;
333
+ while (pos < end && buffer[pos] !== 0x2C)
334
+ pos++;
335
+ pos++;
336
+ const [relating, _] = readRefId(buffer, pos, end);
337
+ if (relating < 0 || related.length === 0)
338
+ return null;
339
+ return { relatingObject: relating, relatedObjects: related };
340
+ }
341
+ else if (typeUpper === 'IFCRELASSIGNSTOGROUP' || typeUpper === 'IFCRELASSIGNSTOPRODUCT') {
342
+ const [related, rp] = readRefList(buffer, pos, end);
343
+ pos = skipCommas(buffer, rp, end, 2);
344
+ const [relating, _] = readRefId(buffer, pos, end);
345
+ if (relating < 0 || related.length === 0)
346
+ return null;
347
+ return { relatingObject: relating, relatedObjects: related };
348
+ }
349
+ else if (typeUpper === 'IFCRELCONNECTSELEMENTS' || typeUpper === 'IFCRELCONNECTSPATHELEMENTS') {
350
+ pos = skipCommas(buffer, pos, end, 1);
351
+ const [relating, rp2] = readRefId(buffer, pos, end);
352
+ pos = skipCommas(buffer, rp2, end, 1);
353
+ const [related, _] = readRefId(buffer, pos, end);
354
+ if (relating < 0 || related < 0)
355
+ return null;
356
+ return { relatingObject: relating, relatedObjects: [related] };
357
+ }
358
+ else {
359
+ // Default: attr[4]=RelatingObject, attr[5]=RelatedObject(s)
360
+ const [relating, rp] = readRefId(buffer, pos, end);
361
+ if (relating < 0)
362
+ return null;
363
+ pos = rp;
364
+ while (pos < end && buffer[pos] !== 0x2C)
365
+ pos++;
366
+ pos++;
367
+ const [related, _] = readRefList(buffer, pos, end);
368
+ if (related.length === 0)
369
+ return null;
370
+ return { relatingObject: relating, relatedObjects: related };
371
+ }
372
+ }
373
+ /**
374
+ * Extract property rel data: attr[4]=relatedObjects, attr[5]=relatingDef.
375
+ * Numbers only, no TextDecoder.
376
+ */
377
+ function extractPropertyRelFast(buffer, byteOffset, byteLength) {
378
+ const end = byteOffset + byteLength;
379
+ let pos = byteOffset;
380
+ while (pos < end && buffer[pos] !== 0x28)
381
+ pos++;
382
+ if (pos >= end)
383
+ return null;
384
+ pos++;
385
+ pos = skipCommas(buffer, pos, end, 4);
386
+ const [relatedObjects, rp] = readRefList(buffer, pos, end);
387
+ pos = rp;
388
+ while (pos < end && buffer[pos] !== 0x2C)
389
+ pos++;
390
+ pos++;
391
+ const [relatingDef, _] = readRefId(buffer, pos, end);
392
+ if (relatingDef < 0 || relatedObjects.length === 0)
393
+ return null;
394
+ return { relatedObjects, relatingDef };
395
+ }
97
396
  function detectSchemaVersion(buffer) {
98
397
  const headerEnd = Math.min(buffer.length, 2000);
99
398
  const headerText = new TextDecoder().decode(buffer.subarray(0, headerEnd)).toUpperCase();
@@ -119,32 +418,67 @@ export class ColumnarParser {
119
418
  const startTime = performance.now();
120
419
  const uint8Buffer = new Uint8Array(buffer);
121
420
  const totalEntities = entityRefs.length;
421
+ // Phase timing for performance telemetry
422
+ let phaseStart = startTime;
423
+ const logPhase = (name) => {
424
+ const now = performance.now();
425
+ console.log(`[parseLite] ${name}: ${Math.round(now - phaseStart)}ms`);
426
+ phaseStart = now;
427
+ };
122
428
  options.onProgress?.({ phase: 'building', percent: 0 });
123
429
  // Detect schema version from FILE_SCHEMA header
124
430
  const schemaVersion = detectSchemaVersion(uint8Buffer);
125
- // Initialize builders
431
+ // Initialize builders (entity table capacity set after categorization below)
126
432
  const strings = new StringTable();
127
- const entityTableBuilder = new EntityTableBuilder(totalEntities, strings);
128
433
  const propertyTableBuilder = new PropertyTableBuilder(strings);
129
434
  const quantityTableBuilder = new QuantityTableBuilder(strings);
130
435
  const relationshipGraphBuilder = new RelationshipGraphBuilder();
436
+ logPhase('init builders');
131
437
  // Build compact entity index (typed arrays instead of Map for ~3x memory reduction)
132
438
  const compactByIdIndex = buildCompactEntityIndex(entityRefs);
133
- // Also build byType index (Map<string, number[]>)
439
+ logPhase('compact entity index');
440
+ // Single pass: build byType index AND categorize entities simultaneously.
441
+ // Uses a type-name cache to avoid calling .toUpperCase() on 4.4M refs
442
+ // (only ~776 unique type names in IFC4).
134
443
  const byType = new Map();
135
- for (const ref of entityRefs) {
136
- let typeList = byType.get(ref.type);
137
- if (!typeList) {
138
- typeList = [];
139
- byType.set(ref.type, typeList);
140
- }
141
- typeList.push(ref.expressId);
444
+ const RELEVANT_ENTITY_PREFIXES = new Set([
445
+ 'IFCWALL', 'IFCSLAB', 'IFCBEAM', 'IFCCOLUMN', 'IFCPLATE', 'IFCDOOR', 'IFCWINDOW',
446
+ 'IFCROOF', 'IFCSTAIR', 'IFCRAILING', 'IFCRAMP', 'IFCFOOTING', 'IFCPILE',
447
+ 'IFCMEMBER', 'IFCCURTAINWALL', 'IFCBUILDINGELEMENTPROXY', 'IFCFURNISHINGELEMENT',
448
+ 'IFCFLOWSEGMENT', 'IFCFLOWTERMINAL', 'IFCFLOWCONTROLLER', 'IFCFLOWFITTING',
449
+ 'IFCSPACE', 'IFCOPENINGELEMENT', 'IFCSITE', 'IFCBUILDING', 'IFCBUILDINGSTOREY',
450
+ 'IFCPROJECT', 'IFCCOVERING', 'IFCANNOTATION', 'IFCGRID',
451
+ ]);
452
+ // Category constants for the lookup cache
453
+ const CAT_SKIP = 0, CAT_SPATIAL = 1, CAT_GEOMETRY = 2, CAT_HIERARCHY_REL = 3, CAT_PROPERTY_REL = 4, CAT_PROPERTY_ENTITY = 5, CAT_ASSOCIATION_REL = 6, CAT_TYPE_OBJECT = 7, CAT_RELEVANT = 8;
454
+ // Cache: type name → category (avoids 4.4M .toUpperCase() calls)
455
+ const typeCategoryCache = new Map();
456
+ function getCategory(type) {
457
+ let cat = typeCategoryCache.get(type);
458
+ if (cat !== undefined)
459
+ return cat;
460
+ const upper = type.toUpperCase();
461
+ if (SPATIAL_TYPES.has(upper))
462
+ cat = CAT_SPATIAL;
463
+ else if (GEOMETRY_TYPES.has(upper))
464
+ cat = CAT_GEOMETRY;
465
+ else if (HIERARCHY_REL_TYPES.has(upper))
466
+ cat = CAT_HIERARCHY_REL;
467
+ else if (PROPERTY_REL_TYPES.has(upper))
468
+ cat = CAT_PROPERTY_REL;
469
+ else if (PROPERTY_ENTITY_TYPES.has(upper))
470
+ cat = CAT_PROPERTY_ENTITY;
471
+ else if (ASSOCIATION_REL_TYPES.has(upper))
472
+ cat = CAT_ASSOCIATION_REL;
473
+ else if (isIfcTypeLikeEntity(upper))
474
+ cat = CAT_TYPE_OBJECT;
475
+ else if (RELEVANT_ENTITY_PREFIXES.has(upper) || upper.startsWith('IFCREL'))
476
+ cat = CAT_RELEVANT;
477
+ else
478
+ cat = CAT_SKIP;
479
+ typeCategoryCache.set(type, cat);
480
+ return cat;
142
481
  }
143
- const entityIndex = {
144
- byId: compactByIdIndex,
145
- byType,
146
- };
147
- // First pass: collect spatial, geometry, relationship, property, and type refs for targeted parsing
148
482
  const spatialRefs = [];
149
483
  const geometryRefs = [];
150
484
  const relationshipRefs = [];
@@ -152,238 +486,142 @@ export class ColumnarParser {
152
486
  const propertyEntityRefs = [];
153
487
  const associationRelRefs = [];
154
488
  const typeObjectRefs = [];
489
+ const otherRelevantRefs = [];
155
490
  for (const ref of entityRefs) {
156
- // Categorize refs for targeted parsing
157
- const typeUpper = ref.type.toUpperCase();
158
- if (SPATIAL_TYPES.has(typeUpper)) {
159
- spatialRefs.push(ref);
491
+ // Build byType index
492
+ let typeList = byType.get(ref.type);
493
+ if (!typeList) {
494
+ typeList = [];
495
+ byType.set(ref.type, typeList);
160
496
  }
161
- else if (GEOMETRY_TYPES.has(typeUpper)) {
497
+ typeList.push(ref.expressId);
498
+ // Categorize (cached — .toUpperCase() called once per unique type)
499
+ const cat = getCategory(ref.type);
500
+ if (cat === CAT_SPATIAL)
501
+ spatialRefs.push(ref);
502
+ else if (cat === CAT_GEOMETRY)
162
503
  geometryRefs.push(ref);
163
- }
164
- else if (HIERARCHY_REL_TYPES.has(typeUpper)) {
504
+ else if (cat === CAT_HIERARCHY_REL)
165
505
  relationshipRefs.push(ref);
166
- }
167
- else if (PROPERTY_REL_TYPES.has(typeUpper)) {
506
+ else if (cat === CAT_PROPERTY_REL)
168
507
  propertyRelRefs.push(ref);
169
- }
170
- else if (PROPERTY_ENTITY_TYPES.has(typeUpper)) {
508
+ else if (cat === CAT_PROPERTY_ENTITY)
171
509
  propertyEntityRefs.push(ref);
172
- }
173
- else if (ASSOCIATION_REL_TYPES.has(typeUpper)) {
510
+ else if (cat === CAT_ASSOCIATION_REL)
174
511
  associationRelRefs.push(ref);
175
- }
176
- else if (isIfcTypeLikeEntity(typeUpper)) {
512
+ else if (cat === CAT_TYPE_OBJECT)
177
513
  typeObjectRefs.push(ref);
178
- }
514
+ else if (cat === CAT_RELEVANT)
515
+ otherRelevantRefs.push(ref);
179
516
  }
180
- // === TARGETED PARSING: Parse spatial and geometry entities for GlobalIds ===
181
- options.onProgress?.({ phase: 'parsing spatial', percent: 10 });
517
+ logPhase(`categorize ${totalEntities} spatial:${spatialRefs.length} geom:${geometryRefs.length} rel:${relationshipRefs.length} propRel:${propertyRelRefs.length} assocRel:${associationRelRefs.length} type:${typeObjectRefs.length} other:${otherRelevantRefs.length}`);
518
+ // Create entity table builder with EXACT capacity (not totalEntities which
519
+ // includes millions of geometry-representation entities we don't store).
520
+ // For a 14M entity file, this reduces allocation from ~546MB to ~20MB.
521
+ const relevantCount = spatialRefs.length + geometryRefs.length + typeObjectRefs.length
522
+ + relationshipRefs.length + otherRelevantRefs.length;
523
+ const entityTableBuilder = new EntityTableBuilder(relevantCount, strings);
524
+ const entityIndex = {
525
+ byId: compactByIdIndex,
526
+ byType,
527
+ };
528
+ // Time-based yielding: yield to the main thread every ~80ms so geometry
529
+ // streaming callbacks can fire. This limits main-thread blocking to short
530
+ // bursts that don't starve geometry, while adding minimal overhead (~15 yields
531
+ // × ~1ms each ≈ 15ms total over the full parse).
532
+ const YIELD_INTERVAL_MS = 80;
533
+ let lastYieldTime = performance.now();
534
+ const yieldIfNeeded = async () => {
535
+ const now = performance.now();
536
+ if (now - lastYieldTime >= YIELD_INTERVAL_MS) {
537
+ await new Promise(resolve => setTimeout(resolve, 0));
538
+ lastYieldTime = performance.now();
539
+ }
540
+ };
541
+ // === TARGETED PARSING using batch byte-level extraction ===
542
+ // Uses 2 TextDecoder.decode() calls total for ALL entity GlobalIds/Names
543
+ // (instead of per-entity calls), and pure byte scanning for relationships.
544
+ options.onProgress?.({ phase: 'parsing entities', percent: 10 });
182
545
  const extractor = new EntityExtractor(uint8Buffer);
546
+ // Spatial entities: small count, use extractEntity for full accuracy
183
547
  const parsedEntityData = new Map();
184
- // Parse spatial entities (typically < 100 entities)
185
548
  for (const ref of spatialRefs) {
186
549
  const entity = extractor.extractEntity(ref);
187
550
  if (entity) {
188
551
  const attrs = entity.attributes || [];
189
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
190
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
191
- parsedEntityData.set(ref.expressId, { globalId, name });
192
- }
193
- else {
194
- console.warn(`[ColumnarParser] Failed to extract spatial entity #${ref.expressId} (${ref.type})`);
195
- }
196
- }
197
- // Parse geometry entities for GlobalIds (needed for BCF component references)
198
- // IFC entities with geometry have GlobalId at attribute[0] and Name at attribute[2]
199
- options.onProgress?.({ phase: 'parsing geometry globalIds', percent: 12 });
200
- for (const ref of geometryRefs) {
201
- const entity = extractor.extractEntity(ref);
202
- if (entity) {
203
- const attrs = entity.attributes || [];
204
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
205
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
206
- parsedEntityData.set(ref.expressId, { globalId, name });
207
- }
208
- }
209
- // Parse type objects (IfcWallType, IfcDoorType, etc.) for GlobalId and Name
210
- // Type objects derive from IfcRoot: attrs[0]=GlobalId, attrs[2]=Name
211
- // Needed for IDS validation against type entities
212
- for (const ref of typeObjectRefs) {
213
- const entity = extractor.extractEntity(ref);
214
- if (entity) {
215
- const attrs = entity.attributes || [];
216
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
217
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
218
- parsedEntityData.set(ref.expressId, { globalId, name });
552
+ parsedEntityData.set(ref.expressId, {
553
+ globalId: typeof attrs[0] === 'string' ? attrs[0] : '',
554
+ name: typeof attrs[2] === 'string' ? attrs[2] : '',
555
+ });
219
556
  }
220
557
  }
221
- // Parse relationship entities (typically < 10k entities)
558
+ logPhase('spatial entities');
559
+ await yieldIfNeeded();
560
+ // Geometry + type object entities: batch extract GlobalId+Name with 2 TextDecoder calls
561
+ options.onProgress?.({ phase: 'parsing geometry names', percent: 12 });
562
+ const geomData = batchExtractGlobalIdAndName(uint8Buffer, geometryRefs);
563
+ for (const [id, data] of geomData)
564
+ parsedEntityData.set(id, data);
565
+ await yieldIfNeeded();
566
+ const typeData = batchExtractGlobalIdAndName(uint8Buffer, typeObjectRefs);
567
+ for (const [id, data] of typeData)
568
+ parsedEntityData.set(id, data);
569
+ logPhase('batch geom GlobalId+Name');
570
+ await yieldIfNeeded();
571
+ // Relationships: byte-level scanning (numbers only, no TextDecoder)
222
572
  options.onProgress?.({ phase: 'parsing relationships', percent: 20 });
223
- const relationships = [];
224
- for (const ref of relationshipRefs) {
225
- const entity = extractor.extractEntity(ref);
226
- if (entity) {
227
- const typeUpper = entity.type.toUpperCase();
228
- const rel = this.extractRelationshipFast(entity, typeUpper);
229
- if (rel) {
230
- relationships.push(rel);
231
- // Add to relationship graph
232
- const relType = REL_TYPE_MAP[typeUpper];
233
- if (relType) {
234
- for (const targetId of rel.relatedObjects) {
235
- relationshipGraphBuilder.addEdge(rel.relatingObject, targetId, relType, rel.relatingObject);
236
- }
237
- }
238
- }
239
- }
240
- }
241
- // === PARSE PROPERTY RELATIONSHIPS for on-demand loading ===
242
- options.onProgress?.({ phase: 'parsing property refs', percent: 25 });
243
- const onDemandPropertyMap = new Map();
244
- const onDemandQuantityMap = new Map();
245
- // Parse IfcRelDefinesByProperties to build entity -> pset/qset mapping
246
- // ALSO add to relationship graph so cache loads can rebuild on-demand maps
247
- for (const ref of propertyRelRefs) {
248
- const entity = extractor.extractEntity(ref);
249
- if (entity) {
250
- const attrs = entity.attributes || [];
251
- // IfcRelDefinesByProperties: relatedObjects at [4], relatingPropertyDefinition at [5]
252
- const relatedObjects = attrs[4];
253
- const relatingDef = attrs[5];
254
- if (typeof relatingDef === 'number' && Array.isArray(relatedObjects)) {
255
- // Add to relationship graph (needed for cache rebuild)
256
- for (const objId of relatedObjects) {
257
- if (typeof objId === 'number') {
258
- relationshipGraphBuilder.addEdge(relatingDef, objId, RelationshipType.DefinesByProperties, ref.expressId);
259
- }
260
- }
261
- // Find if the relating definition is a property set or quantity set
262
- const defRef = entityIndex.byId.get(relatingDef);
263
- if (defRef) {
264
- const defTypeUpper = defRef.type.toUpperCase();
265
- const isPropertySet = defTypeUpper === 'IFCPROPERTYSET';
266
- const isQuantitySet = defTypeUpper === 'IFCELEMENTQUANTITY';
267
- if (isPropertySet || isQuantitySet) {
268
- const targetMap = isPropertySet ? onDemandPropertyMap : onDemandQuantityMap;
269
- for (const objId of relatedObjects) {
270
- if (typeof objId === 'number') {
271
- let list = targetMap.get(objId);
272
- if (!list) {
273
- list = [];
274
- targetMap.set(objId, list);
275
- }
276
- list.push(relatingDef);
277
- }
278
- }
279
- }
280
- }
281
- }
573
+ // Use a toUpperCase cache across relationship refs (same type name set)
574
+ const typeUpperCache = new Map();
575
+ const getTypeUpper = (type) => {
576
+ let u = typeUpperCache.get(type);
577
+ if (u === undefined) {
578
+ u = type.toUpperCase();
579
+ typeUpperCache.set(type, u);
282
580
  }
283
- }
284
- // === PARSE ASSOCIATION RELATIONSHIPS for on-demand classification/material/document loading ===
285
- const onDemandClassificationMap = new Map();
286
- const onDemandMaterialMap = new Map();
287
- const onDemandDocumentMap = new Map();
288
- for (const ref of associationRelRefs) {
289
- const entity = extractor.extractEntity(ref);
290
- if (entity) {
291
- const attrs = entity.attributes || [];
292
- // IfcRelAssociates subtypes:
293
- // [0] GlobalId, [1] OwnerHistory, [2] Name, [3] Description
294
- // [4] RelatedObjects (list of element IDs)
295
- // [5] RelatingClassification / RelatingMaterial / RelatingDocument
296
- const relatedObjects = attrs[4];
297
- const relatingRef = attrs[5];
298
- if (typeof relatingRef === 'number' && Array.isArray(relatedObjects)) {
299
- const typeUpper = ref.type.toUpperCase();
300
- if (typeUpper === 'IFCRELASSOCIATESCLASSIFICATION') {
301
- for (const objId of relatedObjects) {
302
- if (typeof objId === 'number') {
303
- let list = onDemandClassificationMap.get(objId);
304
- if (!list) {
305
- list = [];
306
- onDemandClassificationMap.set(objId, list);
307
- }
308
- list.push(relatingRef);
309
- }
310
- }
311
- }
312
- else if (typeUpper === 'IFCRELASSOCIATESMATERIAL') {
313
- // IFC allows multiple IfcRelAssociatesMaterial per element but typically
314
- // only one is valid. Last-write-wins: later relationships override earlier ones.
315
- for (const objId of relatedObjects) {
316
- if (typeof objId === 'number') {
317
- onDemandMaterialMap.set(objId, relatingRef);
318
- }
319
- }
320
- }
321
- else if (typeUpper === 'IFCRELASSOCIATESDOCUMENT') {
322
- for (const objId of relatedObjects) {
323
- if (typeof objId === 'number') {
324
- let list = onDemandDocumentMap.get(objId);
325
- if (!list) {
326
- list = [];
327
- onDemandDocumentMap.set(objId, list);
328
- }
329
- list.push(relatingRef);
330
- }
331
- }
581
+ return u;
582
+ };
583
+ for (const ref of relationshipRefs) {
584
+ const typeUpper = getTypeUpper(ref.type);
585
+ const rel = extractRelFast(uint8Buffer, ref.byteOffset, ref.byteLength, typeUpper);
586
+ if (rel) {
587
+ const relType = REL_TYPE_MAP[typeUpper];
588
+ if (relType) {
589
+ for (const targetId of rel.relatedObjects) {
590
+ relationshipGraphBuilder.addEdge(rel.relatingObject, targetId, relType, ref.expressId);
332
591
  }
333
592
  }
334
593
  }
335
594
  }
336
- // === BUILD ENTITY TABLE with spatial data included ===
595
+ logPhase('byte-level relationships');
596
+ // === BUILD ENTITY TABLE from categorized arrays ===
597
+ // Instead of iterating ALL 4.4M entityRefs, iterate only categorized arrays
598
+ // (~100K-200K total). This eliminates a 200-300ms loop over 4.4M items.
337
599
  options.onProgress?.({ phase: 'building entities', percent: 30 });
338
- // OPTIMIZATION: Only add entities that are useful for the viewer UI
339
- // Skip geometric primitives like IFCCARTESIANPOINT, IFCDIRECTION, etc.
340
- // This reduces 4M+ entities to ~100K relevant ones
341
- const RELEVANT_ENTITY_PREFIXES = new Set([
342
- 'IFCWALL', 'IFCSLAB', 'IFCBEAM', 'IFCCOLUMN', 'IFCPLATE', 'IFCDOOR', 'IFCWINDOW',
343
- 'IFCROOF', 'IFCSTAIR', 'IFCRAILING', 'IFCRAMP', 'IFCFOOTING', 'IFCPILE',
344
- 'IFCMEMBER', 'IFCCURTAINWALL', 'IFCBUILDINGELEMENTPROXY', 'IFCFURNISHINGELEMENT',
345
- 'IFCFLOWSEGMENT', 'IFCFLOWTERMINAL', 'IFCFLOWCONTROLLER', 'IFCFLOWFITTING',
346
- 'IFCSPACE', 'IFCOPENINGELEMENT', 'IFCSITE', 'IFCBUILDING', 'IFCBUILDINGSTOREY',
347
- 'IFCPROJECT', 'IFCCOVERING', 'IFCANNOTATION', 'IFCGRID',
348
- ]);
349
- let processed = 0;
350
- let added = 0;
351
- for (const ref of entityRefs) {
352
- const typeUpper = ref.type.toUpperCase();
353
- // Skip non-relevant entities (geometric primitives, etc.)
354
- const hasGeometry = GEOMETRY_TYPES.has(typeUpper);
355
- const isType = isIfcTypeLikeEntity(typeUpper);
356
- const isSpatial = SPATIAL_TYPES.has(typeUpper);
357
- const isRelevant = hasGeometry || isType || isSpatial ||
358
- RELEVANT_ENTITY_PREFIXES.has(typeUpper) ||
359
- typeUpper.startsWith('IFCREL') || // Keep relationships for hierarchy
360
- onDemandPropertyMap.has(ref.expressId) || // Keep entities with properties
361
- onDemandQuantityMap.has(ref.expressId); // Keep entities with quantities
362
- if (!isRelevant) {
363
- processed++;
364
- continue;
365
- }
366
- // Get parsed data (GlobalId, Name) for spatial and geometry entities
367
- const entityData = parsedEntityData.get(ref.expressId);
368
- const globalId = entityData?.globalId || '';
369
- const name = entityData?.name || '';
370
- entityTableBuilder.add(ref.expressId, ref.type, globalId, name, '', // description
371
- '', // objectType
372
- hasGeometry, isType);
373
- added++;
374
- processed++;
375
- // Yield every 10000 entities for better interleaving with geometry streaming
376
- if (processed % 10000 === 0) {
377
- options.onProgress?.({ phase: 'building entities', percent: 30 + (processed / totalEntities) * 50 });
378
- // Direct yield - don't use maybeYield since we're already throttling
379
- await new Promise(resolve => setTimeout(resolve, 0));
600
+ // Helper to add entities with pre-parsed data
601
+ const addEntityBatch = (refs, hasGeometry, isType) => {
602
+ for (const ref of refs) {
603
+ const entityData = parsedEntityData.get(ref.expressId);
604
+ entityTableBuilder.add(ref.expressId, ref.type, entityData?.globalId || '', entityData?.name || '', '', // description
605
+ '', // objectType
606
+ hasGeometry, isType);
380
607
  }
381
- }
608
+ };
609
+ addEntityBatch(spatialRefs, false, false);
610
+ addEntityBatch(geometryRefs, true, false);
611
+ addEntityBatch(typeObjectRefs, false, true);
612
+ addEntityBatch(relationshipRefs, false, false);
613
+ addEntityBatch(otherRelevantRefs, false, false);
614
+ logPhase('add entity batches');
382
615
  const entityTable = entityTableBuilder.build();
616
+ logPhase('entity table build()');
383
617
  // Empty property/quantity tables - use on-demand extraction instead
384
618
  const propertyTable = propertyTableBuilder.build();
385
619
  const quantityTable = quantityTableBuilder.build();
386
- const relationshipGraph = relationshipGraphBuilder.build();
620
+ // Build intermediate relationship graph (spatial/hierarchy edges only).
621
+ // Property/association edges are added later; final graph is rebuilt at the end.
622
+ const hierarchyRelGraph = relationshipGraphBuilder.build();
623
+ logPhase('hierarchy rel graph build()');
624
+ await yieldIfNeeded();
387
625
  // === EXTRACT LENGTH UNIT SCALE ===
388
626
  options.onProgress?.({ phase: 'extracting units', percent: 85 });
389
627
  const lengthUnitScale = extractLengthUnitScale(uint8Buffer, entityIndex);
@@ -392,57 +630,134 @@ export class ColumnarParser {
392
630
  let spatialHierarchy;
393
631
  try {
394
632
  const hierarchyBuilder = new SpatialHierarchyBuilder();
395
- spatialHierarchy = hierarchyBuilder.build(entityTable, relationshipGraph, strings, uint8Buffer, entityIndex, lengthUnitScale);
633
+ spatialHierarchy = hierarchyBuilder.build(entityTable, hierarchyRelGraph, strings, uint8Buffer, entityIndex, lengthUnitScale);
634
+ logPhase('spatial hierarchy');
396
635
  }
397
636
  catch (error) {
398
637
  console.warn('[ColumnarParser] Failed to build spatial hierarchy:', error);
399
638
  }
400
- const parseTime = performance.now() - startTime;
401
- options.onProgress?.({ phase: 'complete', percent: 100 });
402
- return {
639
+ // === EMIT SPATIAL HIERARCHY EARLY ===
640
+ // The hierarchy panel can render immediately while property/association
641
+ // parsing continues. This lets the panel appear at the same time as
642
+ // geometry streaming completes.
643
+ const earlyStore = {
403
644
  fileSize: buffer.byteLength,
404
645
  schemaVersion,
405
646
  entityCount: totalEntities,
406
- parseTime,
647
+ parseTime: performance.now() - startTime,
407
648
  source: uint8Buffer,
408
649
  entityIndex,
409
650
  strings,
410
651
  entities: entityTable,
411
652
  properties: propertyTable,
412
653
  quantities: quantityTable,
413
- relationships: relationshipGraph,
654
+ relationships: hierarchyRelGraph,
414
655
  spatialHierarchy,
415
- onDemandPropertyMap, // For instant property access
416
- onDemandQuantityMap, // For instant quantity access
417
- onDemandClassificationMap, // For instant classification access
418
- onDemandMaterialMap, // For instant material access
419
- onDemandDocumentMap, // For instant document access
420
656
  };
421
- }
422
- /**
423
- * Fast relationship extraction - inline for performance
424
- */
425
- extractRelationshipFast(entity, typeUpper) {
426
- const attrs = entity.attributes;
427
- if (attrs.length < 6)
428
- return null;
429
- let relatingObject;
430
- let relatedObjects;
431
- if (typeUpper === 'IFCRELDEFINESBYPROPERTIES' || typeUpper === 'IFCRELDEFINESBYTYPE' || typeUpper === 'IFCRELCONTAINEDINSPATIALSTRUCTURE') {
432
- relatedObjects = attrs[4];
433
- relatingObject = attrs[5];
657
+ options.onSpatialReady?.(earlyStore);
658
+ await yieldIfNeeded(); // Let geometry process after hierarchy emission
659
+ // === DEFERRED: Parse property and association relationships ===
660
+ // These are NOT needed for the spatial hierarchy panel.
661
+ options.onProgress?.({ phase: 'parsing property refs', percent: 92 });
662
+ const onDemandPropertyMap = new Map();
663
+ const onDemandQuantityMap = new Map();
664
+ // Pre-build Sets of property set / quantity set IDs from already-categorized refs.
665
+ // This replaces 252K binary searches on the 14M compact entity index with O(1) Set lookups.
666
+ const propertySetIds = new Set();
667
+ const quantitySetIds = new Set();
668
+ for (const ref of propertyEntityRefs) {
669
+ const tu = getTypeUpper(ref.type);
670
+ if (tu === 'IFCPROPERTYSET')
671
+ propertySetIds.add(ref.expressId);
672
+ else if (tu === 'IFCELEMENTQUANTITY')
673
+ quantitySetIds.add(ref.expressId);
434
674
  }
435
- else {
436
- relatingObject = attrs[4];
437
- relatedObjects = attrs[5];
675
+ // Property rels: byte-level scanning + addEdge (now fast with SoA builder).
676
+ let totalPropRelObjects = 0;
677
+ for (let pi = 0; pi < propertyRelRefs.length; pi++) {
678
+ if ((pi & 0x3FF) === 0)
679
+ await yieldIfNeeded();
680
+ const ref = propertyRelRefs[pi];
681
+ const result = extractPropertyRelFast(uint8Buffer, ref.byteOffset, ref.byteLength);
682
+ if (result) {
683
+ const { relatedObjects, relatingDef } = result;
684
+ totalPropRelObjects += relatedObjects.length;
685
+ for (const objId of relatedObjects) {
686
+ relationshipGraphBuilder.addEdge(relatingDef, objId, RelationshipType.DefinesByProperties, ref.expressId);
687
+ }
688
+ // Build on-demand property/quantity maps using pre-built Sets (O(1) vs binary search)
689
+ const isPropSet = propertySetIds.has(relatingDef);
690
+ const isQtySet = !isPropSet && quantitySetIds.has(relatingDef);
691
+ if (isPropSet || isQtySet) {
692
+ const targetMap = isPropSet ? onDemandPropertyMap : onDemandQuantityMap;
693
+ for (const objId of relatedObjects) {
694
+ let list = targetMap.get(objId);
695
+ if (!list) {
696
+ list = [];
697
+ targetMap.set(objId, list);
698
+ }
699
+ list.push(relatingDef);
700
+ }
701
+ }
702
+ }
438
703
  }
439
- if (typeof relatingObject !== 'number' || !Array.isArray(relatedObjects)) {
440
- return null;
704
+ console.log(`[parseLite] propertyRels: ${propertyRelRefs.length} rels, ${totalPropRelObjects} total relatedObjects`);
705
+ await yieldIfNeeded();
706
+ // Association rels: byte-level scanning, no addEdge (same reasoning as property rels)
707
+ options.onProgress?.({ phase: 'parsing associations', percent: 95 });
708
+ const onDemandClassificationMap = new Map();
709
+ const onDemandMaterialMap = new Map();
710
+ const onDemandDocumentMap = new Map();
711
+ for (const ref of associationRelRefs) {
712
+ const result = extractPropertyRelFast(uint8Buffer, ref.byteOffset, ref.byteLength);
713
+ if (result) {
714
+ const { relatedObjects, relatingDef: relatingRef } = result;
715
+ const typeUpper = getTypeUpper(ref.type);
716
+ if (typeUpper === 'IFCRELASSOCIATESCLASSIFICATION') {
717
+ for (const objId of relatedObjects) {
718
+ let list = onDemandClassificationMap.get(objId);
719
+ if (!list) {
720
+ list = [];
721
+ onDemandClassificationMap.set(objId, list);
722
+ }
723
+ list.push(relatingRef);
724
+ relationshipGraphBuilder.addEdge(relatingRef, objId, RelationshipType.AssociatesClassification, ref.expressId);
725
+ }
726
+ }
727
+ else if (typeUpper === 'IFCRELASSOCIATESMATERIAL') {
728
+ for (const objId of relatedObjects) {
729
+ onDemandMaterialMap.set(objId, relatingRef);
730
+ relationshipGraphBuilder.addEdge(relatingRef, objId, RelationshipType.AssociatesMaterial, ref.expressId);
731
+ }
732
+ }
733
+ else if (typeUpper === 'IFCRELASSOCIATESDOCUMENT') {
734
+ for (const objId of relatedObjects) {
735
+ let list = onDemandDocumentMap.get(objId);
736
+ if (!list) {
737
+ list = [];
738
+ onDemandDocumentMap.set(objId, list);
739
+ }
740
+ list.push(relatingRef);
741
+ relationshipGraphBuilder.addEdge(relatingRef, objId, RelationshipType.AssociatesDocument, ref.expressId);
742
+ }
743
+ }
744
+ }
441
745
  }
746
+ logPhase('property+association rels');
747
+ // Rebuild relationship graph with ALL edges (hierarchy + property + association)
748
+ const fullRelationshipGraph = relationshipGraphBuilder.build();
749
+ logPhase('relationship graph build()');
750
+ const parseTime = performance.now() - startTime;
751
+ options.onProgress?.({ phase: 'complete', percent: 100 });
442
752
  return {
443
- type: entity.type,
444
- relatingObject,
445
- relatedObjects: relatedObjects.filter((id) => typeof id === 'number'),
753
+ ...earlyStore,
754
+ parseTime,
755
+ relationships: fullRelationshipGraph,
756
+ onDemandPropertyMap,
757
+ onDemandQuantityMap,
758
+ onDemandClassificationMap,
759
+ onDemandMaterialMap,
760
+ onDemandDocumentMap,
446
761
  };
447
762
  }
448
763
  /**