// Only static scene assets enter this database. Live simulation/SCADA data do not. const DATABASE = 'tjwater-scene-assets-v1'; const STORE = 'resources'; const REQUEST_TIMEOUT_MS = 30_000; function openDatabase() { return new Promise(resolve => { let settled = false; const finish = value => { if (!settled) { settled = true; resolve(value); } }; try { const request = globalThis.indexedDB.open(DATABASE, 1); request.onupgradeneeded = () => request.result.createObjectStore(STORE, {keyPath:'url'}); request.onerror = request.onblocked = () => finish(null); request.onsuccess = () => { const database = request.result; if (settled) { database.close(); return; } database.onversionchange = () => database.close(); finish(database); }; } catch { finish(null); } }); } function transact(database, mode, operation) { return new Promise((resolve, reject) => { const transaction = database.transaction(STORE, mode); const request = operation(transaction.objectStore(STORE)); transaction.oncomplete = () => resolve(request.result); transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error); }); } export function createSceneResourceCache(baseUrl, onProgress = () => {}) { // Open lazily, after synchronous WebGL setup, so database callbacks can run promptly. let database; const pending = new Map(); async function read(file, revision, decode) { const url = new URL(file, baseUrl); const key = url.href; const report = (stage, loaded = 0, total = 0) => onProgress({file, stage, loaded, total}); const storage = await (database ??= openDatabase()); if (storage && revision) { try { const entry = await transact(storage, 'readonly', store => store.get(key)); if (entry?.revision === revision && entry.data instanceof ArrayBuffer) { report('cache', entry.data.byteLength, entry.data.byteLength); const result = await decode(entry.data); report('ready', entry.data.byteLength, entry.data.byteLength); return result; } } catch { /* Unavailable or invalid cache: fetch a fresh copy below. */ } } // Revisioned URLs also keep the browser's HTTP cache in sync with the manifest. if (revision) url.searchParams.set('v', revision); const controller = new AbortController(); let timer; const resetTimeout = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); }; report('download'); resetTimeout(); let data; try { const response = await fetch(url, {signal:controller.signal, cache:'no-cache'}); if (!response.ok) throw Error('HTTP ' + response.status); const total = Number(response.headers.get('content-length')) || 0; if (response.body) { const reader = response.body.getReader(); const chunks = []; let loaded = 0; while (true) { const {done, value} = await reader.read(); if (done) break; chunks.push(value); loaded += value.byteLength; report('download', loaded, total); resetTimeout(); } const bytes = new Uint8Array(loaded); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } data = bytes.buffer; } else { data = await response.arrayBuffer(); } } catch (error) { throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message)); } finally { clearTimeout(timer); } report('parse', data.byteLength, data.byteLength); // Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache. const result = await decode(data); if (storage && revision) { try { // One entry per URL: a successful new revision replaces the old bytes. await transact(storage, 'readwrite', store => store.put({url:key, revision, data})); } catch { /* Storage denied/full must not prevent scene rendering. */ } } report('ready', data.byteLength, data.byteLength); return result; } return { load(file, revision, decode = data => data) { const key = file + ':' + revision; if (!pending.has(key)) { const task = read(file, revision, decode).finally(() => pending.delete(key)); pending.set(key, task); } return pending.get(key); }, }; }