=> {
const identity = nextUserActionIdentity("OPEN");
if (!acquireProjectActionLock(identity)) return;
@@ -3497,7 +3556,7 @@ export function App() {
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item} )}
{ const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}>最近项目 ({recentProjects.length}) {recentProjects.map((project) => {project.displayName} )} fileInputRef.current?.click()}>打开 关闭 void recoverCachedProject()}>恢复 void saveBlend()}>保存 void reportGLBExport()}>GLB 诊断 void applyEditCommand({ type: "undo" })}>↶ void applyEditCommand({ type: "redo" })}>↷ dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3 {workerFaultTestMode ? Fault : null}
- route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else void openBlendFile(file); } event.target.value = ""; }} />
+ route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) void importSceneFile(file, route.format); else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); } } event.target.value = ""; }} />
{workerFault ? {workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained. void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"} {workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null}
{recentProjectIssues.length > 0 ? 最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。 {recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)} void removeInvalidRecentProject(issue.project.projectId)}>移除引用
)}
: null}
diff --git a/web/app/src/engine-client/WebEngineClient.ts b/web/app/src/engine-client/WebEngineClient.ts
index 0a6931fac..3abd28d43 100644
--- a/web/app/src/engine-client/WebEngineClient.ts
+++ b/web/app/src/engine-client/WebEngineClient.ts
@@ -245,6 +245,21 @@ export class WebEngineClient {
return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] };
}
+ async importScene(format: string, fileName: string, mimeType: string, base64: string): Promise {
+ const result = await this.request({ type: "jsonCommand", cmd: "importScene", params: { format, fileName, mimeType, base64 } });
+ if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "IO 导入未返回场景结果", 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],
+ delta: result.delta,
+ importResult: result.json,
+ };
+ }
+
async snapshot(): Promise {
const result = await this.request({ type: "snapshot" });
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneIR", true);
diff --git a/web/protocol/web-engine.ts b/web/protocol/web-engine.ts
index c8735a335..5ef429dd4 100644
--- a/web/protocol/web-engine.ts
+++ b/web/protocol/web-engine.ts
@@ -333,7 +333,9 @@ export type WebEngineJsonCommand =
| "readRNAPropertySchema"
| "setRNAArrayProperty"
| "setRNAEnumProperty"
- | "readRNACollection";
+ | "readRNACollection"
+ | "importScene"
+ | "exportScene";
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
diff --git a/web/tests/e2e/io-format-ui-gate.spec.ts b/web/tests/e2e/io-format-ui-gate.spec.ts
index e34104956..3a59599d6 100644
--- a/web/tests/e2e/io-format-ui-gate.spec.ts
+++ b/web/tests/e2e/io-format-ui-gate.spec.ts
@@ -2,18 +2,19 @@ import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
+const objFixture = path.resolve(import.meta.dirname, "../../../tests/files/web/m12_obj_desktop_v1/single-mesh.obj");
test("M12-05C exposes only matrix-declared file and operator routes", async ({ page }) => {
await page.goto("/");
const input = page.getByTestId("blend-file-input");
- await expect(input).toHaveAttribute("accept", ".blend,application/octet-stream");
- await expect(input).toHaveAttribute("data-io-format-import-routes", "");
+ await expect(input).toHaveAttribute("accept", ".blend,application/octet-stream,.gltf,.glb,.obj,.stl,.ply");
+ await expect(input).toHaveAttribute("data-io-format-import-routes", "GLTF,GLB,OBJ,STL,PLY");
await input.setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.keyboard.press("F3");
- const search = page.getByRole("textbox", { name: "搜索操作" });
+ const search = page.getByRole("textbox", { name: "搜索 Blender 操作" });
await search.fill("export glb");
await expect(page.getByRole("button", { name: "Export GLB", exact: true })).toHaveCount(1);
await search.fill("export usd");
@@ -22,7 +23,7 @@ test("M12-05C exposes only matrix-declared file and operator routes", async ({ p
await expect(page.getByRole("button", { name: /Import OBJ|导入 OBJ/ })).toHaveCount(0);
await page.keyboard.press("Escape");
- await input.setInputFiles({ name: "mesh.obj", mimeType: "model/obj", buffer: Buffer.from("v 0 0 0\n") });
- await expect(page.getByTestId("engine-status")).toContainText("IO: format route unavailable");
- await expect(page.getByTestId("engine-status")).not.toContainText("SceneIR r2");
+ await input.setInputFiles(objFixture);
+ await expect(page.getByTestId("engine-status")).toContainText("OBJ imported");
+ await expect(page.getByTestId("engine-status")).toContainText("SceneIR r");
});
diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts
index ed4b5e256..c9123b2b8 100644
--- a/web/tests/e2e/smoke.spec.ts
+++ b/web/tests/e2e/smoke.spec.ts
@@ -2079,26 +2079,26 @@ test("operator search executes context-filtered workspace, mode and Main command
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling");
await page.keyboard.press("F3");
- await page.getByRole("textbox", { name: "搜索操作" }).fill("switch to animation");
- await page.keyboard.press("Enter");
+ await page.getByRole("textbox", { name: "搜索 Blender 操作" }).fill("switch to animation");
+ await page.getByRole("button", { name: "Switch to Animation" }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Animation");
- await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
+ await expect(page.getByRole("dialog", { name: "操作搜索" })).toHaveCount(0);
await page.keyboard.press("F3");
- await page.getByRole("textbox", { name: "搜索操作" }).fill("enter edit");
- await page.keyboard.press("Enter");
+ await page.getByRole("textbox", { name: "搜索 Blender 操作" }).fill("enter edit");
+ await page.getByRole("button", { name: "Enter Edit Mode" }).click();
await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible();
await page.keyboard.press("F3");
- await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
+ await page.getByRole("textbox", { name: "搜索 Blender 操作" }).fill("cube");
await expect(page.getByRole("button", { name: "Add Cube" })).toHaveCount(0);
await page.keyboard.press("Escape");
- await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0);
+ await expect(page.getByRole("dialog", { name: "操作搜索" })).toHaveCount(0);
await page.getByRole("button", { name: "Edit Mode" }).click();
await page.keyboard.press("F3");
- await page.getByRole("textbox", { name: "搜索操作" }).fill("cube");
+ await page.getByRole("textbox", { name: "搜索 Blender 操作" }).fill("cube");
await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible();
- await page.keyboard.press("Enter");
+ await page.getByRole("button", { name: "Add Cube" }).click();
await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (4 objects)");
});