// 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; const CHUNK_BYTES = 1024 * 1024; const MAX_ATTEMPTS = 3; 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 partialKey = key + '#partial'; let chunks = [], loaded = 0, total = 0; if (storage && revision) { try { const partial = await transact(storage, 'readonly', store => store.get(partialKey)); if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 && Array.isArray(partial.chunks) && partial.chunks.length && partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) { const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; } } } catch { /* A denied partial cache does not prevent downloading. */ } } async function downloadPart() { const controller = new AbortController(); let timer; const resetTimeout = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); }; resetTimeout(); try { const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1; const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}}); if (!response.ok) { const error = Error('HTTP ' + response.status); error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status); throw error; } let partTotal = 0, expected = 0; if (response.status === 206) { const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? ''); const start = Number(range?.[1]), last = Number(range?.[2]); partTotal = Number(range?.[3]); if (!range || start !== loaded || last < start || last > end || last >= partTotal || !Number.isSafeInteger(partTotal) || (total && total !== partTotal)) { throw Error('资源分段响应无效'); } expected = last - start + 1; } else { if (loaded) throw Error('服务器未按断点返回资源'); // Servers without Range support can still return an ordinary complete response. partTotal = Number(response.headers.get('content-length')) || 0; } const buffers = []; let size = 0; if (response.body) { const reader = response.body.getReader(); try { while (true) { const {done, value} = await reader.read(); if (done) break; buffers.push(value); size += value.byteLength; if (expected && size > expected) throw Error('资源分段长度无效'); report('download', loaded + size, partTotal); resetTimeout(); } } finally { await reader.cancel().catch(() => {}); } } else { const buffer = new Uint8Array(await response.arrayBuffer()); buffers.push(buffer); size = buffer.byteLength; } if (expected && size !== expected) throw Error('资源分段不完整'); const bytes = new Uint8Array(size); let offset = 0; for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; } return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206}; } catch (error) { controller.abort(); throw error; } finally { clearTimeout(timer); } } report('download', loaded, total); try { while (!total || loaded < total) { let part; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { try { part = await downloadPart(); break; } catch (error) { // Permanent HTTP/contract errors stop; temporary transport/server errors can recover. if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error; if (attempt + 1 === MAX_ATTEMPTS) throw error; report('retry', loaded, total); await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1))); } } chunks.push(part.data); loaded += part.data.byteLength; total = part.total; if (storage && revision && part.ranged) { try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); } catch { /* Storage denied/full: retain the in-memory download. */ } } if (!part.ranged) break; } } catch (error) { throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message)); } const bytes = new Uint8Array(loaded); let offset = 0; for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; } const data = bytes.buffer; if (storage) { try { await transact(storage, 'readwrite', store => store.delete(partialKey)); } catch { /* Completed bytes can still be parsed when storage is unavailable. */ } } 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); }, }; }