@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.
- package/dist/columnar-parser.d.ts +1 -4
- package/dist/columnar-parser.d.ts.map +1 -1
- package/dist/columnar-parser.js +561 -277
- package/dist/columnar-parser.js.map +1 -1
- package/dist/compact-entity-index.d.ts.map +1 -1
- package/dist/compact-entity-index.js +12 -5
- package/dist/compact-entity-index.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -13
- package/dist/index.js.map +1 -1
- package/dist/scan-worker-inline.d.ts +21 -0
- package/dist/scan-worker-inline.d.ts.map +1 -0
- package/dist/scan-worker-inline.js +239 -0
- package/dist/scan-worker-inline.js.map +1 -0
- package/package.json +2 -2
package/dist/columnar-parser.js
CHANGED
|
@@ -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
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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:
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
753
|
+
...earlyStore,
|
|
754
|
+
parseTime,
|
|
755
|
+
relationships: fullRelationshipGraph,
|
|
756
|
+
onDemandPropertyMap,
|
|
757
|
+
onDemandQuantityMap,
|
|
758
|
+
onDemandClassificationMap,
|
|
759
|
+
onDemandMaterialMap,
|
|
760
|
+
onDemandDocumentMap,
|
|
477
761
|
};
|
|
478
762
|
}
|
|
479
763
|
/**
|