Add Chromium-only Blender WebEngine parity work
This commit is contained in:
121
web/app/src/engine-client/EngineClient.ts
Normal file
121
web/app/src/engine-client/EngineClient.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
EngineCapabilities,
|
||||
EngineRequest,
|
||||
EngineResponse,
|
||||
EngineCommand,
|
||||
} from "../../../protocol/engine";
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (response: EngineResponse) => void;
|
||||
reject: (reason: ErrorReport) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface EngineClientOptions {
|
||||
timeoutMs?: number;
|
||||
workerFactory?: () => Worker;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () =>
|
||||
new Worker(new URL("../workers/engine.worker.ts", import.meta.url), { type: "module" });
|
||||
|
||||
export class EngineClient {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly workerFactory: () => Worker;
|
||||
private worker: Worker | null = null;
|
||||
private requestCounter = 0;
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private _revision = 0;
|
||||
|
||||
constructor(options: EngineClientOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 10_000;
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
}
|
||||
|
||||
get revision(): number {
|
||||
return this._revision;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.worker) return;
|
||||
const worker = this.workerFactory();
|
||||
worker.onmessage = (event: MessageEvent<EngineResponse>) => this.handleResponse(event.data);
|
||||
worker.onerror = () => this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 发生异常", true));
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
async init(protocolVersion = 1): Promise<EngineCapabilities> {
|
||||
const response = await this.request({ type: "init", protocolVersion });
|
||||
if (!response.capabilities) throw this.report("WASM_INIT_FAILED", "EngineWorker 未返回能力信息", false);
|
||||
return response.capabilities;
|
||||
}
|
||||
|
||||
async getSceneSnapshot(): Promise<SceneSnapshotIR> {
|
||||
const response = await this.request({ type: "getSceneSnapshot", sinceRevision: this._revision });
|
||||
if (!response.snapshot) throw this.report("INVALID_ARGUMENT", "EngineWorker 未返回 SceneIR", false);
|
||||
return response.snapshot;
|
||||
}
|
||||
|
||||
async setFrame(frame: number): Promise<void> {
|
||||
await this.request({ type: "setFrame", frame });
|
||||
}
|
||||
|
||||
async setObjectVisibility(objectId: string, visible: boolean): Promise<void> {
|
||||
await this.request({ type: "setObjectVisibility", objectId, visible });
|
||||
}
|
||||
|
||||
restart(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 已重启", true));
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this._revision = 0;
|
||||
this.start();
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "EngineWorker 已关闭", true));
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
|
||||
private request(command: EngineCommand): Promise<EngineResponse> {
|
||||
this.start();
|
||||
const worker = this.worker;
|
||||
if (!worker) return Promise.reject(this.report("WASM_INIT_FAILED", "无法创建 EngineWorker", false));
|
||||
|
||||
const requestId = `engine-${++this.requestCounter}`;
|
||||
const message: EngineRequest = { requestId, expectedRevision: this._revision, command };
|
||||
return new Promise<EngineResponse>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(this.report("WORKER_TERMINATED", `请求超时: ${command.type}`, true));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(requestId, { resolve, reject, timer });
|
||||
worker.postMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private handleResponse(response: EngineResponse): void {
|
||||
const pending = this.pending.get(response.requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(response.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
this._revision = response.revision;
|
||||
if (response.ok) pending.resolve(response);
|
||||
else pending.reject(response.reports?.[0] ?? this.report("INVALID_ARGUMENT", "EngineWorker 请求失败", true));
|
||||
}
|
||||
|
||||
private failPending(report: ErrorReport): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(report);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
|
||||
return { code, severity: "error", message, recoverable };
|
||||
}
|
||||
}
|
||||
210
web/app/src/engine-client/WebEngineClient.ts
Normal file
210
web/app/src/engine-client/WebEngineClient.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import type { ProgressEvent } from "../../../protocol/progress";
|
||||
import type {
|
||||
AssetRequestResult,
|
||||
MeshGeometryBuffer,
|
||||
WebEngineEditCommand,
|
||||
WebEngineLODResult,
|
||||
WebEngineRequest,
|
||||
WebEngineResponse,
|
||||
WebEngineResult,
|
||||
WebEngineStatus,
|
||||
} from "../../../protocol/web-engine";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import type { SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { SimplifyResult } from "../../../protocol/simplify";
|
||||
import type { DepsgraphEvaluationIR } from "../../../protocol/depsgraph";
|
||||
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
|
||||
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: WebEngineResult) => void;
|
||||
reject: (error: ErrorReport) => void;
|
||||
onProgress?: (progress: ProgressEvent) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface WebEngineClientOptions {
|
||||
timeoutMs?: number;
|
||||
workerFactory?: () => Worker;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () =>
|
||||
new Worker(new URL("../workers/web-engine.worker.ts", import.meta.url), { type: "module" });
|
||||
|
||||
export interface BlendOpenResult {
|
||||
status: WebEngineStatus;
|
||||
snapshot: SceneSnapshotIR;
|
||||
geometryBuffers: MeshGeometryBuffer[];
|
||||
nonMeshGeometryBuffers?: NonMeshGeometryChunk[];
|
||||
simplify?: SimplifyResult;
|
||||
}
|
||||
|
||||
export class WebEngineClient {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly workerFactory: () => Worker;
|
||||
private worker: Worker | null = null;
|
||||
private requestCounter = 0;
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private geometryBuffers: MeshGeometryBuffer[] = [];
|
||||
private nonMeshGeometryBuffers: NonMeshGeometryChunk[] = [];
|
||||
|
||||
constructor(options: WebEngineClientOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
}
|
||||
|
||||
async init(): Promise<WebEngineStatus> {
|
||||
return (await this.request({ type: "init" })).status;
|
||||
}
|
||||
|
||||
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
|
||||
if (buffer.byteLength === 0) {
|
||||
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
|
||||
}
|
||||
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
|
||||
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
|
||||
this.geometryBuffers = result.geometryBuffers ?? [];
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
|
||||
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
|
||||
}
|
||||
|
||||
async snapshot(): Promise<BlendOpenResult> {
|
||||
const result = await this.request({ type: "snapshot" });
|
||||
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneIR", true);
|
||||
this.geometryBuffers = result.geometryBuffers ?? this.geometryBuffers;
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
|
||||
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
|
||||
}
|
||||
|
||||
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {
|
||||
const result = await this.request({ type: "delta" });
|
||||
if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true);
|
||||
return { status: result.status, delta: result.delta };
|
||||
}
|
||||
|
||||
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta }> {
|
||||
const result = await this.request({ type: "applyCommand", payload });
|
||||
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true);
|
||||
this.geometryBuffers = result.geometryDelta
|
||||
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
|
||||
: result.geometryBuffers ?? this.geometryBuffers;
|
||||
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
|
||||
return {
|
||||
status: result.status,
|
||||
snapshot: result.snapshot,
|
||||
geometryBuffers: [...this.geometryBuffers],
|
||||
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
|
||||
delta: result.delta,
|
||||
simplify: result.simplify,
|
||||
};
|
||||
}
|
||||
|
||||
async previewCommand(payload: Extract<WebEngineEditCommand, { type: "previewDecimateMesh" }>): Promise<BlendOpenResult> {
|
||||
const result = await this.request({ type: "applyCommand", payload });
|
||||
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回预览结果", true);
|
||||
return { status: result.status, snapshot: result.snapshot, geometryBuffers: result.geometryBuffers ?? [], nonMeshGeometryBuffers: result.nonMeshGeometryBuffers ?? [], simplify: result.simplify };
|
||||
}
|
||||
|
||||
async generateLOD(payload: LODGenerationRequest): Promise<{ status: WebEngineStatus; lod: WebEngineLODResult }> {
|
||||
const result = await this.request({ type: "generateLOD", payload });
|
||||
if (!result.lod) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 LOD 结果", true);
|
||||
return { status: result.status, lod: result.lod };
|
||||
}
|
||||
|
||||
async requestAsset(assetId: string): Promise<AssetRequestResult> {
|
||||
if (!assetId) throw this.report("INVALID_ARGUMENT", "Asset ID 不能为空", true);
|
||||
const result = await this.request({ type: "requestAsset", assetId });
|
||||
if (!result.asset) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回资产结果", true);
|
||||
return result.asset;
|
||||
}
|
||||
|
||||
async queryRenderCapability(request: RenderCapabilityRequest): Promise<CapabilityGateResult> {
|
||||
const result = await this.request({ type: "queryRenderCapability", request });
|
||||
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine 未返回渲染能力门", true);
|
||||
return result.capabilityGate;
|
||||
}
|
||||
|
||||
async queryNonMeshCapability(dataId: string): Promise<CapabilityGateResult> {
|
||||
const result = await this.request({ type: "queryNonMeshCapability", dataId });
|
||||
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine did not return the non-mesh capability gate", true);
|
||||
return result.capabilityGate;
|
||||
}
|
||||
|
||||
async evaluateDepsgraph(): Promise<{ status: WebEngineStatus; depsgraph: DepsgraphEvaluationIR }> {
|
||||
const result = await this.request({ type: "evaluateDepsgraph" });
|
||||
if (!result.depsgraph) throw this.report("CAPABILITY_MISSING", "WebEngine 未返回 Blender Depsgraph 结果", true);
|
||||
return { status: result.status, depsgraph: result.depsgraph };
|
||||
}
|
||||
|
||||
async saveBlend(): Promise<ArrayBuffer> {
|
||||
const result = await this.request({ type: "saveBlend" });
|
||||
if (!result.blend) throw this.report("BLEND_WRITE_FAILED", "WebEngine 未返回 .blend 数据", true);
|
||||
return result.blend;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.geometryBuffers = [];
|
||||
this.nonMeshGeometryBuffers = [];
|
||||
}
|
||||
|
||||
private start(): Worker {
|
||||
if (this.worker) return this.worker;
|
||||
const worker = this.workerFactory();
|
||||
worker.onmessage = (event: MessageEvent<WebEngineResponse>) => this.handleResponse(event.data);
|
||||
worker.onerror = (event) => {
|
||||
this.failPending(this.report("WORKER_TERMINATED", event.message || "WebEngineWorker 发生异常", true));
|
||||
};
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
private request(
|
||||
command: WebEngineRequest["command"],
|
||||
transfer: Transferable[] = [],
|
||||
onProgress?: (progress: ProgressEvent) => void,
|
||||
): Promise<WebEngineResult> {
|
||||
const worker = this.start();
|
||||
const requestId = `web-engine-${++this.requestCounter}`;
|
||||
const request = { requestId, command } as WebEngineRequest;
|
||||
return new Promise<WebEngineResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
|
||||
}, this.timeoutMs);
|
||||
this.pending.set(requestId, { resolve, reject, onProgress, timer });
|
||||
worker.postMessage(request, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
private handleResponse(response: WebEngineResponse): void {
|
||||
const pending = this.pending.get(response.requestId);
|
||||
if (!pending) return;
|
||||
if (response.kind === "progress") {
|
||||
pending.onProgress?.(response.progress);
|
||||
return;
|
||||
}
|
||||
this.pending.delete(response.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
if (response.ok) pending.resolve(response.result);
|
||||
else pending.reject(response.error);
|
||||
}
|
||||
|
||||
private failPending(error: ErrorReport): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
|
||||
return { code, severity: "error", message, recoverable };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user