@ifc-lite/parser 2.1.2 → 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.
@@ -96,10 +96,303 @@ const PROPERTY_ENTITY_TYPES = new Set([
96
96
  function isIfcTypeLikeEntity(typeUpper) {
97
97
  return typeUpper.endsWith('TYPE') || typeUpper.endsWith('STYLE');
98
98
  }
99
+ // ==========================================
100
+ // Byte-level helpers for fast extraction
101
+ // These avoid per-entity TextDecoder calls by working on raw bytes.
102
+ // ==========================================
99
103
  /**
100
- * Detect the IFC schema version from the STEP FILE_SCHEMA header.
101
- * 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)
102
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
+ }
103
396
  function detectSchemaVersion(buffer) {
104
397
  const headerEnd = Math.min(buffer.length, 2000);
105
398
  const headerText = new TextDecoder().decode(buffer.subarray(0, headerEnd)).toUpperCase();
@@ -125,32 +418,67 @@ export class ColumnarParser {
125
418
  const startTime = performance.now();
126
419
  const uint8Buffer = new Uint8Array(buffer);
127
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
+ };
128
428
  options.onProgress?.({ phase: 'building', percent: 0 });
129
429
  // Detect schema version from FILE_SCHEMA header
130
430
  const schemaVersion = detectSchemaVersion(uint8Buffer);
131
- // Initialize builders
431
+ // Initialize builders (entity table capacity set after categorization below)
132
432
  const strings = new StringTable();
133
- const entityTableBuilder = new EntityTableBuilder(totalEntities, strings);
134
433
  const propertyTableBuilder = new PropertyTableBuilder(strings);
135
434
  const quantityTableBuilder = new QuantityTableBuilder(strings);
136
435
  const relationshipGraphBuilder = new RelationshipGraphBuilder();
436
+ logPhase('init builders');
137
437
  // Build compact entity index (typed arrays instead of Map for ~3x memory reduction)
138
438
  const compactByIdIndex = buildCompactEntityIndex(entityRefs);
139
- // 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).
140
443
  const byType = new Map();
141
- for (const ref of entityRefs) {
142
- let typeList = byType.get(ref.type);
143
- if (!typeList) {
144
- typeList = [];
145
- byType.set(ref.type, typeList);
146
- }
147
- 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;
148
481
  }
149
- const entityIndex = {
150
- byId: compactByIdIndex,
151
- byType,
152
- };
153
- // First pass: collect spatial, geometry, relationship, property, and type refs for targeted parsing
154
482
  const spatialRefs = [];
155
483
  const geometryRefs = [];
156
484
  const relationshipRefs = [];
@@ -158,238 +486,142 @@ export class ColumnarParser {
158
486
  const propertyEntityRefs = [];
159
487
  const associationRelRefs = [];
160
488
  const typeObjectRefs = [];
489
+ const otherRelevantRefs = [];
161
490
  for (const ref of entityRefs) {
162
- // Categorize refs for targeted parsing
163
- const typeUpper = ref.type.toUpperCase();
164
- if (SPATIAL_TYPES.has(typeUpper)) {
165
- 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);
166
496
  }
167
- 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)
168
503
  geometryRefs.push(ref);
169
- }
170
- else if (HIERARCHY_REL_TYPES.has(typeUpper)) {
504
+ else if (cat === CAT_HIERARCHY_REL)
171
505
  relationshipRefs.push(ref);
172
- }
173
- else if (PROPERTY_REL_TYPES.has(typeUpper)) {
506
+ else if (cat === CAT_PROPERTY_REL)
174
507
  propertyRelRefs.push(ref);
175
- }
176
- else if (PROPERTY_ENTITY_TYPES.has(typeUpper)) {
508
+ else if (cat === CAT_PROPERTY_ENTITY)
177
509
  propertyEntityRefs.push(ref);
178
- }
179
- else if (ASSOCIATION_REL_TYPES.has(typeUpper)) {
510
+ else if (cat === CAT_ASSOCIATION_REL)
180
511
  associationRelRefs.push(ref);
181
- }
182
- else if (isIfcTypeLikeEntity(typeUpper)) {
512
+ else if (cat === CAT_TYPE_OBJECT)
183
513
  typeObjectRefs.push(ref);
184
- }
514
+ else if (cat === CAT_RELEVANT)
515
+ otherRelevantRefs.push(ref);
185
516
  }
186
- // === TARGETED PARSING: Parse spatial and geometry entities for GlobalIds ===
187
- 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 });
188
545
  const extractor = new EntityExtractor(uint8Buffer);
546
+ // Spatial entities: small count, use extractEntity for full accuracy
189
547
  const parsedEntityData = new Map();
190
- // Parse spatial entities (typically < 100 entities)
191
548
  for (const ref of spatialRefs) {
192
549
  const entity = extractor.extractEntity(ref);
193
550
  if (entity) {
194
551
  const attrs = entity.attributes || [];
195
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
196
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
197
- parsedEntityData.set(ref.expressId, { globalId, name });
198
- }
199
- else {
200
- console.warn(`[ColumnarParser] Failed to extract spatial entity #${ref.expressId} (${ref.type})`);
201
- }
202
- }
203
- // Parse geometry entities for GlobalIds (needed for BCF component references)
204
- // IFC entities with geometry have GlobalId at attribute[0] and Name at attribute[2]
205
- options.onProgress?.({ phase: 'parsing geometry globalIds', percent: 12 });
206
- for (const ref of geometryRefs) {
207
- const entity = extractor.extractEntity(ref);
208
- if (entity) {
209
- const attrs = entity.attributes || [];
210
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
211
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
212
- parsedEntityData.set(ref.expressId, { globalId, name });
213
- }
214
- }
215
- // Parse type objects (IfcWallType, IfcDoorType, etc.) for GlobalId and Name
216
- // Type objects derive from IfcRoot: attrs[0]=GlobalId, attrs[2]=Name
217
- // Needed for IDS validation against type entities
218
- for (const ref of typeObjectRefs) {
219
- const entity = extractor.extractEntity(ref);
220
- if (entity) {
221
- const attrs = entity.attributes || [];
222
- const globalId = typeof attrs[0] === 'string' ? attrs[0] : '';
223
- const name = typeof attrs[2] === 'string' ? attrs[2] : '';
224
- 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
+ });
225
556
  }
226
557
  }
227
- // 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)
228
572
  options.onProgress?.({ phase: 'parsing relationships', percent: 20 });
229
- const relationships = [];
230
- for (const ref of relationshipRefs) {
231
- const entity = extractor.extractEntity(ref);
232
- if (entity) {
233
- const typeUpper = entity.type.toUpperCase();
234
- const rel = this.extractRelationshipFast(entity, typeUpper);
235
- if (rel) {
236
- relationships.push(rel);
237
- // Add to relationship graph
238
- const relType = REL_TYPE_MAP[typeUpper];
239
- if (relType) {
240
- for (const targetId of rel.relatedObjects) {
241
- relationshipGraphBuilder.addEdge(rel.relatingObject, targetId, relType, rel.relatingObject);
242
- }
243
- }
244
- }
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);
245
580
  }
246
- }
247
- // === PARSE PROPERTY RELATIONSHIPS for on-demand loading ===
248
- options.onProgress?.({ phase: 'parsing property refs', percent: 25 });
249
- const onDemandPropertyMap = new Map();
250
- const onDemandQuantityMap = new Map();
251
- // Parse IfcRelDefinesByProperties to build entity -> pset/qset mapping
252
- // ALSO add to relationship graph so cache loads can rebuild on-demand maps
253
- for (const ref of propertyRelRefs) {
254
- const entity = extractor.extractEntity(ref);
255
- if (entity) {
256
- const attrs = entity.attributes || [];
257
- // IfcRelDefinesByProperties: relatedObjects at [4], relatingPropertyDefinition at [5]
258
- const relatedObjects = attrs[4];
259
- const relatingDef = attrs[5];
260
- if (typeof relatingDef === 'number' && Array.isArray(relatedObjects)) {
261
- // Add to relationship graph (needed for cache rebuild)
262
- for (const objId of relatedObjects) {
263
- if (typeof objId === 'number') {
264
- relationshipGraphBuilder.addEdge(relatingDef, objId, RelationshipType.DefinesByProperties, ref.expressId);
265
- }
266
- }
267
- // Find if the relating definition is a property set or quantity set
268
- const defRef = entityIndex.byId.get(relatingDef);
269
- if (defRef) {
270
- const defTypeUpper = defRef.type.toUpperCase();
271
- const isPropertySet = defTypeUpper === 'IFCPROPERTYSET';
272
- const isQuantitySet = defTypeUpper === 'IFCELEMENTQUANTITY';
273
- if (isPropertySet || isQuantitySet) {
274
- const targetMap = isPropertySet ? onDemandPropertyMap : onDemandQuantityMap;
275
- for (const objId of relatedObjects) {
276
- if (typeof objId === 'number') {
277
- let list = targetMap.get(objId);
278
- if (!list) {
279
- list = [];
280
- targetMap.set(objId, list);
281
- }
282
- list.push(relatingDef);
283
- }
284
- }
285
- }
286
- }
287
- }
288
- }
289
- }
290
- // === PARSE ASSOCIATION RELATIONSHIPS for on-demand classification/material/document loading ===
291
- const onDemandClassificationMap = new Map();
292
- const onDemandMaterialMap = new Map();
293
- const onDemandDocumentMap = new Map();
294
- for (const ref of associationRelRefs) {
295
- const entity = extractor.extractEntity(ref);
296
- if (entity) {
297
- const attrs = entity.attributes || [];
298
- // IfcRelAssociates subtypes:
299
- // [0] GlobalId, [1] OwnerHistory, [2] Name, [3] Description
300
- // [4] RelatedObjects (list of element IDs)
301
- // [5] RelatingClassification / RelatingMaterial / RelatingDocument
302
- const relatedObjects = attrs[4];
303
- const relatingRef = attrs[5];
304
- if (typeof relatingRef === 'number' && Array.isArray(relatedObjects)) {
305
- const typeUpper = ref.type.toUpperCase();
306
- if (typeUpper === 'IFCRELASSOCIATESCLASSIFICATION') {
307
- for (const objId of relatedObjects) {
308
- if (typeof objId === 'number') {
309
- let list = onDemandClassificationMap.get(objId);
310
- if (!list) {
311
- list = [];
312
- onDemandClassificationMap.set(objId, list);
313
- }
314
- list.push(relatingRef);
315
- }
316
- }
317
- }
318
- else if (typeUpper === 'IFCRELASSOCIATESMATERIAL') {
319
- // IFC allows multiple IfcRelAssociatesMaterial per element but typically
320
- // only one is valid. Last-write-wins: later relationships override earlier ones.
321
- for (const objId of relatedObjects) {
322
- if (typeof objId === 'number') {
323
- onDemandMaterialMap.set(objId, relatingRef);
324
- }
325
- }
326
- }
327
- else if (typeUpper === 'IFCRELASSOCIATESDOCUMENT') {
328
- for (const objId of relatedObjects) {
329
- if (typeof objId === 'number') {
330
- let list = onDemandDocumentMap.get(objId);
331
- if (!list) {
332
- list = [];
333
- onDemandDocumentMap.set(objId, list);
334
- }
335
- list.push(relatingRef);
336
- }
337
- }
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);
338
591
  }
339
592
  }
340
593
  }
341
594
  }
342
- // === 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.
343
599
  options.onProgress?.({ phase: 'building entities', percent: 30 });
344
- // OPTIMIZATION: Only add entities that are useful for the viewer UI
345
- // Skip geometric primitives like IFCCARTESIANPOINT, IFCDIRECTION, etc.
346
- // This reduces 4M+ entities to ~100K relevant ones
347
- const RELEVANT_ENTITY_PREFIXES = new Set([
348
- 'IFCWALL', 'IFCSLAB', 'IFCBEAM', 'IFCCOLUMN', 'IFCPLATE', 'IFCDOOR', 'IFCWINDOW',
349
- 'IFCROOF', 'IFCSTAIR', 'IFCRAILING', 'IFCRAMP', 'IFCFOOTING', 'IFCPILE',
350
- 'IFCMEMBER', 'IFCCURTAINWALL', 'IFCBUILDINGELEMENTPROXY', 'IFCFURNISHINGELEMENT',
351
- 'IFCFLOWSEGMENT', 'IFCFLOWTERMINAL', 'IFCFLOWCONTROLLER', 'IFCFLOWFITTING',
352
- 'IFCSPACE', 'IFCOPENINGELEMENT', 'IFCSITE', 'IFCBUILDING', 'IFCBUILDINGSTOREY',
353
- 'IFCPROJECT', 'IFCCOVERING', 'IFCANNOTATION', 'IFCGRID',
354
- ]);
355
- let processed = 0;
356
- let added = 0;
357
- for (const ref of entityRefs) {
358
- const typeUpper = ref.type.toUpperCase();
359
- // Skip non-relevant entities (geometric primitives, etc.)
360
- const hasGeometry = GEOMETRY_TYPES.has(typeUpper);
361
- const isType = isIfcTypeLikeEntity(typeUpper);
362
- const isSpatial = SPATIAL_TYPES.has(typeUpper);
363
- const isRelevant = hasGeometry || isType || isSpatial ||
364
- RELEVANT_ENTITY_PREFIXES.has(typeUpper) ||
365
- typeUpper.startsWith('IFCREL') || // Keep relationships for hierarchy
366
- onDemandPropertyMap.has(ref.expressId) || // Keep entities with properties
367
- onDemandQuantityMap.has(ref.expressId); // Keep entities with quantities
368
- if (!isRelevant) {
369
- processed++;
370
- continue;
371
- }
372
- // Get parsed data (GlobalId, Name) for spatial and geometry entities
373
- const entityData = parsedEntityData.get(ref.expressId);
374
- const globalId = entityData?.globalId || '';
375
- const name = entityData?.name || '';
376
- entityTableBuilder.add(ref.expressId, ref.type, globalId, name, '', // description
377
- '', // objectType
378
- hasGeometry, isType);
379
- added++;
380
- processed++;
381
- // Yield every 10000 entities for better interleaving with geometry streaming
382
- if (processed % 10000 === 0) {
383
- options.onProgress?.({ phase: 'building entities', percent: 30 + (processed / totalEntities) * 50 });
384
- // Direct yield - don't use maybeYield since we're already throttling
385
- 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);
386
607
  }
387
- }
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');
388
615
  const entityTable = entityTableBuilder.build();
616
+ logPhase('entity table build()');
389
617
  // Empty property/quantity tables - use on-demand extraction instead
390
618
  const propertyTable = propertyTableBuilder.build();
391
619
  const quantityTable = quantityTableBuilder.build();
392
- 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();
393
625
  // === EXTRACT LENGTH UNIT SCALE ===
394
626
  options.onProgress?.({ phase: 'extracting units', percent: 85 });
395
627
  const lengthUnitScale = extractLengthUnitScale(uint8Buffer, entityIndex);
@@ -398,82 +630,134 @@ export class ColumnarParser {
398
630
  let spatialHierarchy;
399
631
  try {
400
632
  const hierarchyBuilder = new SpatialHierarchyBuilder();
401
- spatialHierarchy = hierarchyBuilder.build(entityTable, relationshipGraph, strings, uint8Buffer, entityIndex, lengthUnitScale);
633
+ spatialHierarchy = hierarchyBuilder.build(entityTable, hierarchyRelGraph, strings, uint8Buffer, entityIndex, lengthUnitScale);
634
+ logPhase('spatial hierarchy');
402
635
  }
403
636
  catch (error) {
404
637
  console.warn('[ColumnarParser] Failed to build spatial hierarchy:', error);
405
638
  }
406
- const parseTime = performance.now() - startTime;
407
- options.onProgress?.({ phase: 'complete', percent: 100 });
408
- 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 = {
409
644
  fileSize: buffer.byteLength,
410
645
  schemaVersion,
411
646
  entityCount: totalEntities,
412
- parseTime,
647
+ parseTime: performance.now() - startTime,
413
648
  source: uint8Buffer,
414
649
  entityIndex,
415
650
  strings,
416
651
  entities: entityTable,
417
652
  properties: propertyTable,
418
653
  quantities: quantityTable,
419
- relationships: relationshipGraph,
654
+ relationships: hierarchyRelGraph,
420
655
  spatialHierarchy,
421
- onDemandPropertyMap, // For instant property access
422
- onDemandQuantityMap, // For instant quantity access
423
- onDemandClassificationMap, // For instant classification access
424
- onDemandMaterialMap, // For instant material access
425
- onDemandDocumentMap, // For instant document access
426
656
  };
427
- }
428
- /**
429
- * Fast relationship extraction - inline for performance
430
- */
431
- extractRelationshipFast(entity, typeUpper) {
432
- const attrs = entity.attributes;
433
- if (attrs.length < 6)
434
- return null;
435
- let relatingObject;
436
- let relatedObjects;
437
- if (typeUpper === 'IFCRELDEFINESBYPROPERTIES' || typeUpper === 'IFCRELDEFINESBYTYPE'
438
- || typeUpper === 'IFCRELCONTAINEDINSPATIALSTRUCTURE' || typeUpper === 'IFCRELREFERENCEDINSPATIALSTRUCTURE') {
439
- // [4]=RelatedObjects/RelatedElements, [5]=RelatingPropertyDef/Type/Structure
440
- relatedObjects = attrs[4];
441
- relatingObject = attrs[5];
442
- }
443
- else if (typeUpper === 'IFCRELASSIGNSTOGROUP' || typeUpper === 'IFCRELASSIGNSTOPRODUCT') {
444
- // IfcRelAssigns subtypes: [4]=RelatedObjects, [5]=RelatedObjectsType, [6]=RelatingGroup/Product
445
- relatedObjects = attrs[4];
446
- relatingObject = attrs.length > 6 ? attrs[6] : undefined;
447
- }
448
- else if (typeUpper === 'IFCRELCONNECTSELEMENTS' || typeUpper === 'IFCRELCONNECTSPATHELEMENTS') {
449
- // [4]=ConnectionGeometry, [5]=RelatingElement, [6]=RelatedElement
450
- relatingObject = attrs[5];
451
- relatedObjects = attrs.length > 6 ? attrs[6] : undefined;
452
- }
453
- else {
454
- // IfcRelAggregates, IfcRelVoidsElement, IfcRelFillsElement, IfcRelSpaceBoundary, etc.
455
- // [4]=RelatingObject, [5]=RelatedObject(s)
456
- relatingObject = attrs[4];
457
- relatedObjects = attrs[5];
458
- }
459
- if (typeof relatingObject !== 'number') {
460
- return null;
461
- }
462
- // Handle both 1-to-many (array) and 1-to-1 (single ref) relationships
463
- let relatedArray;
464
- if (Array.isArray(relatedObjects)) {
465
- relatedArray = relatedObjects.filter((id) => typeof id === 'number');
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);
466
674
  }
467
- else if (typeof relatedObjects === 'number') {
468
- relatedArray = [relatedObjects];
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
+ }
469
703
  }
470
- else {
471
- 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
+ }
472
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 });
473
752
  return {
474
- type: entity.type,
475
- relatingObject,
476
- relatedObjects: relatedArray,
753
+ ...earlyStore,
754
+ parseTime,
755
+ relationships: fullRelationshipGraph,
756
+ onDemandPropertyMap,
757
+ onDemandQuantityMap,
758
+ onDemandClassificationMap,
759
+ onDemandMaterialMap,
760
+ onDemandDocumentMap,
477
761
  };
478
762
  }
479
763
  /**