feat(web): complete PBR shader, asset library and offscreen render chain
- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS - add React asset browser, material preview sphere and Web PBR render panel - extend shader graph/compiler to the finite 14-node set and native properties - add image colour-space editing, scene-camera still rendering and asset repair - add focused E2E/unit coverage and implementation status notes Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
77
web/tests/e2e/pbr-asset-library-production.spec.ts
Normal file
77
web/tests/e2e/pbr-asset-library-production.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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 PBR_FIXTURE = path.resolve(HERE, "../../../tests/files/web/m12_glb_main_v1/pbr.blend");
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
const objectCount = async (page: import("@playwright/test").Page): Promise<number> => {
|
||||
const text = await page.getByTestId("scene-stats").innerText();
|
||||
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
|
||||
};
|
||||
|
||||
async function openShading(page: import("@playwright/test").Page) {
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(PBR_FIXTURE);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 60_000 });
|
||||
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 60_000 });
|
||||
const webTools = page.locator('[data-testid="native-web-tools-toggle"]');
|
||||
if (await webTools.count()) await webTools.click();
|
||||
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
||||
await expect(page.locator(".pbr-asset-library")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator(".pbr-asset-card")).toHaveCount(14, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
test("PBR asset library seeds OPFS, appends a model, assigns a material and renders", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await openShading(page);
|
||||
|
||||
await expect.poll(async () => page.locator(".pbr-asset-thumb img").evaluateAll((images) =>
|
||||
images.filter((image) => (image as HTMLImageElement).naturalWidth > 0).length), { timeout: 30_000 }).toBe(14);
|
||||
|
||||
const before = await objectCount(page);
|
||||
await page.locator(".pbr-asset-card", { hasText: "PBR Cube" }).first().dblclick();
|
||||
await expect.poll(() => objectCount(page), { timeout: 30_000 }).toBeGreaterThan(before);
|
||||
|
||||
await page.locator(".pbr-asset-card", { hasText: "PBR Metal Brushed" }).first().dblclick();
|
||||
await expect.poll(async () => page.locator('select[aria-label^="材质槽"]').first().inputValue(), { timeout: 30_000 })
|
||||
.toBe("material:PBR Metal Brushed");
|
||||
await expect.poll(async () => page.locator(".material-preview-status").innerText(), { timeout: 30_000 }).toContain("COMPILED");
|
||||
|
||||
await page.getByRole("button", { name: "Rendering", exact: true }).click();
|
||||
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").count(), { timeout: 90_000 }).toBe(1);
|
||||
await expect.poll(async () => page.locator(".pbr-render-result img").first().evaluate((image) =>
|
||||
(image as HTMLImageElement).naturalWidth), { timeout: 30_000 }).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("seeded PBR asset catalog and previews survive a page reload", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openShading(page);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".blender-app")).toBeVisible({ timeout: 60_000 });
|
||||
const persisted = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const client = new StorageClient();
|
||||
try {
|
||||
const catalog = await client.getAssetCatalog();
|
||||
const preview = catalog.manifest?.assets.find((asset) => asset.preview)?.preview;
|
||||
const bytes = preview ? await client.readAssetCatalogPreview("asset-library", preview.sha256) : null;
|
||||
return {
|
||||
assets: catalog.manifest?.assets.length ?? 0,
|
||||
revision: catalog.manifest?.revision ?? 0,
|
||||
seedId: catalog.seed?.seedId ?? "",
|
||||
previewBytes: bytes?.data.byteLength ?? 0,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
}
|
||||
});
|
||||
expect(persisted.assets).toBe(14);
|
||||
expect(persisted.seedId).toBe("pbr-essentials-v1");
|
||||
expect(persisted.previewBytes).toBeGreaterThan(0);
|
||||
});
|
||||
46
web/tests/e2e/shader-editor-all-nodes-production.spec.ts
Normal file
46
web/tests/e2e/shader-editor-all-nodes-production.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 PBR_FIXTURE = path.resolve(HERE, "../../../tests/files/web/m12_glb_main_v1/pbr.blend");
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
async function openShading(page: import("@playwright/test").Page) {
|
||||
await page.goto("/?ui=web");
|
||||
await page.locator('[data-testid="blend-file-input"]').setInputFiles(PBR_FIXTURE);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-project-id", /.+/, { timeout: 60_000 });
|
||||
await expect(page.getByText(/Engine: SceneIR/).first()).toBeVisible({ timeout: 60_000 });
|
||||
const webTools = page.locator('[data-testid="native-web-tools-toggle"]');
|
||||
if (await webTools.count()) await webTools.click();
|
||||
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
|
||||
await expect(page.locator(".blender-shader-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(2_500);
|
||||
}
|
||||
|
||||
test("shader editor exposes, edits and compiles the finite 14-node set", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await openShading(page);
|
||||
const editor = page.locator(".wasm-shader-editor");
|
||||
|
||||
for (const label of ["Math", "Separate Color", "Mix", "Mapping", "Bump", "UV Map", "Texture Coordinate"]) {
|
||||
await editor.getByRole("button", { name: "添加节点" }).click();
|
||||
await page.locator(".blender-shader-add-menu button", { hasText: new RegExp(`^${label}$`) }).first().click();
|
||||
await expect(editor.locator(".blender-shader-node-type", { hasText: label })).toHaveCount(1, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
await editor.getByRole("button", { name: "框显节点" }).click();
|
||||
await page.waitForTimeout(1_000);
|
||||
const math = editor.locator(".blender-shader-node").filter({ has: page.locator(".blender-shader-node-type", { hasText: /^Math$/ }) }).first();
|
||||
const operation = math.locator("select").first();
|
||||
await operation.selectOption("MULTIPLY");
|
||||
await expect(operation).toHaveValue("MULTIPLY", { timeout: 20_000 });
|
||||
|
||||
const separate = editor.locator(".blender-shader-node").filter({ has: page.locator(".blender-shader-node-type", { hasText: /^Separate Color$/ }) }).first();
|
||||
const mode = separate.locator("select").first();
|
||||
await mode.selectOption("HSV");
|
||||
await expect(mode).toHaveValue("HSV", { timeout: 20_000 });
|
||||
|
||||
await expect.poll(async () => editor.locator(".material-preview-status").innerText(), { timeout: 30_000 }).toContain("COMPILED");
|
||||
});
|
||||
@@ -11,7 +11,7 @@ const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+
|
||||
|
||||
test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await page.goto("/?engine=manual");
|
||||
const result = await page.evaluate(async ({ bytes, pngBase64: inputPngBase64 }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const digest = async (value: ArrayBuffer) => {
|
||||
@@ -34,7 +34,7 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
};
|
||||
};
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
let reopenedClient: WebEngineClient | null = null;
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(bytes).buffer);
|
||||
const material = opened.snapshot.materials[0];
|
||||
@@ -55,19 +55,19 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
id: "rgb",
|
||||
type: "RGB",
|
||||
name: "WBV2 RGB",
|
||||
sockets: [{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] }],
|
||||
sockets: [{ id: "Color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] }],
|
||||
},
|
||||
{
|
||||
id: "valueA",
|
||||
type: "VALUE",
|
||||
name: "WBV2 Value A",
|
||||
sockets: [{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }],
|
||||
sockets: [{ id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }],
|
||||
},
|
||||
{
|
||||
id: "valueB",
|
||||
type: "VALUE",
|
||||
name: "WBV2 Value B",
|
||||
sockets: [{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.22 }],
|
||||
sockets: [{ id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.22 }],
|
||||
},
|
||||
{
|
||||
id: "math",
|
||||
@@ -75,9 +75,9 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
name: "WBV2 Add",
|
||||
properties: { operation: "ADD" },
|
||||
sockets: [
|
||||
{ id: "a", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "b", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE" },
|
||||
{ id: "Value_001", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "Value_002", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "Value", name: "Value", direction: "OUTPUT", dataType: "VALUE" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,7 +86,7 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
name: "WBV2 Image Texture",
|
||||
imageId: importedImage.id,
|
||||
sockets: [
|
||||
{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR" },
|
||||
{ id: "Color", name: "Color", direction: "OUTPUT", dataType: "COLOR" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -94,8 +94,8 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
type: "NORMAL_MAP",
|
||||
name: "WBV2 Normal Map",
|
||||
sockets: [
|
||||
{ id: "color", name: "Color", direction: "INPUT", dataType: "COLOR" },
|
||||
{ id: "normal", name: "Normal", direction: "OUTPUT", dataType: "VECTOR" },
|
||||
{ id: "Color", name: "Color", direction: "INPUT", dataType: "COLOR" },
|
||||
{ id: "Normal", name: "Normal", direction: "OUTPUT", dataType: "VECTOR" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,27 +103,27 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
type: "PRINCIPLED",
|
||||
name: "WBV2 Principled",
|
||||
sockets: [
|
||||
{ id: "base", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] },
|
||||
{ id: "roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.42 },
|
||||
{ id: "normal", name: "Normal", direction: "INPUT", dataType: "VECTOR" },
|
||||
{ id: "bsdf", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" },
|
||||
{ id: "Base Color", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.17, 0.27, 0.37, 1] },
|
||||
{ id: "Roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.42 },
|
||||
{ id: "Normal", name: "Normal", direction: "INPUT", dataType: "VECTOR" },
|
||||
{ id: "BSDF", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "output",
|
||||
type: "MATERIAL_OUTPUT",
|
||||
name: "WBV2 Output",
|
||||
sockets: [{ id: "surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }],
|
||||
sockets: [{ id: "Surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }],
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "rgb", fromSocketId: "color", toNodeId: "principled", toSocketId: "base" },
|
||||
{ fromNodeId: "valueA", fromSocketId: "value", toNodeId: "math", toSocketId: "a" },
|
||||
{ fromNodeId: "valueB", fromSocketId: "value", toNodeId: "math", toSocketId: "b" },
|
||||
{ fromNodeId: "math", fromSocketId: "value", toNodeId: "principled", toSocketId: "roughness" },
|
||||
{ fromNodeId: "image", fromSocketId: "color", toNodeId: "normal", toSocketId: "color" },
|
||||
{ fromNodeId: "normal", fromSocketId: "normal", toNodeId: "principled", toSocketId: "normal" },
|
||||
{ fromNodeId: "principled", fromSocketId: "bsdf", toNodeId: "output", toSocketId: "surface" },
|
||||
{ fromNodeId: "rgb", fromSocketId: "Color", toNodeId: "principled", toSocketId: "Base Color" },
|
||||
{ fromNodeId: "valueA", fromSocketId: "Value", toNodeId: "math", toSocketId: "Value_002" },
|
||||
{ fromNodeId: "valueB", fromSocketId: "Value", toNodeId: "math", toSocketId: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocketId: "Value", toNodeId: "principled", toSocketId: "Roughness" },
|
||||
{ fromNodeId: "image", fromSocketId: "Color", toNodeId: "normal", toSocketId: "Color" },
|
||||
{ fromNodeId: "normal", fromSocketId: "Normal", toNodeId: "principled", toSocketId: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocketId: "BSDF", toNodeId: "output", toSocketId: "Surface" },
|
||||
],
|
||||
};
|
||||
const baseRevision = importedSnapshot.snapshot.revision;
|
||||
@@ -141,6 +141,8 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
}).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message }));
|
||||
const afterMalformed = await client.snapshot();
|
||||
const saved = await client.saveBlend();
|
||||
client.terminate();
|
||||
reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopened = await reopenedClient.openBlend(saved.slice(0));
|
||||
return {
|
||||
browser: "chromium",
|
||||
@@ -164,7 +166,7 @@ test("WBV2-P1113-000 writes Shader Graph through native Blender Main cmd json",
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
reopenedClient.terminate();
|
||||
reopenedClient?.terminate();
|
||||
}
|
||||
}, { bytes: Array.from(fixture), pngBase64 });
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+
|
||||
|
||||
test("WBV2-P1100-003 preserves imported Image and shader image-node links", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
await page.goto("/?engine=manual");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
let reopenedClient: WebEngineClient | null = null;
|
||||
const summary = (snapshot: any, imageId: string, materialId: string) => {
|
||||
const image = snapshot.images?.find((entry: any) => entry.id === imageId);
|
||||
const material = snapshot.materials?.find((entry: any) => entry.id === materialId);
|
||||
@@ -36,12 +36,14 @@ test("WBV2-P1100-003 preserves imported Image and shader image-node links", asyn
|
||||
const malformed = await client.applyCommand({ type: "setMaterialImageNode", materialId: material.id, imageId: "image:missing", usage: "BASE_COLOR" } as any).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message }));
|
||||
const afterMalformed = await client.snapshot();
|
||||
const saved = await client.saveBlend();
|
||||
client.terminate();
|
||||
reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopened = await reopenedClient.openBlend(saved.slice(0));
|
||||
const afterReopen = summary(reopened.snapshot, image.id, material.id);
|
||||
return { fixtureSha256: input.fixtureSha256, materialId: material.id, imageId: image.id, importedRevision: imported.snapshot.revision, linkedRevision: linked.snapshot.revision, afterLink, undo: summary(undo.snapshot, image.id, material.id), redo: summary(redo.snapshot, image.id, material.id), malformed, malformedRevisionPreserved: afterMalformed.snapshot.revision === redo.snapshot.revision, afterReopen, savedSha256: await crypto.subtle.digest("SHA-256", saved).then((hash) => Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("")) };
|
||||
}
|
||||
catch (error: any) { return { error: { code: error?.code, message: error?.message, detail: String(error), importedImageCount: error?.importedImageCount, importedMaterialCount: error?.importedMaterialCount, importedRevision: error?.importedRevision } } as any; }
|
||||
finally { client.terminate(); reopenedClient.terminate(); }
|
||||
finally { client.terminate(); reopenedClient?.terminate(); }
|
||||
}, { bytes: Array.from(fixture), fixtureSha256, pngBase64 });
|
||||
fs.mkdirSync(path.join(root, "tests/golden/WBV2-P1100-003"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "tests/golden/WBV2-P1100-003/material-image-production.json"), `${JSON.stringify({ schemaVersion: 1, task: "WBV2-P1100-003", operation: "MATERIAL_IMAGE_MAIN_CHROMIUM", browser: "chromium", queueMutation: false, ...result }, null, 2)}\n`);
|
||||
|
||||
@@ -14,11 +14,11 @@ const slotOperation = process.env.WBV2_BATCH_SLOT_OPERATION ?? "ADD";
|
||||
|
||||
test("WBV2-P1100-003 preserves addMaterialSlot through Main recovery and reopen", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
await page.goto("/?engine=manual");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
let reopenedClient: WebEngineClient | null = null;
|
||||
const materialNames = (snapshot: any) => snapshot.materials.map((material: any) => material.name);
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input.bytes).buffer);
|
||||
@@ -33,11 +33,13 @@ test("WBV2-P1100-003 preserves addMaterialSlot through Main recovery and reopen"
|
||||
const malformed = await client.applyCommand({ type: input.slotOperation === "REMOVE" ? "removeMaterialSlot" : "addMaterialSlot", objectId: "object:missing", ...(input.slotOperation === "REMOVE" ? { slotIndex: 0 } : { name: "Bad" }) } as any).then(() => ({ code: "NO_ERROR" })).catch((error: any) => ({ code: error?.code, message: error?.message }));
|
||||
const afterMalformed = await client.snapshot();
|
||||
const saved = await client.saveBlend();
|
||||
client.terminate();
|
||||
reopenedClient = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const reopened = await reopenedClient.openBlend(saved.slice(0));
|
||||
return { fixtureSha256: input.fixtureSha256, objectId: object.id, before, afterAdd, undo: materialNames(undo.snapshot), redo: materialNames(redo.snapshot), malformed, malformedRevisionPreserved: afterMalformed.snapshot.revision === redo.snapshot.revision, reopened: materialNames(reopened.snapshot), beforeRevision: opened.snapshot.revision, addRevision: added.snapshot.revision, undoRevision: undo.snapshot.revision, redoRevision: redo.snapshot.revision, reopenedRevision: reopened.snapshot.revision, savedSha256: await crypto.subtle.digest("SHA-256", saved).then((hash) => Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("")) };
|
||||
}
|
||||
catch (error: any) { return { error: { code: error?.code, message: error?.message, detail: String(error) } } as any; }
|
||||
finally { client.terminate(); reopenedClient.terminate(); }
|
||||
finally { client.terminate(); reopenedClient?.terminate(); }
|
||||
}, { bytes: Array.from(fixture), fixtureSha256, slotOperation });
|
||||
fs.mkdirSync(path.dirname(path.join(root, productionPath)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, productionPath), `${JSON.stringify({ schemaVersion: 1, task, operation: `MATERIAL_SLOT_${slotOperation}_MAIN_CHROMIUM`, browser: "chromium", queueMutation: false, fixture: fixturePath, fixtureSha256, sourceUnits, ...result }, null, 2)}\n`);
|
||||
|
||||
@@ -11,7 +11,7 @@ const packageJson = JSON.parse(
|
||||
|
||||
test("browser runtime dependencies stay local", () => {
|
||||
const runtimePackages = Object.keys(packageJson.dependencies ?? {});
|
||||
assert.deepEqual([...runtimePackages].sort(), ["@ffmpeg/core", "@ffmpeg/ffmpeg", "@ffmpeg/util", "@webgpu/glslang", "fflate", "naga-wasm", "react", "react-dom"].sort());
|
||||
assert.deepEqual([...runtimePackages].sort(), ["@ffmpeg/core", "@ffmpeg/ffmpeg", "@ffmpeg/util", "@webgpu/glslang", "@xyflow/react", "fflate", "naga-wasm", "react", "react-accessible-treeview", "react-dom"].sort());
|
||||
assert.equal(packageJson.dependencies?.three, undefined);
|
||||
assert.equal(packageJson.dependencies?.["@sqlite.org/sqlite-wasm"], undefined);
|
||||
for (const name of runtimePackages) {
|
||||
|
||||
@@ -177,3 +177,94 @@ test("M10-08 rejects an unknown renderer backend and malformed texture identity"
|
||||
assert.equal(malformed.status, "BLOCKED");
|
||||
assert.equal(malformed.issues.at(-1).code, "SHADER_INVALID_GRAPH");
|
||||
});
|
||||
|
||||
test("M10-07 compiles Mix constants into the Principled base color", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "color-a", type: "RGB", name: "A", defaultValue: [1, 0, 0, 1] },
|
||||
{ id: "color-b", type: "RGB", name: "B", defaultValue: [0, 0, 1, 1] },
|
||||
{ id: "factor", type: "VALUE", name: "Factor", defaultValue: [0.5] },
|
||||
{ id: "mix", type: "MIX", name: "Mix", properties: { dataType: "RGBA", blendType: "MIX" } },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "factor", fromSocket: "Value", toNodeId: "mix", toSocket: "Factor" },
|
||||
{ fromNodeId: "color-a", fromSocket: "Color", toNodeId: "mix", toSocket: "A" },
|
||||
{ fromNodeId: "color-b", fromSocket: "Color", toNodeId: "mix", toSocket: "B" },
|
||||
{ fromNodeId: "mix", fromSocket: "Result", toNodeId: "principled", toSocket: "Base Color" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}));
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.deepEqual(report.material.baseColor.map((value) => Number(value.toFixed(4))), [0.5, 0, 0.5, 1]);
|
||||
});
|
||||
|
||||
test("M10-07 compiles Separate Color constants into scalar inputs", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "color", type: "RGB", name: "Color", defaultValue: [0.2, 0.4, 0.6, 1] },
|
||||
{ id: "separate", type: "SEPARATE_COLOR", name: "Separate", properties: { mode: "RGB" } },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "color", fromSocket: "Color", toNodeId: "separate", toSocket: "Color" },
|
||||
{ fromNodeId: "separate", fromSocket: "Red", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}));
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.equal(report.material.roughness, 0.2);
|
||||
});
|
||||
|
||||
test("M10-07 compiles Mapping -> Image -> Bump with a UV transform binding", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "uv", type: "UV_MAP", name: "UV", properties: { uvMap: "UVMap" } },
|
||||
{ id: "mapping", type: "MAPPING", name: "Mapping", properties: { vectorType: "POINT" }, sockets: [
|
||||
{ id: "Location", name: "Location", direction: "INPUT", dataType: "VECTOR", value: [0.1, 0.2, 0] },
|
||||
{ id: "Scale", name: "Scale", direction: "INPUT", dataType: "VECTOR", value: [2, 3, 1] },
|
||||
{ id: "Rotation", name: "Rotation", direction: "INPUT", dataType: "VECTOR", value: [0.5, 0, 0] },
|
||||
] },
|
||||
{ id: "bump-image", type: "IMAGE_TEXTURE", name: "Bump Image", imageId: "image:Bump" },
|
||||
{ id: "bump", type: "BUMP", name: "Bump" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "uv", fromSocket: "UV", toNodeId: "mapping", toSocket: "Vector" },
|
||||
{ fromNodeId: "mapping", fromSocket: "Vector", toNodeId: "bump-image", toSocket: "Vector" },
|
||||
{ fromNodeId: "bump-image", fromSocket: "Color", toNodeId: "bump", toSocket: "Height" },
|
||||
{ fromNodeId: "bump", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}), { imageIds: new Set(["image:Bump"]) });
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.deepEqual(report.textureBindings, [{
|
||||
imageId: "image:Bump",
|
||||
usage: "BUMP",
|
||||
uvMap: "UVMap",
|
||||
uvTransform: { offset: [0.1, 0.2], rotation: 0.5, scale: [2, 3] },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("M10-07 keeps texture-fed Mix blocked with a visible issue", () => {
|
||||
const report = compiler.compileMaterialGraph(material({
|
||||
nodes: [
|
||||
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:Color" },
|
||||
{ id: "color", type: "RGB", name: "Color", defaultValue: [0, 0, 0, 1] },
|
||||
{ id: "mix", type: "MIX", name: "Mix", properties: { dataType: "RGBA", blendType: "MIX" } },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "mix", toSocket: "A" },
|
||||
{ fromNodeId: "color", fromSocket: "Color", toNodeId: "mix", toSocket: "B" },
|
||||
{ fromNodeId: "mix", fromSocket: "Result", toNodeId: "principled", toSocket: "Base Color" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
}), { imageIds: new Set(["image:Color"]) });
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.ok(report.issues.some((entry) => entry.message.includes("Mix color inputs")));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user