|
| 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 | + |
| 5 | +/** |
| 6 | + * The full handoff the `@ifc-lite/cache` README's quickstart describes, driven |
| 7 | + * end to end against a REAL parse rather than a hand-built store: parse a STEP |
| 8 | + * source, adapt it with {@link toCacheDataStore}, write it, read it back, and |
| 9 | + * check the pieces a caller depends on survived. |
| 10 | + * |
| 11 | + * `@ifc-lite/parser` is a devDependency of this package for exactly this file. |
| 12 | + * The adapter is deliberately typed structurally (see {@link ParsedIfcStore}), |
| 13 | + * so nothing in `src/` imports the parser -- but a structural type that is |
| 14 | + * never fed the real thing is a claim, not a test. |
| 15 | + */ |
| 16 | + |
| 17 | +import { describe, it, expect } from 'vitest'; |
| 18 | +import { IfcParser, attachDataStoreAccessors, type IfcStoreData } from '@ifc-lite/parser'; |
| 19 | +import { toCacheDataStore } from './adapt.js'; |
| 20 | +import { BinaryCacheWriter } from './writer.js'; |
| 21 | +import { BinaryCacheReader } from './reader.js'; |
| 22 | +import { SchemaVersion } from './types.js'; |
| 23 | + |
| 24 | +/** Pad to the 22-char width of an IFC GlobalId. */ |
| 25 | +const gid = (seed: string): string => seed.padEnd(22, '0').slice(0, 22); |
| 26 | + |
| 27 | +function ifcSource(schema: 'IFC2X3' | 'IFC4' | 'IFC4X3'): string { |
| 28 | + return `ISO-10303-21; |
| 29 | +HEADER; |
| 30 | +FILE_DESCRIPTION((''),'2;1'); |
| 31 | +FILE_NAME('test.ifc','2026-01-01T00:00:00',(''),(''),'','',''); |
| 32 | +FILE_SCHEMA(('${schema}')); |
| 33 | +ENDSEC; |
| 34 | +DATA; |
| 35 | +#1=IFCCARTESIANPOINT((0.,0.,0.)); |
| 36 | +#2=IFCAXIS2PLACEMENT3D(#1,$,$); |
| 37 | +#3=IFCLOCALPLACEMENT($,#2); |
| 38 | +#4=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); |
| 39 | +#5=IFCUNITASSIGNMENT((#4)); |
| 40 | +#6=IFCPROJECT('${gid('Project')}',$,'Test Project',$,$,$,$,$,#5); |
| 41 | +#7=IFCSITE('${gid('Site')}',$,'Site',$,$,#3,$,$,.ELEMENT.,$,$,$,$,$); |
| 42 | +#8=IFCBUILDING('${gid('Building')}',$,'Building',$,$,#3,$,$,.ELEMENT.,$,$,$); |
| 43 | +#9=IFCBUILDINGSTOREY('${gid('Storey')}',$,'Ground Floor',$,$,#3,$,$,.ELEMENT.,0.); |
| 44 | +#10=IFCWALL('${gid('WallA')}',$,'Wall A',$,$,#3,$,$,$); |
| 45 | +#11=IFCWALL('${gid('WallB')}',$,'Wall B',$,$,#3,$,$,$); |
| 46 | +#12=IFCRELAGGREGATES('${gid('RelP2S')}',$,$,$,#6,(#7)); |
| 47 | +#13=IFCRELAGGREGATES('${gid('RelS2B')}',$,$,$,#7,(#8)); |
| 48 | +#14=IFCRELAGGREGATES('${gid('RelB2St')}',$,$,$,#8,(#9)); |
| 49 | +#15=IFCRELCONTAINEDINSPATIALSTRUCTURE('${gid('RelSt2E')}',$,$,$,(#10,#11),#9); |
| 50 | +ENDSEC; |
| 51 | +END-ISO-10303-21; |
| 52 | +`; |
| 53 | +} |
| 54 | + |
| 55 | +function toArrayBuffer(text: string): ArrayBuffer { |
| 56 | + const bytes = new TextEncoder().encode(text); |
| 57 | + const ab = new ArrayBuffer(bytes.byteLength); |
| 58 | + new Uint8Array(ab).set(bytes); |
| 59 | + return ab; |
| 60 | +} |
| 61 | + |
| 62 | +async function parse(schema: 'IFC2X3' | 'IFC4' | 'IFC4X3') { |
| 63 | + const sourceBuffer = toArrayBuffer(ifcSource(schema)); |
| 64 | + const parser = new IfcParser(); |
| 65 | + const store = await parser.parseColumnar(sourceBuffer, { disableWorkerScan: true }); |
| 66 | + return { store, sourceBuffer }; |
| 67 | +} |
| 68 | + |
| 69 | +async function roundTrip(schema: 'IFC2X3' | 'IFC4' | 'IFC4X3') { |
| 70 | + const { store, sourceBuffer } = await parse(schema); |
| 71 | + const cacheBuffer = await new BinaryCacheWriter().write( |
| 72 | + toCacheDataStore(store), |
| 73 | + undefined, |
| 74 | + sourceBuffer, |
| 75 | + { includeGeometry: false }, |
| 76 | + ); |
| 77 | + const result = await new BinaryCacheReader().read(cacheBuffer); |
| 78 | + return { store, sourceBuffer, result }; |
| 79 | +} |
| 80 | + |
| 81 | +describe('toCacheDataStore -> BinaryCacheWriter -> BinaryCacheReader round trip', () => { |
| 82 | + it('carries the entity index through the write, so a read-back cache can resolve entities from the retained source', async () => { |
| 83 | + const { store, sourceBuffer, result } = await roundTrip('IFC4'); |
| 84 | + |
| 85 | + // The adapter must not drop the index: EntityByIdIndex already iterates |
| 86 | + // [number, EntityRef] and EntityRef is assignable to CacheEntityRef. |
| 87 | + expect(result.entityIndex, 'entity-index section written and read back').toBeDefined(); |
| 88 | + |
| 89 | + // The index is a payload, not just a key set. Comparing the `ids` column |
| 90 | + // alone would still pass with every `type`, `byteOffset` and `byteLength` |
| 91 | + // corrupt, so compare each source EntityRef against its decoded row. |
| 92 | + const index = result.entityIndex!; |
| 93 | + const rowOf = (id: number, i: number) => ({ |
| 94 | + expressId: id, |
| 95 | + type: index.typeNames[index.typeIndices[i]], |
| 96 | + byteOffset: index.byteOffsets[i], |
| 97 | + byteLength: index.byteLengths[i], |
| 98 | + }); |
| 99 | + const byExpressId = (a: { expressId: number }, b: { expressId: number }) => |
| 100 | + a.expressId - b.expressId; |
| 101 | + |
| 102 | + const sourceRefs = [...store.entityIndex.byId] |
| 103 | + .map(([id, ref]) => ({ |
| 104 | + // writeEntityIndex normalizes exactly this way before encoding. |
| 105 | + expressId: ref.expressId || id, |
| 106 | + type: String(ref.type).toUpperCase(), |
| 107 | + byteOffset: ref.byteOffset, |
| 108 | + byteLength: ref.byteLength, |
| 109 | + })) |
| 110 | + .sort(byExpressId); |
| 111 | + const readRefs = [...index.ids].map(rowOf).sort(byExpressId); |
| 112 | + |
| 113 | + // Guard the comparison against being trivially satisfiable: an all-zero |
| 114 | + // payload on both sides would match without proving anything. |
| 115 | + expect(sourceRefs.length, 'the parse produced index rows to compare').toBeGreaterThan(0); |
| 116 | + expect( |
| 117 | + sourceRefs.every((ref) => ref.byteOffset > 0 && ref.byteLength > 0), |
| 118 | + 'source rows carry non-zero offsets and lengths', |
| 119 | + ).toBe(true); |
| 120 | + expect(readRefs, 'every entity-index row survives the round trip intact').toEqual(sourceRefs); |
| 121 | + |
| 122 | + // The README's remedy: retain the source, re-attach the lazy accessors on |
| 123 | + // read, and entity lookups work against the restored index. |
| 124 | + const byId = new Map( |
| 125 | + [...index.ids].map((id, i) => [id, { ...rowOf(id, i), lineNumber: 0 }]), |
| 126 | + ); |
| 127 | + const restored = attachDataStoreAccessors({ |
| 128 | + ...result.dataStore, |
| 129 | + schemaVersion: 'IFC4', |
| 130 | + source: new Uint8Array(sourceBuffer), |
| 131 | + entityIndex: { byId, byType: new Map<string, number[]>() }, |
| 132 | + } as unknown as IfcStoreData); |
| 133 | + |
| 134 | + const wall = restored.getEntity(10); |
| 135 | + expect(wall, 'entity #10 resolvable from the restored index + retained source').toBeTruthy(); |
| 136 | + expect(wall!.type.toUpperCase()).toBe('IFCWALL'); |
| 137 | + }); |
| 138 | + |
| 139 | + it('preserves schema, entityCount and the entity table across the round trip', async () => { |
| 140 | + const { store, result } = await roundTrip('IFC4'); |
| 141 | + |
| 142 | + expect(result.dataStore.schema).toBe(SchemaVersion.IFC4); |
| 143 | + expect(result.dataStore.entityCount).toBe(store.entityCount); |
| 144 | + expect(result.dataStore.entities.count).toBe(store.entities.count); |
| 145 | + expect(store.entities.count).toBeGreaterThan(0); |
| 146 | + }); |
| 147 | + |
| 148 | + it.each([ |
| 149 | + ['IFC2X3', SchemaVersion.IFC2X3], |
| 150 | + ['IFC4', SchemaVersion.IFC4], |
| 151 | + ['IFC4X3', SchemaVersion.IFC4X3], |
| 152 | + ] as const)('round-trips a %s source as its own SchemaVersion', async (schema, expected) => { |
| 153 | + const { result } = await roundTrip(schema); |
| 154 | + expect(result.dataStore.schema).toBe(expected); |
| 155 | + }); |
| 156 | + |
| 157 | + it('round-trips an IFC5 source as SchemaVersion.IFC2X3, the documented fallback (the binary format predates IFC5)', async () => { |
| 158 | + // No STEP file declares IFC5, so this exercises the adapter's documented |
| 159 | + // fallback directly on the store shape the adapter accepts. |
| 160 | + const { store, sourceBuffer } = await parse('IFC4'); |
| 161 | + const cacheBuffer = await new BinaryCacheWriter().write( |
| 162 | + toCacheDataStore({ ...store, schemaVersion: 'IFC5' }), |
| 163 | + undefined, |
| 164 | + sourceBuffer, |
| 165 | + { includeGeometry: false }, |
| 166 | + ); |
| 167 | + const result = await new BinaryCacheReader().read(cacheBuffer); |
| 168 | + expect(result.dataStore.schema).toBe(SchemaVersion.IFC2X3); |
| 169 | + }); |
| 170 | +}); |
0 commit comments