ifcviewer-web: complete the OPFS cache in the background

Filling only from the viewer's reads meant the cache converged on the
bytes the camera had needed — a user had to orbit every model into view
(unloading others to get there) before an entry could finish. Now a
filling entry fetches its uncovered spans in order, 8 MB at a time,
whenever the viewer has been quiet for 1.5 s, yielding the moment real
reads resume so interactive streaming always wins. A 42 MB model that
levelled off at 85% viewed now completes seconds after load with no
interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-24 10:57:48 +10:00
parent e5072460cd
commit 73f8e6aea7
+56 -14
View File
@@ -233,6 +233,7 @@
let broken = false; // a write failed (quota?): serve network, stop filling
let complete = spansCover(spans, 0, meta.size);
let writeChain = Promise.resolve();
let lastForegroundRead = 0; // performance.now() of the viewer's last read
const persistLedger = () => {
dirtySince = 0;
@@ -249,12 +250,66 @@
.catch(() => {});
};
const storeBytes = (start, stop, buf) => {
if (broken || complete || buf.byteLength !== stop - start) return;
const copy = buf.slice(0);
writeChain = writeChain
.then(() => cacheCall('write', name, { pos: start, data: copy }, [copy]))
.then(() => {
spans = spansAdd(spans, start, stop);
dirtySince += stop - start;
// Persist the ledger periodically — bytes on disk that the
// ledger does not record are merely re-fetched next visit.
if (dirtySince >= (4 << 20)) return persistLedger();
})
.then(finishIfComplete)
.catch(() => { broken = true; });
};
// Background completion: streaming only reads what the camera needs, so
// left alone the cache converges on the *viewed* bytes, not the file —
// and a user cannot be expected to orbit every model into view to
// finish it. Once the viewer has been quiet for a moment, fetch the
// uncovered spans in order, one modest range at a time, yielding
// whenever real reads resume so interactive streaming always wins.
const IDLE_MS = 1500, STEP_BYTES = 8 << 20;
let backgroundDone = false;
async function backgroundFill() {
while (!complete && !broken && !backgroundDone) {
if (performance.now() - lastForegroundRead < IDLE_MS) {
await new Promise((r) => setTimeout(r, IDLE_MS));
continue;
}
// First gap not yet covered.
let at = 0;
for (const [a, b] of spans) { if (a > at) break; at = Math.max(at, b); }
if (at >= meta.size) { finishIfComplete(); return; }
let stop = Math.min(at + STEP_BYTES, meta.size);
for (const [a] of spans) { if (a > at) { stop = Math.min(stop, a); break; } }
try {
const res = await fetch(url, {
headers: { Range: 'bytes=' + at + '-' + (stop - 1) },
});
if (res.status !== 206 && res.status !== 200) return; // server changed its mind
let buf = await res.arrayBuffer();
if (res.status === 200 && buf.byteLength > stop - at) buf = buf.slice(at, stop);
storeBytes(at, stop, buf);
await writeChain;
} catch (err) {
return; // offline etc: the foreground path is affected too, stop quietly
}
}
}
setTimeout(backgroundFill, IDLE_MS);
return {
size: meta.size,
stopBackgroundFill() { backgroundDone = true; },
slice(start, end) {
const stop = Math.min(end, meta.size);
return {
arrayBuffer: async () => {
lastForegroundRead = performance.now();
if (spansCover(spans, start, stop)) {
if (complete) {
const fh = await dir.getFileHandle(name);
@@ -277,20 +332,7 @@
if (res.status === 200 && buf.byteLength > stop - start) {
buf = buf.slice(start, stop);
}
if (!broken && !complete && buf.byteLength === stop - start) {
const copy = buf.slice(0);
writeChain = writeChain
.then(() => cacheCall('write', name, { pos: start, data: copy }, [copy]))
.then(() => {
spans = spansAdd(spans, start, stop);
dirtySince += stop - start;
// Persist the ledger periodically — bytes on disk that the
// ledger does not record are merely re-fetched next visit.
if (dirtySince >= (4 << 20)) return persistLedger();
})
.then(finishIfComplete)
.catch(() => { broken = true; });
}
storeBytes(start, stop, buf);
return buf;
},
};