diff --git a/docs/scene-resource-cache.md b/docs/scene-resource-cache.md
new file mode 100644
index 0000000..fc00753
--- /dev/null
+++ b/docs/scene-resource-cache.md
@@ -0,0 +1,19 @@
+# 三维场景静态资源下载与缓存
+
+`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。
+
+- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。
+- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。
+- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。
+- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。
+- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。
+
+反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。
+
+聚焦回归:
+
+```bash
+E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1
+```
+
+该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。
diff --git a/e2e/three-dimensional-loading.spec.ts b/e2e/three-dimensional-loading.spec.ts
index d0040c6..73dcb1c 100644
--- a/e2e/three-dimensional-loading.spec.ts
+++ b/e2e/three-dimensional-loading.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
const root = "/three-dimensional/zjb/v29/";
-const preview = root + "preview.html?host=platform2&v=35-resource-cache";
+const preview = root + "preview.html?host=platform2&v=36-resumable-cache";
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
test.setTimeout(120_000);
@@ -28,7 +28,7 @@ test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试
expect(cached).toBe(false);
await page.getByRole("button", { name: "重新载入场景" }).click();
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
- expect(requests).toBe(2);
+ expect(requests).toBe(5);
});
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
@@ -46,7 +46,7 @@ test("模型和元数据跨页面复用持久缓存,即使服务器资源不
await page.goto(preview);
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
const coldMs = Date.now() - coldStart;
- expect(requests).toHaveLength(10);
+ expect(new Set(requests).size).toBe(10);
requests.length = 0;
block = true;
await page.close();
@@ -140,7 +140,7 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
await page.route("**/cache-probe.json*", (route) => {
requests++;
return requests === 1
- ? route.fulfill({ status: 503, body: "unavailable" })
+ ? route.fulfill({ status: 404, body: "not found" })
: requests === 2
? route.fulfill({ body: "invalid json" })
: route.fulfill({ json: { ok: true } });
@@ -160,6 +160,145 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复",
}, root);
expect(requests).toBe(3);
expect(result.errors).toHaveLength(2);
- expect(result.errors[0]).toContain("HTTP 503");
+ expect(result.errors[0]).toContain("HTTP 404");
expect(result.recovered).toEqual({ ok: true });
});
+
+test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => {
+ const { readFile } = await import("node:fs/promises");
+ const { createHash } = await import("node:crypto");
+ const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb");
+ const ranges: string[] = [];
+ let interrupted = false;
+ await page.route("**/models/detail_interior.glb*", async (route) => {
+ const range = route.request().headers().range;
+ ranges.push(range ?? "whole-file");
+ // The public proxy drops whole-file responses, and one partial request drops too.
+ if (!range) return route.abort("failed");
+ if (range.startsWith("bytes=1048576-") && !interrupted) {
+ interrupted = true;
+ return route.abort("failed");
+ }
+ const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
+ const start = Number(match[1]);
+ const end = Math.min(Number(match[2]), bytes.length - 1);
+ return route.fulfill({ status: 206, headers: {
+ "content-type": "model/gltf-binary",
+ "content-range": `bytes ${start}-${end}/${bytes.length}`,
+ }, body: bytes.subarray(start, end + 1) });
+ });
+ await page.goto(root + "manifest.json");
+ const result = await page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery");
+ const data = await load();
+ const cached = await load();
+ return { length: data.byteLength, cachedLength: cached.byteLength,
+ sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") };
+ }, root);
+ expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
+ expect(ranges).toHaveLength(7);
+ expect(ranges[0]).toBe("bytes=0-1048575");
+ expect(ranges[1]).toBe(ranges[2]);
+ expect(interrupted).toBe(true);
+});
+
+for (const fault of ["offset", "total", "truncated", "whole-response"] as const) {
+ test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => {
+ const bytes = Buffer.alloc(1048576 + 16, 7);
+ let broken = true;
+ let requests = 0;
+ await page.route("**/range-probe.bin*", (route) => {
+ requests++;
+ const range = route.request().headers().range;
+ if (!range) return route.abort("failed");
+ const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
+ const start = Number(match[1]);
+ const end = Math.min(Number(match[2]), bytes.length - 1);
+ if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes });
+ return route.fulfill({ status: 206, headers: {
+ "content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`,
+ }, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) });
+ });
+ await page.goto(root + "manifest.json");
+ const failed = await page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; }
+ catch { return true; }
+ }, root);
+ expect(failed).toBe(true);
+ const before = requests;
+ broken = false;
+ const length = await page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength;
+ }, root);
+ expect(length).toBe(bytes.length);
+ // The valid first segment survives; only the rejected final segment is fetched again.
+ expect(requests - before).toBe(1);
+ });
+}
+
+test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => {
+ const bytes = Buffer.alloc(2 * 1048576 + 16, 9);
+ let disconnected = true;
+ const ranges: string[] = [];
+ await page.route("**/resume-probe.bin*", (route) => {
+ const range = route.request().headers().range;
+ ranges.push(range ?? "whole-file");
+ if (!range) return route.abort("failed");
+ const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
+ const start = Number(match[1]);
+ const end = Math.min(Number(match[2]), bytes.length - 1);
+ if (disconnected && start > 0) return route.abort("failed");
+ return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) });
+ });
+ await page.goto(root + "manifest.json");
+ const load = (base: string) => page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ try {
+ const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first");
+ return { length: data.byteLength, failed: false };
+ } catch { return { failed: true }; }
+ }, base);
+ expect(await load(root)).toEqual({ failed: true });
+ expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3);
+ disconnected = false;
+ ranges.length = 0;
+ await page.reload();
+ expect(await load(root)).toEqual({ length: bytes.length, failed: false });
+ expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]);
+});
+
+test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => {
+ let requests = 0;
+ await page.route("**/retry-probe.json*", (route) => {
+ requests++;
+ return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } });
+ });
+ await page.goto(root + "manifest.json");
+ const result = await page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ const stages: string[] = [];
+ const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage));
+ const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
+ return { data, retries: stages.filter(stage => stage === "retry").length };
+ }, root);
+ expect(result).toEqual({ data: { ok: true }, retries: 2 });
+ expect(requests).toBe(3);
+});
+
+test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => {
+ let requests = 0;
+ await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); });
+ await page.goto(root + "manifest.json");
+ const failed = await page.evaluate(async (base) => {
+ const { createSceneResourceCache } = await import(base + "resourceCache.mjs");
+ try {
+ await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
+ return false;
+ } catch { return true; }
+ }, root);
+ expect(failed).toBe(true);
+ expect(requests).toBe(1);
+});
diff --git a/public/three-dimensional/zjb/v29/loadingProgress.mjs b/public/three-dimensional/zjb/v29/loadingProgress.mjs
index cda783d..99c27e1 100644
--- a/public/three-dimensional/zjb/v29/loadingProgress.mjs
+++ b/public/three-dimensional/zjb/v29/loadingProgress.mjs
@@ -32,12 +32,13 @@ export function reportProgress(update) {
const done = values.filter(item => item.stage === 'ready').length;
const loaded = values.reduce((sum, item) => sum + item.loaded, 0);
const cached = values.filter(item => item.stage === 'cache').length;
+ const retrying = values.some(item => item.stage === 'retry');
const parsing = values.some(item => item.stage === 'parse');
const downloading = values.some(item => item.stage === 'download');
progress.max = Math.max(1, values.length);
progress.value = done;
title.textContent = `正在载入场景资源 ${done} / ${values.length}`;
- detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
+ detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`;
}
export function finishLoading() {
diff --git a/public/three-dimensional/zjb/v29/preview.html b/public/three-dimensional/zjb/v29/preview.html
index 25b3050..6feacba 100644
--- a/public/three-dimensional/zjb/v29/preview.html
+++ b/public/three-dimensional/zjb/v29/preview.html
@@ -36,8 +36,8 @@