Commit 20b376f5 authored by Matthias Betz's avatar Matthias Betz
Browse files

add file loading orchestration with LOD reporting



Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 602aff35
// Loads a CityGML file, or the CityGML file inside a zip archive (see
// findCityGmlEntry), through the streaming parser. It knows neither the
// store nor the viewer; everything goes out through the callbacks:
// onBlock(block, getSummary) block of geometry (see createParser);
// getSummary() describes what was parsed so far
// onLod(lod) first block of a LOD (0..4 or null)
// onProgress(fraction) share of the file (or zip entry) read
// Resolves with { summary, sourceName }; rejects with the load error or an
// AbortError once options.signal aborts.
import { createParser, readFile, readStream } from './citygml.js';
import { listEntries, findCityGmlEntry, openEntry } from './zipreader.js';
// Zip archives start with a local file header ("PK\3\4").
export async function isZip(file) {
const buffer = await file.slice(0, 4).arrayBuffer();
return buffer.byteLength === 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50;
}
export async function loadFile(file, options = {}) {
const { onBlock = () => {}, onLod = () => {}, onProgress = () => {}, signal, chunkSize } = options;
const seenLods = new Set();
const getSummary = () => parser.summary();
const parser = createParser({
onBlock(block) {
if (!seenLods.has(block.lod)) {
seenLods.add(block.lod);
onLod(block.lod);
}
onBlock(block, getSummary);
},
});
signal?.throwIfAborted();
if (!(await isZip(file))) {
const summary = await readFile(file, parser, { signal, onProgress, chunkSize });
return { summary, sourceName: file.name };
}
// Only the zip's directory and the selected entry are read, decompressed as a stream.
const entry = findCityGmlEntry(await listEntries(file));
const stream = await openEntry(file, entry, { onProgress });
const summary = await readStream(stream, parser, { signal });
return { summary, sourceName: `${file.name}/${entry.name}` };
}
import test from 'node:test';
import assert from 'node:assert/strict';
import { makeZip } from './zip-util.js';
import { loadFile, isZip } from '../public/src/loading/loadFile.js';
function cityModel(body) {
return `<?xml version="1.0" encoding="UTF-8"?><core:CityModel>${body}</core:CityModel>`;
}
function square(x, z) {
const p = [[x, 0, z], [x + 1, 0, z], [x + 1, 1, z], [x, 1, z], [x, 0, z]].map(c => c.join(' ')).join(' ');
return `<gml:Polygon><gml:exterior><gml:LinearRing><gml:posList>${p}</gml:posList></gml:LinearRing></gml:exterior></gml:Polygon>`;
}
const lod2 = (...polys) => `<bldg:lod2MultiSurface><gml:MultiSurface>${polys.join('')}</gml:MultiSurface></bldg:lod2MultiSurface>`;
const XML = cityModel(lod2(...Array.from({ length: 30 }, (_, i) => square(i * 2, 0))));
const file = (data, name) => Object.assign(new Blob([data]), { name });
async function collect(f, options = {}) {
const lods = [];
const blocks = new Map();
const result = await loadFile(f, {
...options,
onLod: lod => lods.push(lod),
onBlock: block => blocks.set(block.id, block.count),
});
return { result, lods, vertices: [...blocks.values()].reduce((a, b) => a + b, 0) };
}
test('isZip recognises the zip signature', async () => {
assert.equal(await isZip(makeZip([{ name: 'a.gml', data: XML }])), true);
assert.equal(await isZip(new Blob([XML])), false);
assert.equal(await isZip(new Blob(['PK'])), false);
});
test('a plain file and the same file in a zip give the same geometry', async () => {
const plain = await collect(file(XML, 'city.gml'));
const zipped = await collect(file(makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'data/city.gml', data: XML }]), 'city.zip'));
assert.equal(plain.result.summary.vertexCount, 30 * 6);
assert.equal(zipped.result.summary.vertexCount, 30 * 6);
assert.equal(plain.vertices, 30 * 6);
assert.equal(zipped.vertices, 30 * 6);
assert.equal(plain.result.sourceName, 'city.gml');
assert.equal(zipped.result.sourceName, 'city.zip/data/city.gml');
});
test('each LOD is reported once although its block is delivered many times', async () => {
const blockCalls = [];
const lods = [];
await loadFile(file(XML, 'city.gml'), {
chunkSize: 256,
onLod: lod => lods.push(lod),
onBlock: block => blockCalls.push(block.id),
});
assert.ok(blockCalls.length > 1, 'fixture should deliver the block repeatedly');
assert.deepEqual(lods, [2]);
});
test('getSummary describes what was parsed so far', async () => {
let first = null;
await loadFile(file(XML, 'city.gml'), {
chunkSize: 256,
onBlock: (block, getSummary) => { if (first === null) first = getSummary(); },
});
assert.ok(first.vertexCount > 0 && first.vertexCount < 30 * 6, `vertexCount ${first.vertexCount}`);
});
test('a 7z archive is rejected with its message', async () => {
const sevenZip = new Uint8Array([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04]);
await assert.rejects(loadFile(file(sevenZip, 'a.7z')), /7z archive/);
});
test('a zip with two GML files is rejected with their names', async () => {
const zip = makeZip([{ name: 'a.gml', data: XML }, { name: 'b.gml', data: XML }]);
await assert.rejects(loadFile(file(zip, 'two.zip')), /a\.gml.*b\.gml/s);
});
test('an aborted load rejects with AbortError, plain and zipped', async () => {
for (const f of [file(XML, 'city.gml'), file(makeZip([{ name: 'a.gml', data: XML }]), 'a.zip')]) {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadFile(f, { signal: controller.signal }), { name: 'AbortError' });
}
});
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment