Files
FreeCADWasmDev/web/src/main.ts
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

742 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof detectBrowserCapabilities>; __freecadViewState?: Record<string, unknown>; } }
const root = document.querySelector<HTMLDivElement>("#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 = `
<main class="app-shell">
<header class="topbar">
<div class="brand"><span class="brand-mark">FC</span><span>FreeCAD 1.2 Web</span></div>
<div class="actions">
<button id="sample-button" type="button">打开样例</button>
<button id="open-button" type="button">打开 STEP</button>
<button id="main-chain-button" type="button">主链工作区</button>
<button id="workbench-button" type="button">统一工作台</button>
<button id="python-button" type="button">Python Console</button>
<button id="download-button" type="button" disabled>下载 STEP</button>
<button id="reset-button" type="button" disabled>清空</button>
<input id="file-input" type="file" accept=".step,.stp" hidden />
</div>
</header>
<section class="workspace">
<div id="viewport" class="viewport" aria-label="FreeCAD 3D 视图"></div>
<aside class="inspector">
<div class="inspector-heading">模型状态</div>
<dl class="stats">
<div><dt>文件</dt><dd id="file-name">未加载</dd></div>
<div><dt>顶点</dt><dd id="vertex-count">0</dd></div>
<div><dt>三角形</dt><dd id="triangle-count">0</dd></div>
<div><dt>面</dt><dd id="face-count">0</dd></div>
<div><dt>包围盒</dt><dd id="bounds">-</dd></div>
</dl>
<div class="view-tools" aria-label="视图操作">
<button id="fit-button" type="button" disabled>适配</button>
<button id="front-button" type="button" disabled>前视</button>
<button id="top-button" type="button" disabled>俯视</button>
<button id="side-button" type="button" disabled>侧视</button>
<button id="visibility-button" type="button" disabled>隐藏/显示</button>
<button id="section-button" type="button" disabled>剖切</button>
</div>
<div class="object-tree" aria-label="对象树"><div class="inspector-heading">对象树</div><button id="object-tree-primary" class="tree-item" type="button" disabled><span aria-hidden="true">&#9632;</span><span id="tree-object-label">未加载对象</span></button></div>
<label class="view-field">显示模式<select id="display-mode" disabled><option value="shaded">着色</option><option value="wireframe">线框</option><option value="points">点</option></select></label>
<label class="view-field">颜色<input id="display-color" type="color" value="#73c6d4" disabled /></label>
<label class="view-field">线宽<input id="line-width" type="range" min="1" max="8" step="1" value="1" disabled /></label>
<div class="selection" role="status">选中对象:<strong id="selected-object">无</strong></div>
<div id="status" class="status" role="status">正在初始化 FreeCAD 1.2 WebKernel...</div>
</aside>
</section>
</main>
`;
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<HTMLDivElement>("#viewport")!;
const status = document.querySelector<HTMLDivElement>("#status")!;
const fileName = document.querySelector<HTMLElement>("#file-name")!;
const vertexCount = document.querySelector<HTMLElement>("#vertex-count")!;
const triangleCount = document.querySelector<HTMLElement>("#triangle-count")!;
const faceCount = document.querySelector<HTMLElement>("#face-count")!;
const boundsLabel = document.querySelector<HTMLElement>("#bounds")!;
const selectedObject = document.querySelector<HTMLElement>("#selected-object")!;
const openButton = document.querySelector<HTMLButtonElement>("#open-button")!;
const sampleButton = document.querySelector<HTMLButtonElement>("#sample-button")!;
const mainChainButton = document.querySelector<HTMLButtonElement>("#main-chain-button")!;
const workbenchButton = document.querySelector<HTMLButtonElement>("#workbench-button")!;
const pythonButton = document.querySelector<HTMLButtonElement>("#python-button")!;
const downloadButton = document.querySelector<HTMLButtonElement>("#download-button")!;
const resetButton = document.querySelector<HTMLButtonElement>("#reset-button")!;
const fitButton = document.querySelector<HTMLButtonElement>("#fit-button")!;
const frontButton = document.querySelector<HTMLButtonElement>("#front-button")!;
const topButton = document.querySelector<HTMLButtonElement>("#top-button")!;
const sideButton = document.querySelector<HTMLButtonElement>("#side-button")!;
const visibilityButton = document.querySelector<HTMLButtonElement>("#visibility-button")!;
const sectionButton = document.querySelector<HTMLButtonElement>("#section-button")!;
const treeObjectButton = document.querySelector<HTMLButtonElement>("#object-tree-primary")!;
const treeObjectLabel = document.querySelector<HTMLElement>("#tree-object-label")!;
const displayMode = document.querySelector<HTMLSelectElement>("#display-mode")!;
const displayColor = document.querySelector<HTMLInputElement>("#display-color")!;
const lineWidth = document.querySelector<HTMLInputElement>("#line-width")!;
const fileInput = document.querySelector<HTMLInputElement>("#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<THREE.BufferGeometry, THREE.MeshStandardMaterial> | null = null;
let loading = false;
let kernelReadyForUi = false;
let sampleRequested = false;
let pendingCacheKey: Promise<string> | 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<ViewProviderDisplayState> = {};
try {
const raw = sessionStorage.getItem("freecad-web-view-state");
if (raw) persisted = JSON.parse(raw) as Partial<ViewProviderDisplayState>;
} 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<ViewProviderDisplayState>) {
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<WorkerResponse>) => {
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<void>; close(): Promise<void> }>; getFile(): Promise<{ arrayBuffer(): Promise<ArrayBuffer> }> };
type RecoveryDirectory = { getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<RecoveryDirectory>; getFileHandle(name: string, options?: { create?: boolean }): Promise<RecoveryFile>; removeEntry(name: string): Promise<void> };
const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise<RecoveryDirectory> };
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<Record<string, unknown>> = [];
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<ArrayBuffer> }> };
type RecoveryDirectory = { getFileHandle(name: string): Promise<RecoveryFile>; entries(): AsyncIterableIterator<[string, unknown]> };
const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise<{ getDirectoryHandle(name: string): Promise<RecoveryDirectory> }> };
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<string>();
for await (const [name] of directory.entries()) names.add(name);
const results: Array<Record<string, unknown>> = [];
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();
});