mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-10-02 04:59:52 +00:00
ifcviewer-web: OPFS model cache behind addUrl(url, {cache: true})
Streaming a federation over the network re-downloads everything on every
visit: browsers do not populate their HTTP cache from ranged fetches
(measured at 0 of 78 range requests served from cache even with a strong
ETag). Host pages have started hand-rolling OPFS caches against the
library's own source seam — this is the second app to port the same ~350
lines — so the capability moves into the library.
The design keeps what those pages got right: the cache fills FROM THE
VIEWER'S OWN RANGED READS (no second download, and only bytes the camera
actually needed), entries are keyed by a hash of the URL and validated
by ETag (falling back to Last-Modified + size), and a byte-span ledger
guarantees a partial copy is never mistaken for a whole one. What it
fixes: writes go through a FileSystemSyncAccessHandle in an inline
worker — positional writes with no copy-on-open, where the pages'
createWritable({keepExistingData}) paid a whole-file copy per flush
(quadratic as the cache fills) and buffered up to 48 MB per model in JS
to compensate — the handle's exclusive lock makes a second tab fall back
to plain network instead of corrupting the entry; a complete copy now
opens when the server is unreachable (offline was dead before despite
the bytes being local); and entry names are hashes, where prefix-matched
sanitised names could delete a sibling model's cache.
viewer.cacheInfo() reports entries and the storage estimate;
viewer.clearCache(url?) drops one or all. Browsers without OPFS or sync
handles, servers without validators, and second tabs all degrade to
exactly today's network streaming.
Verified: the sample round-trips to zero range requests on reload, and a
42 MB model goes from 108 range requests to 5 on the second visit — the
85% the camera had viewed comes off disk, coverage honestly reports
incomplete for the bytes streaming never needed. The test server now
sends a content-hash ETag so the specs exercise real validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// The OPFS model cache behind addUrl(url, {cache: true}): the first load
|
||||||
|
// streams over HTTP Range and fills a local copy from those same reads; a
|
||||||
|
// reload of the page then loads the model with zero geometry traffic. One
|
||||||
|
// browser context spans both loads — OPFS is origin storage, so it survives
|
||||||
|
// page reloads within the context.
|
||||||
|
|
||||||
|
function watchRequests(page, counters) {
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (!req.url().includes('sample.ifcview')) return;
|
||||||
|
if (req.method() === 'HEAD') counters.head++;
|
||||||
|
else if (req.headers()['range']) counters.range++;
|
||||||
|
else counters.other++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScripting(page, errors) {
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
const t = msg.text();
|
||||||
|
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t);
|
||||||
|
});
|
||||||
|
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||||
|
await page.goto('/scripting.html');
|
||||||
|
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
|
||||||
|
{ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCachedSampleAndSettle(page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const v = window.viewer;
|
||||||
|
const loaded = new Promise((resolve) => {
|
||||||
|
const off = v.onModelLoaded((d) => { off(); resolve(d); });
|
||||||
|
});
|
||||||
|
await v.addUrl('/sample.ifcview', { replace: true, cache: true, name: 'cached' });
|
||||||
|
await loaded;
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const v = window.viewer;
|
||||||
|
if (!v.modelCount()) return false;
|
||||||
|
const p = v.modelProgress(0);
|
||||||
|
return p.total > 0 && p.resident === p.total;
|
||||||
|
}, null, { timeout: 30_000 });
|
||||||
|
// The element table is read through the same source — pull it so its
|
||||||
|
// ranges land in the cache too, then let the write chain drain.
|
||||||
|
await page.evaluate(() => window.viewer.getObjects());
|
||||||
|
await page.waitForFunction(async () => {
|
||||||
|
const info = await window.viewer.cacheInfo();
|
||||||
|
const e = info.entries.find((x) => x.url.endsWith('/sample.ifcview'));
|
||||||
|
return !!(e && e.complete);
|
||||||
|
}, null, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('first load fills the cache from its own reads; a reload streams nothing', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
const first = { head: 0, range: 0, other: 0 };
|
||||||
|
watchRequests(page, first);
|
||||||
|
await openScripting(page, errors);
|
||||||
|
await page.evaluate(() => window.viewer.clearCache());
|
||||||
|
|
||||||
|
await addCachedSampleAndSettle(page);
|
||||||
|
expect(first.range, 'first visit must stream over HTTP Range').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const info = await page.evaluate(() => window.viewer.cacheInfo());
|
||||||
|
const entry = info.entries.find((x) => x.url.endsWith('/sample.ifcview'));
|
||||||
|
expect(entry.complete).toBe(true);
|
||||||
|
expect(entry.cachedBytes).toBe(entry.size);
|
||||||
|
|
||||||
|
// Second visit: same context, fresh page. Only the HEAD validation may
|
||||||
|
// touch the network — every byte of geometry and metadata comes from OPFS.
|
||||||
|
await page.reload();
|
||||||
|
const second = { head: 0, range: 0, other: 0 };
|
||||||
|
watchRequests(page, second);
|
||||||
|
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
|
||||||
|
{ timeout: 30_000 });
|
||||||
|
await addCachedSampleAndSettle(page);
|
||||||
|
expect(second.range, 'a complete validated copy must stream zero ranges').toBe(0);
|
||||||
|
expect(second.other, 'and never download the file whole').toBe(0);
|
||||||
|
expect(second.head).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The cached model is actually usable: objects enumerate with GUIDs.
|
||||||
|
const objects = await page.evaluate(() => window.viewer.getObjects());
|
||||||
|
expect(objects.length).toBeGreaterThan(0);
|
||||||
|
expect(objects.some((o) => o.guid)).toBe(true);
|
||||||
|
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearCache drops the entry and the next load streams again', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
await openScripting(page, errors);
|
||||||
|
await addCachedSampleAndSettle(page);
|
||||||
|
|
||||||
|
const cleared = await page.evaluate(() => window.viewer.clearCache('/sample.ifcview'));
|
||||||
|
expect(cleared).toBe(1);
|
||||||
|
const info = await page.evaluate(() => window.viewer.cacheInfo());
|
||||||
|
expect(info.entries.find((x) => x.url.endsWith('/sample.ifcview'))).toBeUndefined();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
const counters = { head: 0, range: 0, other: 0 };
|
||||||
|
watchRequests(page, counters);
|
||||||
|
await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
|
||||||
|
{ timeout: 30_000 });
|
||||||
|
await addCachedSampleAndSettle(page);
|
||||||
|
expect(counters.range).toBeGreaterThan(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// Serve dir resolution: $WEB_BUILD_DIR if set, else the repo's build-web.
|
// Serve dir resolution: $WEB_BUILD_DIR if set, else the repo's build-web.
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ http.createServer(async (req, res) => {
|
|||||||
try { body = await readFile(inRoot); }
|
try { body = await readFile(inRoot); }
|
||||||
catch { body = await readFile(inSrc); } // fall back to the source dir
|
catch { body = await readFile(inSrc); } // fall back to the source dir
|
||||||
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
|
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
|
||||||
|
// A strong ETag from the content, so the OPFS cache spec can exercise
|
||||||
|
// validation exactly the way a real Accept-Ranges host would offer it.
|
||||||
|
const etag = '"' + createHash('sha1').update(body).digest('hex').slice(0, 16) + '"';
|
||||||
|
|
||||||
// HEAD: headers only — lets the remote backend resolve total size.
|
// HEAD: headers only — lets the remote backend resolve total size.
|
||||||
if (req.method === 'HEAD') {
|
if (req.method === 'HEAD') {
|
||||||
@@ -53,6 +57,7 @@ http.createServer(async (req, res) => {
|
|||||||
'Content-Type': ctype,
|
'Content-Type': ctype,
|
||||||
'Content-Length': body.length,
|
'Content-Length': body.length,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
|
'ETag': etag,
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
@@ -74,12 +79,13 @@ http.createServer(async (req, res) => {
|
|||||||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Length': slice.length,
|
'Content-Length': slice.length,
|
||||||
|
'ETag': etag,
|
||||||
});
|
});
|
||||||
res.end(slice);
|
res.end(slice);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes' });
|
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes', 'ETag': etag });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404).end('not found');
|
res.writeHead(404).end('not found');
|
||||||
|
|||||||
@@ -51,6 +51,307 @@
|
|||||||
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
|
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- OPFS model cache ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// addUrl(url, {cache: true}) keeps a local copy of the sidecar in the
|
||||||
|
// Origin Private File System, filled FROM THE VIEWER'S OWN RANGED READS —
|
||||||
|
// no second download, and only the bytes the camera actually needed.
|
||||||
|
// (Browsers do not populate their HTTP cache from ranged fetches: measured
|
||||||
|
// 0 of 78 range requests served from cache even with a strong ETag.)
|
||||||
|
//
|
||||||
|
// Entries are keyed by a hash of the URL and validated by ETag (falling
|
||||||
|
// back to Last-Modified + size); a byte-span ledger records which ranges
|
||||||
|
// are really on disk, so a partial copy is never mistaken for a whole one —
|
||||||
|
// a read is served locally only when its span is fully covered. On the next
|
||||||
|
// visit a complete validated copy loads with zero geometry traffic; if the
|
||||||
|
// server is unreachable, the newest complete copy is used as-is (offline).
|
||||||
|
//
|
||||||
|
// Writes go through a dedicated worker holding a FileSystemSyncAccessHandle:
|
||||||
|
// positional writes with no copy-on-open (createWritable({keepExistingData})
|
||||||
|
// copies the whole existing file into a swap file per open — quadratic as
|
||||||
|
// the cache fills), and the handle's exclusive lock makes a second tab fall
|
||||||
|
// back to plain network instead of corrupting the entry. Browsers without
|
||||||
|
// sync access handles just never cache — behaviour is exactly as without
|
||||||
|
// the flag.
|
||||||
|
const CACHE_DIR = 'ifcviewer-cache';
|
||||||
|
|
||||||
|
const cacheWorkerSource = `
|
||||||
|
const handles = new Map();
|
||||||
|
onmessage = async (e) => {
|
||||||
|
const { id, op, name, pos, data, len } = e.data;
|
||||||
|
const reply = (msg, transfer) => postMessage(Object.assign({ id }, msg), transfer || []);
|
||||||
|
try {
|
||||||
|
if (op === 'open') {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const dir = await root.getDirectoryHandle('${CACHE_DIR}', { create: true });
|
||||||
|
const fh = await dir.getFileHandle(name, { create: true });
|
||||||
|
handles.set(name, await fh.createSyncAccessHandle());
|
||||||
|
reply({ ok: true, size: handles.get(name).getSize() });
|
||||||
|
} else if (op === 'write') {
|
||||||
|
handles.get(name).write(new Uint8Array(data), { at: pos });
|
||||||
|
reply({ ok: true });
|
||||||
|
} else if (op === 'read') {
|
||||||
|
const buf = new Uint8Array(len);
|
||||||
|
const n = handles.get(name).read(buf, { at: pos });
|
||||||
|
reply({ ok: n === len, data: buf.buffer }, [buf.buffer]);
|
||||||
|
} else if (op === 'close') {
|
||||||
|
const h = handles.get(name);
|
||||||
|
if (h) { h.flush(); h.close(); handles.delete(name); }
|
||||||
|
reply({ ok: true });
|
||||||
|
} else {
|
||||||
|
reply({ ok: false, error: 'unknown op ' + op });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reply({ ok: false, error: String((err && err.message) || err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
let cacheWorker = null; // lazily created; false once known unusable
|
||||||
|
let cacheMsgId = 0;
|
||||||
|
const cachePending = new Map();
|
||||||
|
function cacheCall(op, name, extra, transfer) {
|
||||||
|
if (cacheWorker === false) return Promise.reject(new Error('no cache worker'));
|
||||||
|
if (!cacheWorker) {
|
||||||
|
try {
|
||||||
|
cacheWorker = new Worker(URL.createObjectURL(
|
||||||
|
new Blob([cacheWorkerSource], { type: 'text/javascript' })));
|
||||||
|
cacheWorker.onmessage = (e) => {
|
||||||
|
const pending = cachePending.get(e.data.id);
|
||||||
|
if (!pending) return;
|
||||||
|
cachePending.delete(e.data.id);
|
||||||
|
if (e.data.ok) pending.resolve(e.data);
|
||||||
|
else pending.reject(new Error(e.data.error || (op + ' failed')));
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
cacheWorker = false;
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const id = ++cacheMsgId;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
cachePending.set(id, { resolve: resolve, reject: reject });
|
||||||
|
cacheWorker.postMessage(Object.assign({ id: id, op: op, name: name }, extra || {}),
|
||||||
|
transfer || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheDirHandle(create) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
return root.getDirectoryHandle(CACHE_DIR, { create: !!create });
|
||||||
|
}
|
||||||
|
|
||||||
|
let persistAsked = false;
|
||||||
|
async function openCacheDir() {
|
||||||
|
try {
|
||||||
|
const dir = await cacheDirHandle(true);
|
||||||
|
// Without this a large cache is "best effort" and the browser may drop
|
||||||
|
// it under disk pressure — invisibly, looking like the site being slow
|
||||||
|
// again on the next visit.
|
||||||
|
if (!persistAsked && navigator.storage.persist) {
|
||||||
|
persistAsked = true;
|
||||||
|
navigator.storage.persist().catch(() => {});
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheEntryName(url) {
|
||||||
|
const bytes = new TextEncoder().encode(url);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest).slice(0, 16))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, merged, half-open [start, end) byte spans.
|
||||||
|
function spansAdd(spans, start, end) {
|
||||||
|
const out = [];
|
||||||
|
let s0 = start, e0 = end;
|
||||||
|
for (const [a, b] of spans) {
|
||||||
|
if (b < s0 || a > e0) out.push([a, b]);
|
||||||
|
else { s0 = Math.min(s0, a); e0 = Math.max(e0, b); }
|
||||||
|
}
|
||||||
|
out.push([s0, e0]);
|
||||||
|
out.sort((x, y) => x[0] - y[0]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const spansCover = (spans, start, end) =>
|
||||||
|
spans.some(([a, b]) => a <= start && b >= end);
|
||||||
|
const spansBytes = (spans) => spans.reduce((sum, [a, b]) => sum + (b - a), 0);
|
||||||
|
|
||||||
|
async function readCacheMeta(dir, name) {
|
||||||
|
try {
|
||||||
|
const fh = await dir.getFileHandle(name + '.meta');
|
||||||
|
return JSON.parse(await (await fh.getFile()).text());
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The meta file is tiny, so main-thread createWritable is fine here; only
|
||||||
|
// the tab holding the data file's exclusive lock ever writes it.
|
||||||
|
async function writeCacheMeta(dir, name, meta) {
|
||||||
|
const fh = await dir.getFileHandle(name + '.meta', { create: true });
|
||||||
|
const w = await fh.createWritable();
|
||||||
|
await w.write(JSON.stringify(meta));
|
||||||
|
await w.close();
|
||||||
|
}
|
||||||
|
async function removeCacheEntry(dir, name) {
|
||||||
|
await dir.removeEntry(name).catch(() => {});
|
||||||
|
await dir.removeEntry(name + '.meta').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function headValidators(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: 'HEAD' });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return {
|
||||||
|
etag: res.headers.get('ETag') || null,
|
||||||
|
lastModified: res.headers.get('Last-Modified') || null,
|
||||||
|
size: parseInt(res.headers.get('Content-Length') || '0', 10) || 0,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return null; // offline, or CORS refused HEAD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function validatorsMatch(meta, head) {
|
||||||
|
if (meta.etag && head.etag) return meta.etag === head.etag;
|
||||||
|
if (meta.lastModified && head.lastModified) {
|
||||||
|
return meta.lastModified === head.lastModified && meta.size === head.size;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Blob-shaped source (`size` + `slice(a, b).arrayBuffer()`) backed by the
|
||||||
|
// OPFS entry, filling from every ranged read that goes through it. The
|
||||||
|
// wasm's read shim only ever calls those two members, so this passes for
|
||||||
|
// the File it would get from a picked file.
|
||||||
|
function fillingCacheSource(dir, name, url, meta) {
|
||||||
|
let spans = meta.spans.slice();
|
||||||
|
let dirtySince = 0; // bytes written since the ledger was persisted
|
||||||
|
let broken = false; // a write failed (quota?): serve network, stop filling
|
||||||
|
let complete = spansCover(spans, 0, meta.size);
|
||||||
|
let writeChain = Promise.resolve();
|
||||||
|
|
||||||
|
const persistLedger = () => {
|
||||||
|
dirtySince = 0;
|
||||||
|
return writeCacheMeta(dir, name, Object.assign({}, meta, { spans: spans }))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
const finishIfComplete = () => {
|
||||||
|
if (complete || !spansCover(spans, 0, meta.size)) return;
|
||||||
|
complete = true;
|
||||||
|
// Flush + release the lock; from here reads come off the closed file.
|
||||||
|
writeChain = writeChain
|
||||||
|
.then(() => cacheCall('close', name))
|
||||||
|
.then(persistLedger)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: meta.size,
|
||||||
|
slice(start, end) {
|
||||||
|
const stop = Math.min(end, meta.size);
|
||||||
|
return {
|
||||||
|
arrayBuffer: async () => {
|
||||||
|
if (spansCover(spans, start, stop)) {
|
||||||
|
if (complete) {
|
||||||
|
const fh = await dir.getFileHandle(name);
|
||||||
|
return (await fh.getFile()).slice(start, stop).arrayBuffer();
|
||||||
|
}
|
||||||
|
// Serialise behind the writes so a just-written range is
|
||||||
|
// readable (sync-handle writes are visible to the same handle
|
||||||
|
// immediately; ordering through the chain keeps it simple).
|
||||||
|
return (writeChain = writeChain.then(() =>
|
||||||
|
cacheCall('read', name, { pos: start, len: stop - start })
|
||||||
|
)).then((r) => r.data);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Range: 'bytes=' + start + '-' + (stop - 1) },
|
||||||
|
});
|
||||||
|
if (res.status !== 206 && res.status !== 200) {
|
||||||
|
throw new Error('range fetch failed: ' + res.status);
|
||||||
|
}
|
||||||
|
let buf = await res.arrayBuffer();
|
||||||
|
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; });
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide what to hand the loader for a cached URL:
|
||||||
|
// {file} — a complete validated local copy (zero network)
|
||||||
|
// {source} — a Blob-shaped self-filling source
|
||||||
|
// null — cache unusable here (no OPFS / no validators / second tab):
|
||||||
|
// caller falls back to the plain URL path.
|
||||||
|
async function cachedUrlSource(url) {
|
||||||
|
if (!(crypto && crypto.subtle) || !(navigator.storage && navigator.storage.getDirectory)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const head = await headValidators(url);
|
||||||
|
const dir = await openCacheDir();
|
||||||
|
if (!dir) return null;
|
||||||
|
const name = await cacheEntryName(url);
|
||||||
|
const meta = await readCacheMeta(dir, name);
|
||||||
|
|
||||||
|
const completeLocal = async (m) => {
|
||||||
|
const fh = await dir.getFileHandle(name);
|
||||||
|
const file = await fh.getFile();
|
||||||
|
return file.size === m.size ? { file: file } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!head) {
|
||||||
|
// Offline (or HEAD refused): a complete copy is better than nothing —
|
||||||
|
// this is the offline story. Anything less falls back to the URL path,
|
||||||
|
// which will fail the same way it always did.
|
||||||
|
if (meta && spansCover(meta.spans, 0, meta.size)) return completeLocal(meta);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!head.etag && !head.lastModified) return null; // nothing to validate by
|
||||||
|
if (!head.size) return null;
|
||||||
|
|
||||||
|
if (meta && validatorsMatch(meta, head)) {
|
||||||
|
if (spansCover(meta.spans, 0, meta.size)) {
|
||||||
|
const local = await completeLocal(meta);
|
||||||
|
if (local) return local;
|
||||||
|
}
|
||||||
|
// Partial copy of the still-current file: resume filling it.
|
||||||
|
} else if (meta) {
|
||||||
|
await removeCacheEntry(dir, name); // server has a different file now
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = (!meta || !validatorsMatch(meta, head))
|
||||||
|
? { url: url, etag: head.etag, lastModified: head.lastModified,
|
||||||
|
size: head.size, spans: [] }
|
||||||
|
: meta;
|
||||||
|
try {
|
||||||
|
await cacheCall('open', name); // exclusive: a second tab lands in catch
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await writeCacheMeta(dir, name, fresh).catch(() => {});
|
||||||
|
return { source: fillingCacheSource(dir, name, url, fresh) };
|
||||||
|
}
|
||||||
|
|
||||||
// Mouse navigation schemes the wasm's classifyPress understands. Named so a
|
// Mouse navigation schemes the wasm's classifyPress understands. Named so a
|
||||||
// typo is an error here rather than a silent fall-back to blender in the core.
|
// typo is an error here rather than a silent fall-back to blender in the core.
|
||||||
const NAV_PRESETS = ['blender', 'rhino', 'revit', 'web'];
|
const NAV_PRESETS = ['blender', 'rhino', 'revit', 'web'];
|
||||||
@@ -593,14 +894,79 @@
|
|||||||
Module._load_sidecar_from_source_c(sid);
|
Module._load_sidecar_from_source_c(sid);
|
||||||
return sid;
|
return sid;
|
||||||
},
|
},
|
||||||
|
// `cache: true` keeps a local OPFS copy filled from the viewer's own
|
||||||
|
// ranged reads (see the OPFS model cache section above): the next visit
|
||||||
|
// loads it with zero geometry traffic, and a complete copy still opens
|
||||||
|
// when the server is unreachable. Falls back to plain URL streaming
|
||||||
|
// wherever the cache cannot help (no OPFS, no validators from the
|
||||||
|
// server, another tab already filling this entry).
|
||||||
addUrl: async function (url, o) {
|
addUrl: async function (url, o) {
|
||||||
if (o && o.replace) this.clearScene();
|
if (o && o.replace) this.clearScene();
|
||||||
const sid = await registerUrl(url);
|
let sid = null;
|
||||||
|
if (o && o.cache) {
|
||||||
|
const cached = await cachedUrlSource(url).catch(() => null);
|
||||||
|
if (cached) {
|
||||||
|
const src = cached.file || cached.source;
|
||||||
|
sid = Module.__ifcvSources.length;
|
||||||
|
Module.__ifcvSources.push({ file: src, url: null, size: src.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sid === null) sid = await registerUrl(url);
|
||||||
if (o && o.name) this.setModelName(sid, o.name);
|
if (o && o.name) this.setModelName(sid, o.name);
|
||||||
Module._load_sidecar_from_source_c(sid);
|
Module._load_sidecar_from_source_c(sid);
|
||||||
return sid;
|
return sid;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// What the OPFS cache holds: [{url, size, cachedBytes, complete}] plus
|
||||||
|
// the browser's storage estimate. Entries whose ledger has not caught
|
||||||
|
// up with the last few reads under-report slightly; nothing over-reports.
|
||||||
|
cacheInfo: async function () {
|
||||||
|
const out = { entries: [], estimate: null };
|
||||||
|
try {
|
||||||
|
const dir = await cacheDirHandle(false);
|
||||||
|
for await (const key of dir.keys()) {
|
||||||
|
if (!key.endsWith('.meta')) continue;
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(await (await
|
||||||
|
(await dir.getFileHandle(key)).getFile()).text());
|
||||||
|
out.entries.push({
|
||||||
|
url: meta.url,
|
||||||
|
size: meta.size,
|
||||||
|
cachedBytes: spansBytes(meta.spans),
|
||||||
|
complete: spansCover(meta.spans, 0, meta.size),
|
||||||
|
});
|
||||||
|
} catch (err) { /* torn meta: skip */ }
|
||||||
|
}
|
||||||
|
} catch (err) { /* no cache dir yet */ }
|
||||||
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
|
out.estimate = await navigator.storage.estimate().catch(() => null);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drop cached models — one URL, or everything. Sources already handed
|
||||||
|
// to the viewer keep working (open handles and Files stay readable);
|
||||||
|
// the next visit simply streams from the network again.
|
||||||
|
clearCache: async function (url) {
|
||||||
|
try {
|
||||||
|
const dir = await cacheDirHandle(false);
|
||||||
|
const only = url ? await cacheEntryName(url) : null;
|
||||||
|
const names = [];
|
||||||
|
for await (const key of dir.keys()) {
|
||||||
|
const base = key.endsWith('.meta') ? key.slice(0, -5) : key;
|
||||||
|
if (!only || base === only) names.push(key);
|
||||||
|
}
|
||||||
|
let n = 0;
|
||||||
|
for (const key of names) {
|
||||||
|
await dir.removeEntry(key).catch(() => {});
|
||||||
|
if (!key.endsWith('.meta')) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
} catch (err) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Federation ------------------------------------------------------
|
// ---- Federation ------------------------------------------------------
|
||||||
//
|
//
|
||||||
// The concepts an .ifcfed file carries, without the file format: a
|
// The concepts an .ifcfed file carries, without the file format: a
|
||||||
|
|||||||
Reference in New Issue
Block a user