742 lines
36 KiB
TypeScript
742 lines
36 KiB
TypeScript
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">■</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();
|
||
});
|