Skip to content

Commit a24b8cf

Browse files
authored
perf(parser): retain numeric columns through metadata and cache preparation (#3985) (#4012)
1 parent f2a9f5d commit a24b8cf

21 files changed

Lines changed: 921 additions & 306 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ifc-lite/parser": minor
3+
"@ifc-lite/cache": minor
4+
---
5+
6+
Expose borrowed compact entity columns to compatible consumers and reuse them when writing binary cache indexes. Preserve the existing binary layout, normalized type order, generic iterable inputs and borrowed-buffer ownership while avoiding reference-object reconstruction for valid compact indexes.

apps/viewer/src/hooks/useIfcLoader.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,13 +1464,7 @@ export function useIfcLoader() {
14641464
if (!useParserWorker || !sharedSource) {
14651465
return Promise.reject(new Error('parser worker disabled (no SAB / native file)'));
14661466
}
1467-
// NOTE: `deferPropertyAtomIndex` is not enabled here. The current
1468-
// implementation in `columnar-parser.ts` calls
1469-
// `entityRefs.filter(...)` to split property atoms out of the
1470-
// primary index, which costs more on a 14 M-entity file (~3 s
1471-
// for the filter pass) than the index-build time it saves.
1472-
// Re-enable once the categorization loop builds the two
1473-
// ref arrays inline so there is no second O(N) walk.
1467+
// Keep the existing non-deferred atom policy while qualifying source ownership.
14741468
const worker = new WorkerParser();
14751469
workerParserInstance = worker;
14761470
return worker.parseColumnar(sharedSource, {

docs/guide/parsing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,3 +678,9 @@ When working with multiple IFC files (e.g., architectural, structural, and MEP m
678678
- [Query Guide](querying.md) - Query parsed data
679679
- [Federation Guide](federation.md) - Load and coordinate multiple models
680680
- [API Reference](../api/typescript.md) - Complete API docs
681+
682+
### Borrowing compact entity columns
683+
684+
`CompactEntityIndex.getColumns()` exposes the four numeric backing arrays (`expressIds`, `byteOffsets`, `byteLengths`, `typeIndices`) and a copy of the `typeStrings` list. This supports column-aware consumers such as binary cache serialization without creating a reference object for every entity.
685+
686+
The numeric arrays are borrowed and must not be mutated. They remain valid until their owner detaches them. A transport may transfer the arrays when retiring the owning index; cache consumers must not detach them. Changing the returned string list does not change the index. Generic map-compatible indexes remain supported by the parser and cache interfaces.

packages/cache/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,10 @@ See the [API Reference](https://ifclite.dev/docs/api/typescript/#ifc-litecache).
124124
## License
125125

126126
[MPL-2.0](../../LICENSE)
127+
128+
129+
### Compact entity-index input
130+
131+
`CacheEntityIndex.byId` continues to accept an iterable of entity references. Column-aware producers may additionally implement `getColumns()`, returning `expressIds`, `byteOffsets`, `byteLengths`, `typeIndices` and `typeStrings` with the same rows as iteration. The parser's `CompactEntityIndex` supplies this method; the cache package does not require a parser runtime dependency.
132+
133+
Serialization borrows valid sorted columns synchronously, preserving stable duplicate order, normalized type names and first-row type-table order. It never mutates or detaches input backing. Unsupported column shapes use the iterable representation. Producers must keep borrowed columns stable during serialization and must not expose unrelated columns through this method. The existing binary format and generic iterable behavior are unchanged.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
import { existsSync, readFileSync } from 'node:fs';
5+
import { deepStrictEqual } from 'node:assert/strict';
6+
import { resolve } from 'node:path';
7+
import { describe, expect, it } from 'vitest';
8+
import { CompactEntityIndex, IfcParser } from '@ifc-lite/parser';
9+
import { writeEntityIndex, readEntityIndex } from './entity-index.js';
10+
import { BufferReader, BufferWriter } from '../utils/buffer-utils.js';
11+
import type { CacheEntityIndex, CacheEntityRef } from '../types.js';
12+
13+
function encode(byId: CacheEntityIndex['byId']): ArrayBuffer {
14+
const writer = new BufferWriter();
15+
writeEntityIndex(writer, { byId });
16+
return writer.build();
17+
}
18+
function iterableOnly(index: CompactEntityIndex): CacheEntityIndex['byId'] {
19+
return { [Symbol.iterator]: () => index[Symbol.iterator]() };
20+
}
21+
function checkOracle(index: CompactEntityIndex) {
22+
const before = [...index];
23+
const expected = encode(iterableOnly(index));
24+
const actual = encode(index);
25+
// #3985: exact section bytes ARE the binary-cache compatibility contract.
26+
// Native strict comparison keeps the 65536-type boundary oracle affordable
27+
// under the full parallel suite without shortening its byte/object coverage.
28+
deepStrictEqual(new Uint8Array(actual), new Uint8Array(expected));
29+
deepStrictEqual([...index], before);
30+
const restored = readEntityIndex(new BufferReader(actual));
31+
deepStrictEqual(restored, readEntityIndex(new BufferReader(expected)));
32+
return restored;
33+
}
34+
function compact(ids: number[], types: number[], names: string[]) {
35+
return new CompactEntityIndex(Uint32Array.from(ids),
36+
Uint32Array.from(ids.map((_, i) => i * 19)),
37+
Uint32Array.from(ids.map((_, i) => i + 7)), Uint16Array.from(types), names);
38+
}
39+
40+
describe('compact cache columns preserve iterable byte layout (#3985)', () => {
41+
it('keeps sparse/duplicate IDs, normalized first-row type order and source ownership', () => {
42+
const index = compact([0, 0, 2, 0xffffffff], [3, 1, 0, 2],
43+
['ifcwall', 'IFCWALL', 'Ifcſpace', 'IFCSPACE', 'UNREFERENCED']);
44+
const columns = index.getColumns();
45+
const beforeColumns = structuredClone(columns);
46+
const decoded = checkOracle(index);
47+
expect(decoded.ids).toEqual(new Uint32Array([0, 0, 2, 0xffffffff]));
48+
expect(decoded.typeNames).toEqual(['IFCSPACE', 'IFCWALL']);
49+
expect(decoded.typeIndices).toEqual(new Uint16Array([0, 1, 1, 0]));
50+
expect(columns).toEqual(beforeColumns);
51+
expect(index.get(0xffffffff)?.type).toBe('Ifcſpace');
52+
// String lists returned to transport/cache cannot rewrite source type names.
53+
columns.typeStrings[2] = 'MUTATED_CONSUMER_TABLE';
54+
expect([...index].at(-1)?.[1].type).toBe('Ifcſpace');
55+
});
56+
57+
it('keeps empty and unused-only type tables empty on the wire', () => {
58+
expect(checkOracle(compact([], [], ['unused'])).typeNames).toEqual([]);
59+
});
60+
61+
it('retains stable sorting when a public constructor supplies unsorted IDs', () => {
62+
const decoded = checkOracle(compact([9, 2, 9, 0], [0, 1, 2, 0], ['IfcWall', 'IfcSlab', 'IfcDoor']));
63+
expect(decoded.ids).toEqual(new Uint32Array([0, 2, 9, 9]));
64+
expect(decoded.byteOffsets).toEqual(new Uint32Array([57, 19, 0, 38]));
65+
expect(decoded.typeNames).toEqual(['IFCWALL', 'IFCSLAB', 'IFCDOOR']);
66+
});
67+
68+
it.each(['shortOffsets', 'longOffsets', 'shortLengths', 'longLengths', 'shortTypes', 'longTypes', 'badTypeIndex', 'nonStringType'])
69+
('preserves iterable coercion for unusual constructor input: %s', kind => {
70+
const ids = new Uint32Array([1, 2]);
71+
const offsets = new Uint32Array(kind === 'shortOffsets' ? [10] : kind === 'longOffsets' ? [10, 20, 30] : [10, 20]);
72+
const lengths = new Uint32Array(kind === 'shortLengths' ? [3] : kind === 'longLengths' ? [3, 4, 5] : [3, 4]);
73+
const types = new Uint16Array(kind === 'shortTypes' ? [0] : kind === 'longTypes' ? [0, 0, 0] : kind === 'badTypeIndex' ? [0, 17] : [0, 0]);
74+
const names = kind === 'nonStringType' ? [42] as unknown as string[] : ['IfcWall'];
75+
checkOracle(new CompactEntityIndex(ids, offsets, lengths, types, names));
76+
});
77+
78+
it('preserves all 65536 distinct referenced source type slots', () => {
79+
const n = 0x10000;
80+
const ids = Uint32Array.from({ length: n }, (_, i) => i);
81+
const types = Uint16Array.from({ length: n }, (_, i) => n - i - 1);
82+
const index = new CompactEntityIndex(ids, ids.slice(), ids.slice(), types,
83+
Array.from({ length: n }, (_, i) => `IfcVendor${i}`));
84+
const restored = checkOracle(index);
85+
expect(restored.typeNames.length).toBe(n);
86+
expect(restored.typeNames[0]).toBe('IFCVENDOR65535');
87+
expect(restored.typeNames[n - 1]).toBe('IFCVENDOR0');
88+
});
89+
90+
it('retains generic iterable overflow rejection at the 65537th unique normalized type', () => {
91+
const byId: CacheEntityIndex['byId'] = {
92+
*[Symbol.iterator](): IterableIterator<[number, CacheEntityRef]> {
93+
for (let id = 0; id <= 0x10000; id++) {
94+
yield [id, { expressId: id, type: `IfcVendor${id}`, byteOffset: id, byteLength: 1 }];
95+
}
96+
},
97+
};
98+
expect(() => encode(byId)).toThrow('more than 65535 unique IFC type names');
99+
});
100+
});
101+
102+
const realFixture = resolve(__dirname, '../../../../tests/models/ara3d/AC20-FZK-Haus.ifc');
103+
const hasFixture = existsSync(realFixture);
104+
if (!hasFixture) console.warn('skip compact cache real IFC oracle: run pnpm fixtures');
105+
it.skipIf(!hasFixture)('retains real Archicad cache section bytes and properties (#3985)', async () => {
106+
const source = Uint8Array.from(readFileSync(realFixture)).buffer;
107+
const parsed = await new IfcParser().parseColumnar(source, { disableWorkerScan: true });
108+
expect(parsed.entityIndex.byId).toBeInstanceOf(CompactEntityIndex);
109+
const index = parsed.entityIndex.byId as CompactEntityIndex;
110+
const propertyIds = [...parsed.onDemandPropertyMap!.keys()];
111+
expect(propertyIds.length).toBeGreaterThan(0);
112+
const sampleId = propertyIds[0];
113+
const before = parsed.getProperties(sampleId);
114+
expect(before.length).toBeGreaterThan(0);
115+
checkOracle(index);
116+
expect(parsed.getProperties(sampleId)).toEqual(before);
117+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
import type { CacheEntityIndex, CachedEntityIndexColumns } from '../types.js';
5+
6+
/** #3985: reuse validated numeric backing; retain generic iterable semantics. */
7+
export function prepareBorrowedEntityColumns(byId: CacheEntityIndex['byId']): CachedEntityIndexColumns | undefined {
8+
if (typeof byId.getColumns !== 'function') return undefined;
9+
const c = byId.getColumns();
10+
if (!(c.expressIds instanceof Uint32Array) || !(c.byteOffsets instanceof Uint32Array)
11+
|| !(c.byteLengths instanceof Uint32Array) || !(c.typeIndices instanceof Uint16Array)
12+
|| !Array.isArray(c.typeStrings)) return undefined;
13+
const n = c.expressIds.length;
14+
if (c.byteOffsets.length !== n || c.byteLengths.length !== n || c.typeIndices.length !== n) return undefined;
15+
const remap = new Int32Array(Math.min(c.typeStrings.length, 0x10000)).fill(-1);
16+
const seen = new Map<string, number>();
17+
const typeNames: string[] = [];
18+
const typeIndices = new Uint16Array(n);
19+
for (let row = 0; row < n; row++) {
20+
if (row && c.expressIds[row] < c.expressIds[row - 1]) return undefined;
21+
const rawTypeIndex = c.typeIndices[row];
22+
if (rawTypeIndex >= c.typeStrings.length || typeof c.typeStrings[rawTypeIndex] !== 'string') return undefined;
23+
let index = remap[rawTypeIndex];
24+
if (index === -1) {
25+
const name = c.typeStrings[rawTypeIndex].toUpperCase();
26+
let existing = seen.get(name);
27+
if (existing === undefined) {
28+
existing = typeNames.length;
29+
if (existing > 0xffff) throw new Error('Entity index has more than 65535 unique IFC type names');
30+
typeNames.push(name);
31+
seen.set(name, existing);
32+
}
33+
index = existing;
34+
remap[rawTypeIndex] = index;
35+
}
36+
typeIndices[row] = index;
37+
}
38+
return { ids: c.expressIds, byteOffsets: c.byteOffsets, byteLengths: c.byteLengths, typeIndices, typeNames };
39+
}

packages/cache/src/sections/entity-index.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44

55
import type { CacheEntityIndex, CacheEntityRef, CachedEntityIndexColumns } from '../types.js';
66
import { BufferReader, BufferWriter } from '../utils/buffer-utils.js';
7+
import { prepareBorrowedEntityColumns } from './entity-index-columns.js';
78

89
export function writeEntityIndex(writer: BufferWriter, entityIndex: CacheEntityIndex): void {
10+
const columns = prepareBorrowedEntityColumns(entityIndex.byId);
11+
if (columns) {
12+
writeColumns(writer, columns);
13+
return;
14+
}
915
const refs = Array.from(entityIndex.byId, ([id, ref]) => normalizeRef(id, ref))
1016
.sort((a, b) => a.expressId - b.expressId);
1117

@@ -34,15 +40,17 @@ export function writeEntityIndex(writer: BufferWriter, entityIndex: CacheEntityI
3440
typeIndices[i] = typeIndex;
3541
}
3642

37-
writer.writeUint32(refs.length);
38-
writer.writeUint32(typeNames.length);
39-
for (const typeName of typeNames) {
40-
writer.writeString(typeName);
41-
}
42-
writer.writeTypedArray(ids);
43-
writer.writeTypedArray(byteOffsets);
44-
writer.writeTypedArray(byteLengths);
45-
writer.writeTypedArray(typeIndices);
43+
writeColumns(writer, { ids, byteOffsets, byteLengths, typeIndices, typeNames });
44+
}
45+
46+
function writeColumns(writer: BufferWriter, columns: CachedEntityIndexColumns): void {
47+
writer.writeUint32(columns.ids.length);
48+
writer.writeUint32(columns.typeNames.length);
49+
for (const name of columns.typeNames) writer.writeString(name);
50+
writer.writeTypedArray(columns.ids);
51+
writer.writeTypedArray(columns.byteOffsets);
52+
writer.writeTypedArray(columns.byteLengths);
53+
writer.writeTypedArray(columns.typeIndices);
4654
}
4755

4856
export function readEntityIndex(reader: BufferReader): CachedEntityIndexColumns {

packages/cache/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,16 @@ export interface CacheEntityRef {
341341
}
342342

343343
export interface CacheEntityIndex {
344-
byId: Iterable<[number, CacheEntityRef]>;
344+
byId: Iterable<[number, CacheEntityRef]> & {
345+
/** Optional borrowed columns equivalent to iteration; never mutated/transferred. */
346+
getColumns?(): {
347+
expressIds: Uint32Array;
348+
byteOffsets: Uint32Array;
349+
byteLengths: Uint32Array;
350+
typeIndices: Uint16Array;
351+
typeStrings: string[];
352+
};
353+
};
345354
}
346355

347356
export interface CachedEntityIndexColumns {

0 commit comments

Comments
 (0)