import { expect, test } from "@playwright/test"; import fs from "node:fs"; 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 MONKEY = path.resolve(HERE, "../../../tests/files/web/pbr-render-lesson/monkey-head.glb"); const RE_MATERIALS = [ "Black_Anti_Slip_grip", "Blackish_Brick_Wall_016", "black_randome_tile", "black_shinning_tile", "black_tiles_wall", "black_worn_wall_brick", ]; test.use({ viewport: { width: 1600, height: 1000 } }); const objectCount = async (page: import("@playwright/test").Page): Promise => { const text = await page.getByTestId("scene-stats").innerText(); return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0"); }; const resultMean = async (page: import("@playwright/test").Page): Promise => page.evaluate(() => { const image = document.querySelector(".pbr-render-result img"); if (!(image instanceof HTMLImageElement) || !image.naturalWidth) return 0; const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return 0; context.drawImage(image, 0, 0); const data = context.getImageData(0, 0, canvas.width, canvas.height).data; let sum = 0; for (let index = 0; index < data.length; index += 4) sum += (data[index] + data[index + 1] + data[index + 2]) / 3; return sum / (data.length / 4); }); async function openStudio(page: import("@playwright/test").Page): Promise { await page.goto("/?ui=web"); await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 }); await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 }); await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(6); const webTools = page.locator('[data-testid="native-web-tools-toggle"]'); if (await webTools.count()) await webTools.click(); } async function selectSuzanne(page: import("@playwright/test").Page): Promise { const expand = page.locator('.outliner-tree .tree-expand[aria-label^="展开"]').first(); if (await expand.count()) await expand.click(); const row = page.getByRole("treeitem").filter({ hasText: /Suzanne/ }).first(); await expect(row).toBeVisible({ timeout: 60_000 }); await row.click(); } test("RE 5000PBR: import monkey GLB, assign six materials, render and save", async ({ page }, testInfo) => { test.setTimeout(600_000); await openStudio(page); await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY); await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7); await expect(page.getByText(/Engine: GLB imported/).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined); await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }); await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 }); await expect(page.locator(".pbr-asset-card")).toHaveCount(20, { timeout: 90_000 }); await expect(page.locator('[data-library="library:re-pbr-v1"]')).toHaveCount(6, { timeout: 90_000 }); await expect.poll(async () => page.locator('[data-library="library:re-pbr-v1"] .pbr-asset-thumb img').evaluateAll((images) => images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 60_000 }).toBe(6); for (const name of RE_MATERIALS) { const card = page.locator('[data-library="library:re-pbr-v1"]', { hasText: name }).first(); await expect(card).toBeVisible({ timeout: 30_000 }); await card.dblclick(); await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 }) .toBe(`material:${name}`); await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "true", { timeout: 30_000 }).catch(() => undefined); await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 }); } await expect.poll(async () => page.locator(".material-preview-status").innerText({ timeout: 60_000 }), { timeout: 90_000 }) .toContain("COMPILED"); await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }); const lessonPanel = page.locator(".pbr-lesson-panel"); await expect(lessonPanel).toBeVisible({ timeout: 60_000 }); await expect(lessonPanel).toContainText("20 个资产"); await expect(lessonPanel).toContainText("6 个材质"); await expect(lessonPanel.getByRole("link", { name: /打开图文/ })) .toHaveAttribute("href", "/tutorial/pbr/render-lesson.html"); await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024"); await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1); await expect.poll(async () => page.locator(".pbr-render-result img").first().evaluate((image) => (image as HTMLImageElement).naturalWidth), { timeout: 60_000 }).toBe(1024); const panel = page.locator(".pbr-render-panel"); await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 60_000 }).toMatch(/^ready:/); await expect.poll(async () => Number(await panel.getAttribute("data-texture-assets") ?? "0"), { timeout: 60_000 }).toBeGreaterThan(0); const sourceTriangles = Number(await panel.getAttribute("data-displacement-source-triangles") ?? "0"); const renderTriangles = Number(await panel.getAttribute("data-displacement-render-triangles") ?? "0"); expect(sourceTriangles).toBeGreaterThan(0); expect(renderTriangles).toBeGreaterThan(sourceTriangles); expect(Number(await panel.getAttribute("data-displacement-level") ?? "0")).toBeGreaterThan(0); expect(Number(await panel.getAttribute("data-displacement-multiplier") ?? "0")).toBe(1); const mean1x = await resultMean(page); const png = page.waitForEvent("download"); await page.locator(".pbr-render-download").click(); const pngDownload = await png; expect(pngDownload.suggestedFilename()).toMatch(/\.png$/); const pngPath = testInfo.outputPath("re-monkey-1024.png"); await pngDownload.saveAs(pngPath); expect(fs.statSync(pngPath).size).toBeGreaterThan(1024); const blend = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目", exact: true }).click(); const blendDownload = await blend; const blendPath = testInfo.outputPath("re-monkey-saved.blend"); await blendDownload.saveAs(blendPath); expect(fs.statSync(blendPath).size).toBeGreaterThan(12); await page.locator('[data-testid="blend-file-input"]').setInputFiles(blendPath); await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 90_000 }); await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 90_000 }); await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7); const options = await page.locator('select[aria-label^="材质槽"]').first().locator("option").allTextContents(); expect(options.join(" ")).toContain(RE_MATERIALS[RE_MATERIALS.length - 1]); await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }); await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024"); await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 120_000 }).toBe(1); const reopenPanel = page.locator(".pbr-render-panel"); await expect.poll(async () => reopenPanel.getAttribute("data-texture-status"), { timeout: 120_000 }) .toMatch(/^ready:([1-9]\d*):0:/); const [expectedText, statusText] = await Promise.all([ reopenPanel.getAttribute("data-texture-expected"), reopenPanel.getAttribute("data-texture-status"), ]); expect(Number(statusText?.split(":")[1] ?? "0")).toBe(Number(expectedText ?? "0")); expect(Number(await reopenPanel.getAttribute("data-texture-revision"))).toBeGreaterThan(0); const scenePosition = await reopenPanel.getAttribute("data-render-camera-position"); expect(scenePosition).toBeTruthy(); await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON"); await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => reopenPanel.getAttribute("data-render-camera-position"), { timeout: 120_000 }) .not.toBe(scenePosition); await expect(reopenPanel).toHaveAttribute("data-view-preset", "COMPARISON"); expect(await reopenPanel.getAttribute("data-render-camera-position")).not.toBe(scenePosition); await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100"); await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE"); const before100 = await page.locator(".pbr-render-result img").getAttribute("src"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }) .not.toBe(before100); await expect(reopenPanel).toHaveAttribute("data-displacement-multiplier", "100"); const mean100x = await resultMean(page); expect(Math.abs(mean100x - mean1x)).toBeGreaterThan(0.01); const displacementDownload = page.waitForEvent("download"); await page.locator(".pbr-render-download").click(); const displacementPng = testInfo.outputPath("re-monkey-displacement-100x.png"); await (await displacementDownload).saveAs(displacementPng); expect(fs.statSync(displacementPng).size).toBeGreaterThan(1024); await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1"); await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("512"); for (const [mode, name] of [["BASE_COLOR", "base-color"], ["NORMAL", "normal"], ["ROUGHNESS", "roughness"]] as const) { await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode); await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0"); const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }) .not.toBe(previous); await expect(reopenPanel).toHaveAttribute("data-display-mode", mode); expect(Number(await reopenPanel.getAttribute("data-diagnostic-meshes") ?? "0")).toBeGreaterThan(0); const diagnosticDownload = page.waitForEvent("download"); await page.locator(".pbr-render-download").click(); const diagnosticPath = testInfo.outputPath(`diagnostic-${name}-512.png`); await (await diagnosticDownload).saveAs(diagnosticPath); expect(fs.statSync(diagnosticPath).size).toBeGreaterThan(1024); } await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL"); await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0"); const exposurePrevious = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposurePrevious); const exposure0 = await resultMean(page); await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2"); const exposureNext = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").getAttribute("src"), { timeout: 120_000 }).not.toBe(exposureNext); const exposure2 = await resultMean(page); expect(Math.abs(exposure2 - exposure0)).toBeGreaterThan(1); }); test("RE 5000PBR: truncated GLB is rejected without scene mutation", async ({ page }) => { test.setTimeout(240_000); await openStudio(page); const before = await objectCount(page); await page.locator('[data-testid="blend-file-input"]').setInputFiles({ name: "truncated.glb", mimeType: "model/gltf-binary", buffer: Buffer.from("glTF\x02\x00\x00\x00\x10\x00\x00\x00", "binary"), }); await expect.poll(() => objectCount(page), { timeout: 60_000 }).toBe(before); await expect(page.getByText(/GLB|glTF|导入失败|import/i).first()).toBeVisible({ timeout: 60_000 }).catch(() => undefined); }); test("RE 5000PBR native reader exposes HEIGHT displacement binding", async ({ page }) => { test.setTimeout(120_000); await page.goto("/"); const result = await page.evaluate(async () => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient({ timeoutMs: 60_000 }); try { await client.init(); const buffer = await (await fetch("/assets/re-pbr/re-pbr-library.blend")).arrayBuffer(); const opened = await client.openBlend(buffer); const material = opened.snapshot.materials.find((candidate) => candidate.name === "Black_Anti_Slip_grip"); return { binding: material?.textureBindings?.find((candidate) => candidate.usage === "HEIGHT") ?? null, displacementImageId: material?.displacementImageId ?? null, displacementScale: material?.displacementScale ?? null, displacementMidlevel: material?.displacementMidlevel ?? null, textureBindings: material?.textureBindings ?? null, nodeTypes: material?.nodes?.map((node) => node.type) ?? null, links: material?.links ?? null, materials: opened.snapshot.materials.map((candidate) => candidate.name), }; } finally { client.terminate(); } }); expect(result.binding).toMatchObject({ usage: "HEIGHT", midlevel: 0.5 }); expect(result.binding.scale).toBeCloseTo(0.001, 6); expect(result.displacementImageId).toContain("Height"); expect(result.displacementScale).toBeCloseTo(0.001, 6); expect(result.displacementMidlevel).toBeCloseTo(0.5, 6); }); test("RE 5000PBR: optional HQ 2048 library loads on demand and renders", async ({ page }, testInfo) => { test.setTimeout(600_000); await openStudio(page); await page.locator('[data-testid="blend-file-input"]').setInputFiles(MONKEY); await expect.poll(() => objectCount(page), { timeout: 90_000 }).toBe(7); await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }); const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载/ }); await expect(hqButton).toBeVisible({ timeout: 60_000 }); if (await hqButton.getAttribute("disabled") === null) { await hqButton.click(); } await expect(page.getByRole("button", { name: "高清贴图已加载" })).toBeVisible({ timeout: 180_000 }); await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }); await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 60_000 }); await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-hq-v1"); await expect(page.locator('[data-library="library:re-pbr-hq-v1"]')).toHaveCount(6, { timeout: 60_000 }); const card = page.locator('[data-library="library:re-pbr-hq-v1"]', { hasText: "black_worn_wall_brick_HQ" }).first(); await card.dblclick(); await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 180_000 }) .toBe("material:black_worn_wall_brick_HQ"); await expect(page.locator(".pbr-asset-grid")).toHaveAttribute("aria-busy", "false", { timeout: 180_000 }); await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }); await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024"); await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 180_000 }).toBe(1); const panel = page.locator(".pbr-render-panel"); await expect.poll(async () => panel.getAttribute("data-texture-status"), { timeout: 180_000 }).toMatch(/^ready:([1-9]\d*):0:/); expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeGreaterThan(0); expect(Number(await panel.getAttribute("data-texture-budget-gpu-bytes") ?? "0")).toBeLessThan(512 * 1024 * 1024); const download = page.waitForEvent("download"); await page.locator(".pbr-render-download").click(); const png = testInfo.outputPath("re-pbr-hq-2048.png"); await (await download).saveAs(png); expect(fs.statSync(png).size).toBeGreaterThan(1024); }); test("RE 5000PBR HQ native images keep 2048 dimensions", async ({ page }) => { test.setTimeout(120_000); await page.goto("/"); const images = await page.evaluate(async () => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient({ timeoutMs: 60_000 }); try { await client.init(); const buffer = await (await fetch("/assets/re-pbr-hq/re-pbr-library.blend")).arrayBuffer(); const opened = await client.openBlend(buffer); return opened.snapshot.images.map((image) => ({ name: image.name, width: image.width, height: image.height, tiles: image.tiles?.map((tile) => ({ width: tile.width, height: tile.height })) ?? null, })); } finally { client.terminate(); } }); const heightImages = images.filter((image) => /Height\.jpg$/.test(image.name)); expect(heightImages.length).toBeGreaterThanOrEqual(1); for (const image of heightImages) { expect(image.width).toBe(2048); expect(image.height).toBe(2048); } });