- Add TexturePaintView with brush, stroke, undo/redo and OPFS persistence. - Import/pack local images with a valid ImageTile and replace stale packed files. - Sync the engine packed asset cache after Main refresh and pack operations. - Add pixel-level texture paint E2E for draw/undo and OPFS reopen. - Document linked-image limits and the local RE-derived practice texture. Co-Authored-By: Claude Code <noreply@anthropic.com>
114 lines
6.5 KiB
TypeScript
114 lines
6.5 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 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);
|
|
});
|