feat(web): enable UV editing in web UI and use OPFS save/reopen
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- Forward workspace and mode to the native context in ?ui=web.
- Locate the writable native UV area; enter Edit explicitly and wait for
  project recovery locks before switching mode.
- Save projects to OPFS by default; add an explicit manual .blend download.
- Add UV E2E coverage and implementation status.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 04:40:13 +08:00
parent a27aa46d7c
commit 7267db7cb4
5 changed files with 222 additions and 19 deletions

View File

@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
const GLB = path.resolve(HERE, "../../../re/猴头.glb");
test.use({ viewport: { width: 1600, height: 900 } });
async function openStudioAndMonkey(page: import("@playwright/test").Page): Promise<void> {
await page.goto("/?ui=web");
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 });
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
}
async function ensureEditMode(page: import("@playwright/test").Page): Promise<void> {
const status = page.locator('[data-testid="engine-status"]');
if ((await status.innerText()).includes("模式:Edit")) return;
try {
await expect(status).toContainText("模式:Edit", { timeout: 5_000 });
return;
}
catch { /* fall through to the explicit mode menu */ }
await page.locator(".mode-chip button").first().evaluate((element) => (element as HTMLElement).click());
const editMode = page.getByRole("menuitemcheckbox", { name: /编辑模式/ });
await editMode.waitFor({ timeout: 15_000 });
await editMode.evaluate((element) => (element as HTMLElement).click());
await expect(status).toContainText("模式:Edit", { timeout: 60_000 });
await page.keyboard.press("Escape");
}
async function openUVWorkspace(page: import("@playwright/test").Page): Promise<void> {
// The native desktop canvas can sit above the workspace tab at the same
// coordinates in headed Chromium; invoke the tab's DOM click directly.
await page.locator(".workspace-tabs button", { hasText: "UV Editing" }).evaluate((element) => (element as HTMLElement).click());
await expect(page.locator(".wasm-uv-editor")).toBeVisible({ timeout: 60_000 });
await expect(page.locator(".uv-controls")).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => page.locator('[data-testid="editor-workflow-context"]').innerText(), { timeout: 60_000 })
.toContain("UV Editing");
// Desktop Blender's UV editor requires OB_MODE_EDIT (space_image.cc);
// enter Edit explicitly rather than depending on a startup timing quirk.
await ensureEditMode(page);
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("原生 UV 选择");
}
async function selectAllAndUnwrap(page: import("@playwright/test").Page): Promise<void> {
await page.locator(".wasm-uv-editor button", { hasText: "全选 UV" }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("UV 选择已提交");
await page.locator(".wasm-uv-editor button", { hasText: "展开所选 UV" }).evaluate((element) => (element as HTMLElement).click());
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("展开 UV已提交");
}
test("UV Editing finds a writable native UV area and commits an unwrap", async ({ page }) => {
test.setTimeout(300_000);
await openStudioAndMonkey(page);
await openUVWorkspace(page);
await expect(page.locator('[data-testid="engine-status"]')).toContainText("模式:Edit", { timeout: 60_000 });
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
await selectAllAndUnwrap(page);
const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
expect(after).toBeGreaterThan(before);
});
test("UV workspace survives save and reopen", async ({ page }) => {
test.setTimeout(360_000);
await openStudioAndMonkey(page);
await openUVWorkspace(page);
await selectAllAndUnwrap(page);
// Save through the OPFS project transaction (the download is a side effect
// of the same commit), then reload and reopen the recent OPFS project.
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
await page.reload();
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
await openUVWorkspace(page);
await expect(page.locator(".uv-controls")).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => page.locator(".wasm-uv-editor").innerText(), { timeout: 60_000 }).toContain("原生 UV 选择");
});