feat(web): complete PBR shader, asset library and offscreen render chain
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

- 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:
mes123456
2026-11-11 14:59:43 +08:00
parent d1e6b63d54
commit a078648297
65 changed files with 6083 additions and 157 deletions

View 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);
});

View 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");
});

View File

@@ -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 });

View File

@@ -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`);

View File

@@ -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`);

View File

@@ -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) {

View File

@@ -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")));
});