- Area headers get a per-area editor switcher backed by a shared AreaEditorContext; Preferences can hide it. - Animation camera area is a real navigable viewport aligned to the scene camera; Graph Editor adds channel selection, keyframe drag/ insert/delete and interpolation. - Geometry Nodes node locations round-trip through the native writer and reader, so saved graphs keep their layout. - Texture Paint can make linked library images local via a new native makeImageLocal command before packing strokes. - Compositor IMAGE and Render Layers nodes now persist to Main and execute against packed images / the last PBR render. - Spreadsheet row cap raised to 250k; Outliner gains Ctrl+A and arrow/ Home/End navigation. Co-Authored-By: Claude Code <noreply@anthropic.com>
142 lines
8.6 KiB
TypeScript
142 lines
8.6 KiB
TypeScript
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");
|
|
const TEXTURE = path.resolve(HERE, "../../app/public/tutorial/texture-paint/re-texture-base.png");
|
|
|
|
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 clickWorkspace(page: import("@playwright/test").Page, name: string): Promise<void> {
|
|
await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click());
|
|
}
|
|
|
|
async function assignReMaterialAndLocalTexture(page: import("@playwright/test").Page): Promise<void> {
|
|
await clickWorkspace(page, "Shading");
|
|
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
|
|
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
|
|
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6);
|
|
await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick();
|
|
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 })
|
|
.toContain("material:black_tiles_wall");
|
|
await page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first().setInputFiles(TEXTURE);
|
|
await expect.poll(async () => (await page.locator('select[aria-label="材质图片"] option').allTextContents()).some((text) => text.includes("re-texture-base")), { timeout: 60_000 })
|
|
.toBe(true);
|
|
await page.locator('select[aria-label="材质图片"]').first().selectOption({ label: "re-texture-base" });
|
|
await page.waitForTimeout(2_000);
|
|
}
|
|
|
|
async function openTexturePaint(page: import("@playwright/test").Page): Promise<void> {
|
|
await clickWorkspace(page, "Texture Paint");
|
|
await page.locator(".texture-paint-view").waitFor({ timeout: 60_000 });
|
|
await page.locator('[data-testid="texture-paint-canvas"]').waitFor({ timeout: 60_000 });
|
|
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-image-id"), { timeout: 60_000 })
|
|
.toContain("re-texture-base");
|
|
await expect.poll(async () => page.locator(".texture-paint-controls output").first().innerText(), { timeout: 60_000 })
|
|
.toContain("已加载");
|
|
}
|
|
|
|
async function centerPixel(page: import("@playwright/test").Page): Promise<[number, number, number, number] | null> {
|
|
return page.evaluate(() => {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="texture-paint-canvas"]');
|
|
const context = canvas?.getContext("2d");
|
|
if (!canvas || !context) return null;
|
|
const data = context.getImageData(Math.floor(canvas.width * 0.5), Math.floor(canvas.height * 0.5), 1, 1).data;
|
|
return [data[0], data[1], data[2], data[3]];
|
|
});
|
|
}
|
|
|
|
async function paintRedThroughCenter(page: import("@playwright/test").Page): Promise<void> {
|
|
await page.locator('[aria-label="画笔颜色"]').fill("#ff0000");
|
|
await page.locator('[aria-label="画笔大小"]').fill("120");
|
|
const box = await page.locator('[data-testid="texture-paint-canvas"]').boundingBox();
|
|
if (!box) throw new Error("texture paint canvas has no box");
|
|
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.5, { steps: 10 });
|
|
await page.mouse.up();
|
|
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 })
|
|
.toBe("笔画已提交");
|
|
}
|
|
|
|
test("Texture Paint paints a local image, commits one stroke and undoes it", async ({ page }) => {
|
|
test.setTimeout(300_000);
|
|
await openStudioAndMonkey(page);
|
|
await assignReMaterialAndLocalTexture(page);
|
|
await openTexturePaint(page);
|
|
const before = await centerPixel(page);
|
|
await paintRedThroughCenter(page);
|
|
const after = await centerPixel(page);
|
|
expect(after?.[0]).toBeGreaterThan(200);
|
|
expect(after?.[1]).toBeLessThan(40);
|
|
expect(after?.[2]).toBeLessThan(40);
|
|
await page.getByRole("button", { name: "撤销笔画", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
|
await expect.poll(async () => page.locator(".texture-paint-view").getAttribute("data-last-action"), { timeout: 60_000 }).toBe("已撤销");
|
|
const undone = await centerPixel(page);
|
|
expect(undone?.[0]).toBe(before?.[0]);
|
|
expect(undone?.[1]).toBe(before?.[1]);
|
|
expect(undone?.[2]).toBe(before?.[2]);
|
|
});
|
|
|
|
test("Texture Paint makes a linked library image local before painting", async ({ page }) => {
|
|
test.setTimeout(300_000);
|
|
await openStudioAndMonkey(page);
|
|
await clickWorkspace(page, "Shading");
|
|
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
|
|
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
|
|
await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"]').count(), { timeout: 60_000 }).toBe(6);
|
|
await page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first().dblclick();
|
|
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 120_000 }).toContain("material:black_tiles_wall");
|
|
await clickWorkspace(page, "Texture Paint");
|
|
const view = page.locator(".texture-paint-view");
|
|
await view.waitFor({ timeout: 60_000 });
|
|
const imageSelect = view.getByLabel("纹理图像");
|
|
await expect.poll(async () => (await imageSelect.locator("option").allTextContents()).some((text) => text.includes("black_tiles_wall")), { timeout: 60_000 }).toBe(true);
|
|
await imageSelect.selectOption({ label: (await imageSelect.locator("option").allTextContents()).find((text) => text.includes("black_tiles_wall"))! });
|
|
await view.getByRole("button", { name: "设为本地图像", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
|
await expect.poll(async () => view.locator(".texture-paint-last-action").innerText(), { timeout: 60_000 }).toContain("已设为本地");
|
|
const canvas = view.getByTestId("texture-paint-canvas");
|
|
await canvas.waitFor({ timeout: 60_000 });
|
|
const box = await canvas.boundingBox();
|
|
if (!box) throw new Error("texture paint canvas has no box");
|
|
await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.5);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.55, { steps: 12 });
|
|
await page.mouse.up();
|
|
await expect.poll(async () => view.getAttribute("data-last-action"), { timeout: 60_000 }).toBe("笔画已提交");
|
|
});
|
|
|
|
test("Texture Paint stroke survives an OPFS save and reopen", async ({ page }) => {
|
|
test.setTimeout(360_000);
|
|
await openStudioAndMonkey(page);
|
|
await assignReMaterialAndLocalTexture(page);
|
|
await openTexturePaint(page);
|
|
await paintRedThroughCenter(page);
|
|
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 openTexturePaint(page);
|
|
const pixel = await centerPixel(page);
|
|
expect(pixel?.[0]).toBeGreaterThan(200);
|
|
expect(pixel?.[1]).toBeLessThan(40);
|
|
expect(pixel?.[2]).toBeLessThan(40);
|
|
});
|