@ifc-lite/parser 2.1.2 → 2.1.4

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