Complete V1 RC deployment capability gates

This commit is contained in:
mes123456
2026-08-15 01:01:23 -04:00
parent a3f3071c03
commit 17ab961485
37 changed files with 2031 additions and 184 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202124" />
<title>Blender Web Editor</title>
<title>Web Blender Modeler V1</title>
</head>
<body>
<div id="root"></div>

View File

@@ -302,7 +302,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="axis-gizmo" aria-hidden="true"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span></div>
{viewportError || !snapshot || snapshot.nodes.length === 0 ? <div className="viewport-message">
<strong>Blender Web Viewport</strong>
<strong>3D Viewport</strong>
{viewportError ? <span>WebGL {viewportError}</span> : <span>Three.js WebGL2 </span>}
</div> : null}
<div className="viewport-toolbar" aria-label="视口工具">
@@ -1344,7 +1344,7 @@ export function App() {
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Blender Web</span></div>
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Web Blender Modeler V1</span></div>
<nav className="menu-bar" aria-label="主菜单"><button type="button" onClick={() => fileInputRef.current?.click()}></button><button type="button"></button><button type="button"></button><button type="button"></button><button type="button"></button></nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
@@ -1360,7 +1360,7 @@ export function App() {
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Blender Web 0.1.0</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);
}

View File

@@ -1,9 +1,15 @@
export interface BrowserCapabilities {
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "../../../protocol/capability-gates";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
export interface BrowserCapabilities extends WasmThreadingCapabilities {
webgl2: boolean;
offscreenCanvas: boolean;
opfs: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
wasm: boolean;
wasmSimd: boolean;
wasmThreads: boolean;
@@ -11,6 +17,34 @@ export interface BrowserCapabilities {
indexedDb: boolean;
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
if (typeof WebAssembly === "undefined") return false;
@@ -40,6 +74,7 @@ export function detectBrowserCapabilities(): BrowserCapabilities {
const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
return {
crossOriginIsolated: globalThis.crossOriginIsolated === true,
webgl2,
offscreenCanvas: typeof OffscreenCanvas !== "undefined",
opfs: Boolean(storage?.getDirectory),

View File

@@ -135,6 +135,10 @@ export class StorageClient {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}
getPendingRequestCount(): number {
return this.pending.size;
}
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
const requestId = `storage-${++this.counter}`;
const request: StorageRequest = { requestId, command };

View File

@@ -18,7 +18,7 @@ type Request =
type Response =
| { type: "ready"; index: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number } }
| { type: "seekResult"; requestId: string; result: LongMediaSeekResultIR }
| { type: "disposed"; cacheBytes: number }
| { type: "disposed"; releasedCacheBytes: number; cacheBytesAfter: number }
| { type: "error"; requestId?: string; message: string };
const scope = self as unknown as {
@@ -71,9 +71,9 @@ scope.onmessage = (event): void => {
return;
}
if (message.type === "cancel") { session?.cancel(); return; }
const cacheBytes = session?.cache.stats().bytes ?? 0;
const releasedCacheBytes = session?.cache.stats().bytes ?? 0;
session?.dispose();
session = null;
assets.clear();
scope.postMessage({ type: "disposed", cacheBytes });
scope.postMessage({ type: "disposed", releasedCacheBytes, cacheBytesAfter: 0 });
};

View File

@@ -4,12 +4,13 @@ import react from "@vitejs/plugin-react";
import fs from "node:fs";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import path from "node:path";
// @ts-expect-error The runtime is Node; this project intentionally avoids a browser dependency on Node types.
import { fileURLToPath } from "node:url";
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
};
const deploymentContract = JSON.parse(
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
) as { responseHeaders: { allResponses: Record<string, string> } };
const isolationHeaders = deploymentContract.responseHeaders.allResponses;
function preserveIsolationHeaders(): Plugin {
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {