import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { deleteMeshCache, meshCacheKey, readMeshCache, writeMeshCache } from "./mesh-cache"; import { RECOVERY_BOUNDARIES, RecoveryInterruption, runRecoveryTransaction, type RecoveryBoundary } from "./recovery"; import { deleteRawStep, isOpfsSupported, listRawSteps, readRawStep, writeRawStep } from "./storage"; import type { MeshPayload, WorkerResponse } from "./worker-protocol"; import { DEFAULT_VIEW_PROVIDER_STATE, normalizeViewProviderState, type ViewProviderDisplayState } from "./viewprovider-runtime.js"; import { RenderObjectRegistry } from "./render-object-registry.js"; import { installNavigation, readNavigationContext } from "./navigation.js"; import { detectBrowserCapabilities, downloadBytes } from "./app-services.js"; import { installUiAccessibility } from "./ui-accessibility.js"; declare global { interface Window { __freecadBrowserCapabilities?: ReturnType; __freecadViewState?: Record; } } const root = document.querySelector("#app"); if (!root) { throw new Error("FreeCAD 1.2 Web 根节点不存在"); } installNavigation("home", readNavigationContext()); const accessibility = installUiAccessibility(); window.__freecadBrowserCapabilities = detectBrowserCapabilities(); const urlParams = new URLSearchParams(window.location.search); const storageRequested = urlParams.get("storage") === "1"; const cacheRequested = urlParams.get("cache") === "1"; root.innerHTML = `
FCFreeCAD 1.2 Web
`; const style = document.createElement("style"); style.textContent = ` :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #10161d; color: #e7edf3; } * { box-sizing: border-box; } body { margin: 0; min-width: 320px; min-height: 100vh; background: #10161d; } button { border: 1px solid #435464; border-radius: 6px; background: #1d2a35; color: #e7edf3; cursor: pointer; font: inherit; padding: 8px 12px; } button:hover:not(:disabled) { border-color: #66b7c8; background: #243b47; } button:disabled { cursor: default; opacity: .45; } .app-shell { display: flex; min-height: 100vh; flex-direction: column; } .topbar { align-items: center; background: #17212b; border-bottom: 1px solid #2c3b48; display: flex; gap: 16px; justify-content: space-between; padding: 12px 16px; } .brand, .actions { align-items: center; display: flex; gap: 10px; } .brand { font-size: 15px; font-weight: 650; letter-spacing: 0; } .brand-mark { align-items: center; background: #3b9db0; border-radius: 5px; color: #071219; display: inline-flex; font-size: 12px; font-weight: 800; height: 28px; justify-content: center; width: 28px; } .workspace { display: grid; flex: 1; grid-template-columns: minmax(0, 1fr) 280px; min-height: 0; } .viewport { background: #0d1319; min-height: 420px; position: relative; } .viewport canvas { display: block; height: 100%; width: 100%; } .inspector { background: #17212b; border-left: 1px solid #2c3b48; padding: 18px 16px; } .inspector-heading { color: #9cb6c2; font-size: 12px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } .stats { margin: 16px 0 0; } .stats div { border-bottom: 1px solid #2c3b48; display: flex; gap: 12px; justify-content: space-between; padding: 10px 0; } dt { color: #8ea2ae; font-size: 12px; } dd { font-size: 12px; margin: 0; max-width: 170px; overflow-wrap: anywhere; text-align: right; } .status { color: #9cb6c2; font-size: 12px; line-height: 1.5; margin-top: 18px; } .status[data-level="error"] { color: #ff9e9e; } .view-tools { display: grid; gap: 6px; grid-template-columns: repeat(3, 1fr); margin-top: 16px; } .tree-item { align-items: center; display: flex; gap: 8px; justify-content: flex-start; margin-top: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; } .tree-item[aria-selected="true"] { border-color: #66c7d5; background: #264b56; } .object-tree { border-top: 1px solid #2c3b48; margin-top: 16px; padding-top: 14px; } .view-tools button { font-size: 11px; padding: 7px 4px; } .selection { border-top: 1px solid #2c3b48; color: #8ea2ae; font-size: 12px; margin-top: 14px; padding-top: 12px; } .selection strong { color: #e7edf3; font-weight: 600; } .view-field { color: #8ea2ae; display: flex; font-size: 12px; gap: 8px; justify-content: space-between; margin-top: 10px; } .view-field select { background: #10161d; border: 1px solid #435464; color: #e7edf3; min-width: 95px; } .view-field input[type="color"] { background: #10161d; border: 1px solid #435464; height: 24px; padding: 2px; width: 48px; } .view-field input[type="range"] { accent-color: #66b7c8; max-width: 120px; } @media (max-width: 720px) { .topbar { align-items: flex-start; flex-direction: column; } .actions { width: 100%; } .actions button { flex: 1; } .workspace { grid-template-columns: 1fr; } .viewport { min-height: 62vh; } .inspector { border-left: 0; border-top: 1px solid #2c3b48; } } `; document.head.append(style); const viewport = document.querySelector("#viewport")!; const status = document.querySelector("#status")!; const fileName = document.querySelector("#file-name")!; const vertexCount = document.querySelector("#vertex-count")!; const triangleCount = document.querySelector("#triangle-count")!; const faceCount = document.querySelector("#face-count")!; const boundsLabel = document.querySelector("#bounds")!; const selectedObject = document.querySelector("#selected-object")!; const openButton = document.querySelector("#open-button")!; const sampleButton = document.querySelector("#sample-button")!; const mainChainButton = document.querySelector("#main-chain-button")!; const workbenchButton = document.querySelector("#workbench-button")!; const pythonButton = document.querySelector("#python-button")!; const downloadButton = document.querySelector("#download-button")!; const resetButton = document.querySelector("#reset-button")!; const fitButton = document.querySelector("#fit-button")!; const frontButton = document.querySelector("#front-button")!; const topButton = document.querySelector("#top-button")!; const sideButton = document.querySelector("#side-button")!; const visibilityButton = document.querySelector("#visibility-button")!; const sectionButton = document.querySelector("#section-button")!; const treeObjectButton = document.querySelector("#object-tree-primary")!; const treeObjectLabel = document.querySelector("#tree-object-label")!; const displayMode = document.querySelector("#display-mode")!; const displayColor = document.querySelector("#display-color")!; const lineWidth = document.querySelector("#line-width")!; const fileInput = document.querySelector("#file-input")!; const scene = new THREE.Scene(); scene.background = new THREE.Color("#0d1319"); const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100000); camera.position.set(4, 4, 4); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.localClippingEnabled = true; viewport.append(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 0.01; scene.add(new THREE.HemisphereLight(0xddeeff, 0x17212b, 1.9)); const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); keyLight.position.set(4, 8, 6); scene.add(keyLight); const grid = new THREE.GridHelper(20, 20, 0x314452, 0x1f2c36); scene.add(grid); let model: THREE.Mesh | null = null; let loading = false; let kernelReadyForUi = false; let sampleRequested = false; let pendingCacheKey: Promise | null = null; let requestSequence = 0; let modelBounds: MeshPayload["bounds"] | null = null; let modelSelected = false; let modelPreselected = false; let sectionEnabled = false; let sectionPlane: THREE.Plane | null = null; let viewState: ViewProviderDisplayState = { ...DEFAULT_VIEW_PROVIDER_STATE }; const renderRegistry = new RenderObjectRegistry(); let lastInputBytes: ArrayBuffer | null = null; function publishViewState(stage = "ready") { window.__freecadViewState = { stage, objectCount: model ? 1 : 0, selected: modelSelected, preselected: modelPreselected, visible: viewState.visible, displayMode: viewState.displayMode, color: viewState.color, lineWidth: viewState.lineWidth, section: sectionEnabled, camera: { position: camera.position.toArray(), target: controls.target.toArray() }, }; } function setStatus(message: string, level: "normal" | "error" = "normal") { status.textContent = message; status.dataset.level = level; } function setBusy(value: boolean) { loading = value; openButton.disabled = value; sampleButton.disabled = value; resetButton.disabled = value || !model; fitButton.disabled = value || !model; frontButton.disabled = value || !model; topButton.disabled = value || !model; sideButton.disabled = value || !model; visibilityButton.disabled = value || !model; sectionButton.disabled = value || !model; displayMode.disabled = value || !model; displayColor.disabled = value || !model; lineWidth.disabled = value || !model; downloadButton.disabled = value || !lastInputBytes; } function formatBounds(bounds: MeshPayload["bounds"]): string { return bounds.map((value) => value.toFixed(2)).join(", "); } function fitCamera(bounds: MeshPayload["bounds"]) { const min = new THREE.Vector3(bounds[0], bounds[1], bounds[2]); const max = new THREE.Vector3(bounds[3], bounds[4], bounds[5]); const center = min.clone().add(max).multiplyScalar(0.5); const size = max.clone().sub(min); const radius = Math.max(size.length() * 0.5, 0.01); controls.target.copy(center); camera.near = Math.max(radius / 1000, 0.0001); camera.far = Math.max(radius * 100, 100); camera.position.copy(center).add(new THREE.Vector3(radius * 1.7, radius * 1.35, radius * 1.7)); camera.updateProjectionMatrix(); controls.update(); } function showMesh(payload: MeshPayload) { if (model) { renderRegistry.remove("primary"); } const entry = renderRegistry.add("primary", payload, { color: "#73c6d4" }); model = entry.object; modelBounds = payload.bounds; modelSelected = false; modelPreselected = false; sectionEnabled = false; sectionPlane = null; let persisted: Partial = {}; try { const raw = sessionStorage.getItem("freecad-web-view-state"); if (raw) persisted = JSON.parse(raw) as Partial; } catch { persisted = {}; } viewState = normalizeViewProviderState({ color: "#73c6d4", ...persisted, selected: false }); model.userData.viewProvider = { id: "native-mesh-view", state: viewState }; model.userData.documentObject = { id: "primary", type: "Part::Feature", source: "freecad-source" }; selectedObject.textContent = "无"; treeObjectLabel.textContent = payload.fileName; treeObjectButton.disabled = false; treeObjectButton.setAttribute("aria-selected", "false"); scene.add(model); fitCamera(payload.bounds); fileName.textContent = payload.fileName; vertexCount.textContent = payload.vertexCount.toLocaleString(); triangleCount.textContent = payload.triangleCount.toLocaleString(); faceCount.textContent = payload.faceCount.toLocaleString(); boundsLabel.textContent = formatBounds(payload.bounds); resetButton.disabled = false; fitButton.disabled = false; frontButton.disabled = false; topButton.disabled = false; sideButton.disabled = false; visibilityButton.disabled = false; displayMode.disabled = false; displayColor.disabled = false; lineWidth.disabled = false; downloadButton.disabled = false; publishViewState("ready"); } function applyViewProviderState(next: Partial) { if (!model) return; viewState = normalizeViewProviderState({ ...viewState, ...next }); renderRegistry.update("primary", viewState); displayMode.value = viewState.displayMode; displayColor.value = viewState.color; lineWidth.value = String(viewState.lineWidth); try { sessionStorage.setItem("freecad-web-view-state", JSON.stringify(viewState)); } catch { /* storage is optional */ } publishViewState("view-provider"); } function setSelection(selected: boolean) { if (!model) return; modelSelected = selected; applyViewProviderState({ selected }); selectedObject.textContent = selected ? model.name || "Part::Feature" : "无"; treeObjectButton.setAttribute("aria-selected", selected ? "true" : "false"); publishViewState(selected ? "selected" : "ready"); setStatus(selected ? `已选中 ${model.name || "Part::Feature"}` : "已取消选择"); } function setPreselection(preselected: boolean) { if (!model || modelSelected === preselected) return; modelPreselected = preselected; model.material.emissive.set(preselected ? 0x194d58 : modelSelected ? 0x1d6670 : 0x000000); model.material.emissiveIntensity = preselected || modelSelected ? 0.75 : 0; model.userData.preselected = preselected; publishViewState("preselected"); } function setSection(enabled: boolean) { if (!model || !modelBounds) return; sectionEnabled = enabled; const min = new THREE.Vector3(modelBounds[0], modelBounds[1], modelBounds[2]); const max = new THREE.Vector3(modelBounds[3], modelBounds[4], modelBounds[5]); const constant = (min.x + max.x) * 0.5; sectionPlane = enabled ? new THREE.Plane(new THREE.Vector3(-1, 0, 0), constant) : null; model.material.clippingPlanes = sectionPlane ? [sectionPlane] : []; model.userData.viewProvider = { id: "native-mesh-view", state: { ...viewState, section: enabled } }; sectionButton.setAttribute("aria-pressed", enabled ? "true" : "false"); publishViewState("section"); setStatus(enabled ? "已启用 X 中剖切" : "已关闭剖切"); } function setCameraPreset(axis: "front" | "top" | "side") { if (!modelBounds) return; const min = new THREE.Vector3(modelBounds[0], modelBounds[1], modelBounds[2]); const max = new THREE.Vector3(modelBounds[3], modelBounds[4], modelBounds[5]); const center = min.clone().add(max).multiplyScalar(0.5); const size = max.clone().sub(min); const radius = Math.max(size.length() * 0.5, 0.01); controls.target.copy(center); const offset = axis === "front" ? new THREE.Vector3(0, 0, radius * 2.4) : axis === "top" ? new THREE.Vector3(0, radius * 2.4, 0) : new THREE.Vector3(radius * 2.4, 0, 0); camera.position.copy(center).add(offset); camera.lookAt(center); controls.update(); } fitButton.addEventListener("click", () => { if (modelBounds) { fitCamera(modelBounds); setStatus("已适配全部对象"); } }); frontButton.addEventListener("click", () => setCameraPreset("front")); topButton.addEventListener("click", () => setCameraPreset("top")); sideButton.addEventListener("click", () => setCameraPreset("side")); visibilityButton.addEventListener("click", () => { if (model) { applyViewProviderState({ visible: !viewState.visible }); setStatus(viewState.visible ? "对象已显示" : "对象已隐藏"); } }); sectionButton.addEventListener("click", () => setSection(!sectionEnabled)); treeObjectButton.addEventListener("click", () => setSelection(!modelSelected)); displayMode.addEventListener("change", () => applyViewProviderState({ displayMode: displayMode.value as ViewProviderDisplayState["displayMode"] })); displayColor.addEventListener("input", () => applyViewProviderState({ color: displayColor.value })); lineWidth.addEventListener("input", () => applyViewProviderState({ lineWidth: Number(lineWidth.value) })); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); viewport.addEventListener("click", (event) => { if (!model || !model.visible) return; const bounds = viewport.getBoundingClientRect(); pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); setSelection(raycaster.intersectObject(model, false).length > 0); }); viewport.addEventListener("pointermove", (event) => { if (!model || !model.visible) { setPreselection(false); return; } const bounds = viewport.getBoundingClientRect(); pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); setPreselection(raycaster.intersectObject(model, false).length > 0); }); viewport.addEventListener("pointerleave", () => setPreselection(false)); window.addEventListener("keydown", (event) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; if (event.key.toLowerCase() === "f" && modelBounds) { fitCamera(modelBounds); setStatus("已适配全部对象"); } if (event.key === "0") setCameraPreset("front"); if (event.key === "1") setCameraPreset("top"); if (event.key === "2") setCameraPreset("side"); if (event.key === "Escape") setSelection(false); if (event.key.toLowerCase() === "s" && model) setSection(!sectionEnabled); }); const worker = new Worker(new URL("./kernel.worker.ts", import.meta.url), { type: "module" }); worker.onmessage = (event: MessageEvent) => { const response = event.data; if ("requestId" in response && response.requestId) { (window as Window & { __freecadLastResponseRequestId?: string }).__freecadLastResponseRequestId = response.requestId; } if (response.type === "ready") { kernelReadyForUi = true; if (!storageRequested && !cacheRequested) { setStatus(`内核就绪:${response.version}`); } if (sampleRequested) { sampleRequested = false; void loadSample(); } return; } if (response.type === "mesh") { showMesh(response.payload); if (pendingCacheKey) { const cacheKey = pendingCacheKey; pendingCacheKey = null; void cacheKey.then((key) => writeMeshCache(key, response.payload)).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : String(error)); }); } setBusy(false); setStatus(`已加载 ${response.payload.fileName}`); return; } if (response.type === "reset") { return; } setBusy(false); setStatus(response.message, "error"); }; worker.onerror = (event) => { setBusy(false); setStatus(`Worker 错误:${event.message}`, "error"); }; function loadStep(name: string, bytes: ArrayBuffer) { if (loading) return; setBusy(true); setStatus(`正在读取 ${name}...`); lastInputBytes = bytes.slice(0); const workerBytes = bytes.slice(0); pendingCacheKey = meshCacheKey(name, bytes, 0.5, 0.5); void writeRawStep(name, bytes).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : String(error)); }); const requestId = `load-${++requestSequence}`; worker.postMessage({ type: "load", requestId, fileName: name, bytes: workerBytes, linearDeflection: 0.5, angularDeflection: 0.5 }, [workerBytes]); } openButton.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", async () => { const file = fileInput.files?.[0]; fileInput.value = ""; if (!file) return; loadStep(file.name, await file.arrayBuffer()); }); sampleButton.addEventListener("click", async () => { await loadSample(); }); mainChainButton.addEventListener("click", () => { window.location.assign("/main-chain.html"); }); workbenchButton.addEventListener("click", () => { window.location.assign("/workbench.html"); }); pythonButton.addEventListener("click", () => { window.location.assign("/python.html"); }); downloadButton.addEventListener("click", () => { if (!lastInputBytes) return; const result = downloadBytes(fileName.textContent || "FreeCAD-export.step", lastInputBytes, "model/step"); setStatus(result.status === "ok" ? "STEP 文件已下载" : result.error?.message || "STEP 下载失败", result.status === "ok" ? "normal" : "error"); }); async function loadSample() { try { const response = await fetch("/samples/freecad-sample.step"); if (!response.ok) throw new Error(`样例文件请求失败:HTTP ${response.status}`); loadStep("freecad-sample.step", await response.arrayBuffer()); } catch (error) { setStatus(error instanceof Error ? error.message : String(error), "error"); } } async function runStorageSmoke() { if (!isOpfsSupported()) { setStatus("当前浏览器不支持 OPFS", "error"); return; } try { const response = await fetch("/samples/freecad-sample.step"); if (!response.ok) throw new Error(`样例文件请求失败:HTTP ${response.status}`); const input = await response.arrayBuffer(); const name = "freecad-opfs-smoke.step"; await writeRawStep(name, input); const output = await readRawStep(name); const same = output.byteLength === input.byteLength && new Uint8Array(output).every((value, index) => value === new Uint8Array(input)[index]); const listed = await listRawSteps(); if (!same || !listed.includes(name)) throw new Error("OPFS 写入/读取或列举结果不一致"); await deleteRawStep(name); if ((await listRawSteps()).includes(name)) throw new Error("OPFS 删除后仍能列出文件"); setStatus(`OPFS smoke passed:${input.byteLength.toLocaleString()} bytes`); } catch (error: unknown) { setStatus(error instanceof Error ? error.message : String(error), "error"); } } async function runMeshCacheSmoke() { if (!isOpfsSupported()) { setStatus("当前浏览器不支持 OPFS 网格缓存", "error"); return; } try { const response = await fetch("/samples/freecad-sample.step"); if (!response.ok) throw new Error(`样例文件请求失败:HTTP ${response.status}`); const input = await response.arrayBuffer(); const keyA = await meshCacheKey("sample.step", input, 0.5, 0.5); const keyB = await meshCacheKey("sample.step", input, 0.25, 0.5); const keyA2 = await meshCacheKey("sample.step", input, 0.5, 0.5); if (keyA !== keyA2 || keyA === keyB) throw new Error("网格缓存 key 未正确区分输入参数"); const payload: MeshPayload = { positions: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer, normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]).buffer, vertexCount: 3, triangleCount: 1, faceCount: 1, bounds: [0, 0, 0, 1, 1, 0], fileName: "sample.step", }; await writeMeshCache(keyA, payload); const cached = await readMeshCache(keyA); if (!cached || cached.vertexCount !== 3 || cached.triangleCount !== 1 || cached.positions.byteLength !== payload.positions.byteLength) { throw new Error("网格缓存写入或读取失败"); } await deleteMeshCache(keyA); if (await readMeshCache(keyA)) throw new Error("删除后的网格缓存仍然命中"); setStatus("网格缓存 smoke passed"); } catch (error: unknown) { setStatus(error instanceof Error ? error.message : String(error), "error"); } } async function runRecoveryInjectionSmoke() { const input = new TextEncoder().encode("FCStd recovery fixture").buffer; const outcomes: Array<{ boundary: RecoveryBoundary; status: string }> = []; for (const boundary of RECOVERY_BOUNDARIES) { let temporary = new ArrayBuffer(0); let target = new ArrayBuffer(0); try { await runRecoveryTransaction({ writeTemp: async () => { temporary = input.slice(0); }, validateTemp: async () => ({ byteLength: temporary.byteLength }), replace: async () => { target = temporary.slice(0); }, }, boundary); throw new Error(`injection did not interrupt at ${boundary}`); } catch (error: unknown) { if (!(error instanceof RecoveryInterruption) || error.boundary !== boundary) throw error; outcomes.push({ boundary, status: "interrupted" }); if (boundary === "before-write" && temporary.byteLength !== 0) throw new Error("before-write changed temp state"); if (boundary !== "before-write" && target.byteLength !== 0) throw new Error(`${boundary} replaced target early`); } } let committed = new ArrayBuffer(0); await runRecoveryTransaction({ writeTemp: async () => { committed = input.slice(0); }, validateTemp: async () => ({ byteLength: committed.byteLength }), replace: async () => undefined, }); if (committed.byteLength !== input.byteLength) throw new Error("normal recovery transaction did not commit"); setStatus(`恢复注入 smoke passed:${outcomes.length} boundaries`); } async function runRecoveryBoundarySmoke() { type RecoveryFile = { createWritable(): Promise<{ write(data: ArrayBuffer): Promise; close(): Promise }>; getFile(): Promise<{ arrayBuffer(): Promise }> }; type RecoveryDirectory = { getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; getFileHandle(name: string, options?: { create?: boolean }): Promise; removeEntry(name: string): Promise }; const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise }; if (!storage.getDirectory) throw new Error("当前浏览器不支持 OPFS recovery boundaries"); const root = await storage.getDirectory(); const directory = await (await root.getDirectoryHandle("freecad-p5-recovery", { create: true })); const input = new TextEncoder().encode("FCStd recovery boundary fixture").buffer; const digest = async (bytes: ArrayBuffer) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (value) => value.toString(16).padStart(2, "0")).join(""); const snapshots: Array> = []; for (const boundary of RECOVERY_BOUNDARIES) { const tempName = `${boundary}.tmp`; const targetName = `${boundary}.FCStd`; await directory.removeEntry(tempName).catch(() => undefined); await directory.removeEntry(targetName).catch(() => undefined); let tempBytes: ArrayBuffer | null = null; let targetBytes: ArrayBuffer | null = null; try { await runRecoveryTransaction({ writeTemp: async () => { const file = await directory.getFileHandle(tempName, { create: true }); const writable = await file.createWritable(); await writable.write(input); await writable.close(); tempBytes = await (await directory.getFileHandle(tempName)).getFile().then((value) => value.arrayBuffer()); }, validateTemp: async () => { if (!tempBytes || tempBytes.byteLength !== input.byteLength) throw new Error("temporary FCStd snapshot validation failed"); return { byteLength: tempBytes.byteLength, sha256: await digest(tempBytes) }; }, replace: async () => { const file = await directory.getFileHandle(targetName, { create: true }); const writable = await file.createWritable(); await writable.write(input); await writable.close(); targetBytes = await (await directory.getFileHandle(targetName)).getFile().then((value) => value.arrayBuffer()); }, }, boundary); throw new Error(`injection did not interrupt at ${boundary}`); } catch (error: unknown) { if (!(error instanceof RecoveryInterruption) || error.boundary !== boundary) throw error; const tempSnapshot: ArrayBuffer | null = tempBytes; const targetSnapshot: ArrayBuffer | null = targetBytes; snapshots.push({ boundary, tempName, targetName, tempExists: tempSnapshot !== null, tempSize: tempSnapshot === null ? 0 : (tempSnapshot as ArrayBuffer).byteLength, tempSha256: tempSnapshot === null ? null : await digest(tempSnapshot as ArrayBuffer), targetExists: targetSnapshot !== null, targetSize: targetSnapshot === null ? 0 : (targetSnapshot as ArrayBuffer).byteLength, targetSha256: targetSnapshot === null ? null : await digest(targetSnapshot as ArrayBuffer), }); } } setStatus(`恢复边界 smoke passed:${snapshots.length} OPFS snapshots`); } async function runRecoveryValidationSmoke() { type RecoveryFile = { getFile(): Promise<{ arrayBuffer(): Promise }> }; type RecoveryDirectory = { getFileHandle(name: string): Promise; entries(): AsyncIterableIterator<[string, unknown]> }; const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise<{ getDirectoryHandle(name: string): Promise }> }; if (!storage.getDirectory) throw new Error("当前浏览器不支持 OPFS recovery validation"); const root = await storage.getDirectory(); const directory = await root.getDirectoryHandle("freecad-p5-recovery"); const expectedSha256 = "040fe59615486c9b472d7c0300ac30bd3039e1b8fce10fc0462a21427dc8fd1c"; const digest = async (bytes: ArrayBuffer) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (value) => value.toString(16).padStart(2, "0")).join(""); const names = new Set(); for await (const [name] of directory.entries()) names.add(name); const results: Array> = []; for (const boundary of RECOVERY_BOUNDARIES) { const tempName = `${boundary}.tmp`; const targetName = `${boundary}.FCStd`; let tempHash: string | null = null; let targetHash: string | null = null; if (names.has(tempName)) tempHash = await digest(await (await directory.getFileHandle(tempName)).getFile().then((value) => value.arrayBuffer())); if (names.has(targetName)) targetHash = await digest(await (await directory.getFileHandle(targetName)).getFile().then((value) => value.arrayBuffer())); const tempValid = tempHash === expectedSha256; const targetValid = targetHash === expectedSha256; results.push({ boundary, tempExists: names.has(tempName), tempHash, tempValid, targetExists: names.has(targetName), targetHash, targetValid, decision: targetValid ? "use-target" : "reject-incomplete-temp" }); if (targetValid || (names.has(tempName) && !tempValid)) throw new Error(`recovery validation decision mismatch at ${boundary}`); } if (results.length !== RECOVERY_BOUNDARIES.length) throw new Error("recovery snapshot count mismatch"); setStatus(`恢复校验 smoke passed:${results.length} snapshots`); } resetButton.addEventListener("click", () => { if (loading) return; worker.postMessage({ type: "reset", requestId: `reset-${++requestSequence}` }); if (model) { renderRegistry.clear(); model = null; } treeObjectButton.disabled = true; treeObjectLabel.textContent = "未加载对象"; treeObjectButton.setAttribute("aria-selected", "false"); sectionEnabled = false; sectionPlane = null; fileName.textContent = "未加载"; vertexCount.textContent = "0"; triangleCount.textContent = "0"; faceCount.textContent = "0"; boundsLabel.textContent = "-"; modelBounds = null; modelSelected = false; selectedObject.textContent = "无"; fitButton.disabled = true; frontButton.disabled = true; topButton.disabled = true; sideButton.disabled = true; visibilityButton.disabled = true; displayMode.disabled = true; displayColor.disabled = true; lineWidth.disabled = true; downloadButton.disabled = true; lastInputBytes = null; resetButton.disabled = true; publishViewState("reset"); setStatus("已清空"); }); viewport.addEventListener("dragover", (event) => event.preventDefault()); viewport.addEventListener("drop", async (event) => { event.preventDefault(); const file = event.dataTransfer?.files[0]; if (!file) return; loadStep(file.name, await file.arrayBuffer()); }); const resizeObserver = new ResizeObserver(() => { const width = Math.max(viewport.clientWidth, 1); const height = Math.max(viewport.clientHeight, 1); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }); resizeObserver.observe(viewport); function render() { controls.update(); renderer.render(scene, camera); requestAnimationFrame(render); } render(); if (urlParams.get("sample") === "1") { sampleRequested = true; setStatus("等待 FreeCAD 1.2 WebKernel..."); if (kernelReadyForUi) { sampleRequested = false; void loadSample(); } } if (storageRequested) { void runStorageSmoke(); } if (cacheRequested) { void runMeshCacheSmoke(); } if (urlParams.get("recovery") === "1") { void runRecoveryInjectionSmoke().catch((error: unknown) => { setStatus(error instanceof Error ? error.message : String(error), "error"); }); } if (urlParams.get("recovery-boundaries") === "1") { void runRecoveryBoundarySmoke().catch((error: unknown) => { setStatus(error instanceof Error ? error.message : String(error), "error"); }); } if (urlParams.get("recovery-validate") === "1") { void runRecoveryValidationSmoke().catch((error: unknown) => { setStatus(error instanceof Error ? error.message : String(error), "error"); }); } window.addEventListener("pagehide", () => { accessibility.dispose(); resizeObserver.disconnect(); worker.terminate(); renderer.dispose(); });