import { expect, test } from "@playwright/test"; import path from "node:path"; const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend"); const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"); const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/animation_scene.blend"); const riggedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/rigged_shape_scene.blend"); const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"); const greasePencilBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"); const localTexturePng = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png"); const deformationGolden = path.resolve(import.meta.dirname, "../../../tests/golden/W-079/blender-deformation.json"); interface DepsgraphE2EReport { status: string; objectCount: number; meshObjectCount: number; meshes: Array<{ sourceMeshId: string; vertexCount: number; triangleCount: number; positions: number[]; indices: number[]; modifierCount: number; modifiers: Array<{ uuid: string; index: number; type: string; status: "EVALUATED" | "DISABLED" | "BLOCKED"; showViewport: boolean; targetObjectIds?: string[]; errorCode?: string; suggestion?: string; dependsOn?: string[]; }>; }>; } test("opens the Blender-style editor and initializes workers", async ({ page }) => { const externalRequests: string[] = []; let appOrigin = ""; page.on("request", (request) => { if (appOrigin && new URL(request.url()).origin !== appOrigin) externalRequests.push(request.url()); }); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); appOrigin = new URL(page.url()).origin; await expect(page.getByText("Blender Web", { exact: true }).first()).toBeVisible(); await expect(page.getByRole("button", { name: "Layout" })).toHaveClass(/active/); await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1"); await expect(page.locator(".status-bar")).toContainText("Engine: ready, open a .blend file", { timeout: 20_000 }); await expect(page.locator(".status-bar")).toContainText(/Storage: (IndexedDB \+ OPFS|IndexedDB)/, { timeout: 20_000 }); await expect(page.locator("canvas.viewport-canvas")).toBeVisible(); await page.reload(); await expect(page.locator(".status-bar")).toContainText(/Storage: (IndexedDB \+ OPFS|IndexedDB)/, { timeout: 20_000 }); expect(externalRequests).toEqual([]); }); test("migrates IndexedDB metadata and creates a validated OPFS project layout", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ schemaVersion: number; stores: string[]; projectPath?: string }>((resolve, reject) => { const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" }); const infoId = "storage-info-e2e"; const projectId = "layout-e2e"; let info: { schemaVersion: number; stores: string[] } | undefined; worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { schemaVersion: number; stores: string[]; scenePath?: string }; error?: string }>) => { if (event.data.requestId === infoId && event.data.ok) { info = event.data.result; worker.postMessage({ requestId: "storage-project-e2e", command: { type: "ensureProject", projectId } }); return; } if (event.data.requestId !== "storage-project-e2e") return; worker.terminate(); if (!event.data.ok || !event.data.result) { reject(new Error(event.data.error ?? "storage project layout failed")); return; } if (!info) { reject(new Error("storage info response missing")); return; } resolve({ schemaVersion: info.schemaVersion, stores: info.stores, projectPath: event.data.result.scenePath }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId: infoId, command: { type: "info" } }); })); expect(result.schemaVersion).toBe(6); expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"])); expect(result.projectPath).toBe("projects/layout-e2e/scene.blend"); }); test("atomically persists a verified blend before updating project metadata", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ backend: string; bytes: number; revision: number; persisted: boolean; sha256: string }>((resolve, reject) => { const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" }); const buffer = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer; const requestId = "storage-save-e2e"; worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { backend: string; bytes: number; revision: number; persisted: boolean; sha256: string }; error?: string }>) => { if (event.data.requestId !== requestId) return; worker.terminate(); if (!event.data.ok || !event.data.result) { reject(new Error(event.data.error ?? "storage save failed")); return; } resolve(event.data.result); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId, command: { type: "saveProject", projectId: "save-e2e", revision: 7, buffer } }, [buffer]); })); expect(result.backend).toBe("opfs"); expect(result.bytes).toBe(5); expect(result.revision).toBe(7); expect(result.persisted).toBe(true); expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); }); test("recovers verified atomic saves after worker failures at both commit boundaries", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const cases = ["after-stage", "after-scene-commit"] as const; const recovered = []; for (const [index, faultAt] of cases.entries()) { const projectId = `recovery-e2e-${Date.now()}-${index}`; const expected = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index, 10, 20, 30]); const firstClient = new StorageClient(); let fault = ""; try { await firstClient.saveProject(projectId, 40 + index, expected.slice().buffer, faultAt); } catch (error) { fault = error instanceof Error ? error.message : String(error); } firstClient.terminate(); const restartedClient = new StorageClient(); const restored = await restartedClient.readProject(projectId); const clean = await restartedClient.recoverProject(projectId); restartedClient.terminate(); recovered.push({ faultAt, fault, revision: restored.revision, bytes: Array.from(new Uint8Array(restored.buffer)), sha256: restored.sha256, recovered: restored.recovered, cleanStatus: clean.status, cleanRecovered: clean.recovered, }); } return recovered; }); expect(result).toHaveLength(2); for (const [index, item] of result.entries()) { expect(item.fault).toContain(`PROJECT_SAVE_FAULT_INJECTED: ${item.faultAt}`); expect(item.revision).toBe(40 + index); expect(item.bytes).toEqual([0x42, 0x4c, 0x45, 0x4e, 0x44, index, 10, 20, 30]); expect(item.sha256).toMatch(/^[a-f0-9]{64}$/); expect(item.recovered).toBe(true); expect(item.cleanStatus).toBe("clean"); expect(item.cleanRecovered).toBe(false); } }); test("serializes concurrent saves for one project without journal races", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const client = new StorageClient(); const projectId = `save-order-e2e-${Date.now()}`; await Promise.all([ client.saveProject(projectId, 8, Uint8Array.from([8, 8, 8]).buffer), client.saveProject(projectId, 9, Uint8Array.from([9, 9, 9, 9]).buffer), ]); const restored = await client.readProject(projectId); client.terminate(); return { revision: restored.revision, bytes: Array.from(new Uint8Array(restored.buffer)), recovered: restored.recovered, }; }); expect(result).toEqual({ revision: 9, bytes: [9, 9, 9, 9], recovered: false }); }); test("content-addresses assets, deduplicates them, and rediscovers them after worker restart", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const firstClient = new StorageClient(); const concurrent = await Promise.all([firstClient.info(), firstClient.ensureProject("asset-e2e")]); const firstBytes = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]).buffer; const first = await firstClient.putAsset("asset-e2e", firstBytes, "image/png", "//textures/checker.png"); const second = await firstClient.putAsset("asset-e2e", Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]).buffer, "image/png", "textures/checker.png"); firstClient.terminate(); const secondClient = new StorageClient(); const listed = await secondClient.listAssets("asset-e2e"); const restored = await secondClient.readAsset("asset-e2e", first.sha256); let traversalError = ""; try { await secondClient.putAsset("asset-e2e", Uint8Array.from([1]).buffer, "image/png", "../escape.png"); } catch (error) { traversalError = error instanceof Error ? error.message : String(error); } secondClient.terminate(); return { concurrent: concurrent.length, sha256: first.sha256, firstDeduplicated: first.deduplicated, secondDeduplicated: second.deduplicated, path: first.path, sourcePath: first.sourcePath, listed: listed.assets.length, listedHash: listed.assets[0]?.sha256, restored: Array.from(new Uint8Array(restored.data)), traversalError, }; }); expect(result.concurrent).toBe(2); expect(result.sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.firstDeduplicated).toBe(false); expect(result.secondDeduplicated).toBe(true); expect(result.path).toMatch(/projects\/asset-e2e\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/); expect(result.sourcePath).toBe("textures/checker.png"); expect(result.listed).toBe(1); expect(result.listedHash).toBe(result.sha256); expect(result.restored).toEqual([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]); expect(result.traversalError).toContain("ASSET_PATH_OUTSIDE_PROJECT"); }); test("viewport accepts orbit input without rebuilding the shell", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toBeVisible(); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (!box) return; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 20); await page.mouse.up(); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3 · Vertices 8 · Faces 6"); }); test("opens a local .blend into SceneIR and Three.js", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await expect(page.getByText("BasicCamera", { exact: true })).toBeVisible(); await expect(page.getByText("BasicArea", { exact: true })).toBeVisible(); await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r1 (3 objects)"); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3 · Vertices 8 · Faces 6"); const renderedPixels = await page.locator("canvas.viewport-canvas").evaluate((canvas) => { const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); if (!gl) return 0; const pixels = new Uint8Array(4 * 16 * 16); gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); let changed = 0; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++; } return changed; }); expect(renderedPixels).toBeGreaterThan(0); }); test("reads bounded non-mesh data blocks and previews supported geometry", async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend); await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(page.getByText("WebMetaballObject", { exact: true })).toBeVisible(); await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible(); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-non-mesh-count", "6"); await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2"); const renderedPixels = await canvas.evaluate((element) => { const gl = element.getContext("webgl2") ?? element.getContext("webgl"); if (!gl) return 0; const pixels = new Uint8Array(4 * 16 * 16); gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); let changed = 0; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++; } return changed; }); expect(renderedPixels).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath("nonmesh-desktop-1440x900.png"), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await expect(canvas).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); const bounds = await canvas.boundingBox(); expect(bounds?.width ?? 0).toBeGreaterThan(100); expect(bounds?.height ?? 0).toBeGreaterThan(100); const mobilePixels = await canvas.evaluate((element) => { const gl = element.getContext("webgl2") ?? element.getContext("webgl"); if (!gl) return 0; const pixels = new Uint8Array(4 * 16 * 16); gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); let changed = 0; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) changed++; } return changed; }); expect(mobilePixels).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath("nonmesh-mobile-390x844.png"), fullPage: true }); }); test("reports structured N-015 gates for previewable and resource-backed data", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend)); const report = await page.evaluate((input) => new Promise<{ gates: Array<{ id: string; taskId: string; status: string; code?: string }>; metaball: { position: number[]; scale: number[] }; binaryDataIds: string[]; hairType: string }>((resolve, reject) => { type Response = { kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { nonMeshData?: Array<{ id: string; type?: string; elements?: Array<{ position: number[]; scale: number[] }> }> }; nonMeshGeometryBuffers?: Array<{ dataId: string; pointCount: number }>; capabilityGate?: { taskId: string; status: string; issues: Array<{ code: string }> } }; error?: { message?: string } }; const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const ids = ["curve:WebCurveData", "volume:WebVolumeData"]; const results: Array<{ id: string; taskId: string; status: string; code?: string }> = []; let metaball: { position: number[]; scale: number[] } | undefined; let binaryDataIds: string[] = []; let hairType = ""; worker.onmessage = (event: MessageEvent) => { if (event.data.kind !== "result") return; if (event.data.requestId === "nonmesh-open") { if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "non-mesh fixture open failed")); return; } const data = event.data.result?.snapshot?.nonMeshData?.find((candidate) => candidate.id === "metaball:WebMetaballData"); const element = data?.elements?.[0]; if (!element) { worker.terminate(); reject(new Error("metaball metadata missing")); return; } metaball = element; binaryDataIds = [...new Set((event.data.result?.nonMeshGeometryBuffers ?? []).map((chunk) => chunk.dataId))].sort(); hairType = event.data.result?.snapshot?.nonMeshData?.find((candidate) => candidate.id === "curves:WebHairData")?.type ?? ""; ids.forEach((dataId, index) => worker.postMessage({ requestId: `nonmesh-gate-${index}`, command: { type: "queryNonMeshCapability", dataId } })); return; } const index = Number(event.data.requestId.replace("nonmesh-gate-", "")); const gate = event.data.result?.capabilityGate; if (!event.data.ok || !gate || !Number.isSafeInteger(index)) return; results.push({ id: ids[index], taskId: gate.taskId, status: gate.status, code: gate.issues[0]?.code }); if (results.length === ids.length) { worker.terminate(); if (!metaball) { reject(new Error("metaball metadata missing after gate queries")); return; } resolve({ gates: results.sort((left, right) => left.id.localeCompare(right.id)), metaball, binaryDataIds, hairType }); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: "nonmesh-open", command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(report.gates).toEqual([ { id: "curve:WebCurveData", taskId: "N-015", status: "READY", code: undefined }, { id: "volume:WebVolumeData", taskId: "N-015", status: "BLOCKED", code: "NON_MESH_RESOURCE_MISSING" }, ]); expect(report.binaryDataIds).toEqual(["curve:WebCurveData", "curve:WebSurfaceData", "curves:WebCurvesData", "curves:WebHairData", "pointcloud:WebPointCloudData"]); expect(report.hairType).toBe("HAIR"); expect(report.metaball.position[0]).toBeCloseTo(-0.65, 5); expect(report.metaball.position[1]).toBeCloseTo(0, 5); expect(report.metaball.position[2]).toBeCloseTo(0.4, 5); expect(report.metaball.scale).toEqual([1, 1, 1]); }); test("enforces the N-015 one-million-point binary transfer gate", async ({ page }) => { await page.goto("/"); const gate = await page.evaluate(() => new Promise<{ status: string; metrics: { pointCount: number; chunkCount: number; transferredBytes: number; elapsedMs: number; peakBytes: number } }>((resolve, reject) => { const worker = new Worker("/src/workers/nonmesh-binary-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ pointCount: 1_000_000 }); })); expect(gate.status).toBe("READY"); expect(gate.metrics.pointCount).toBe(1_000_000); expect(gate.metrics.chunkCount).toBe(16); expect(gate.metrics.transferredBytes).toBeGreaterThan(16 * 65_536 * 12); expect(gate.metrics.peakBytes).toBeLessThan(512 * 1024 * 1024); }); test("rejects malformed N-015 binary chunks, integer overflow and budget overflow", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ hash: string; length: string; integerOverflow: string; invalidAttributes: string; attributeOverflow: string; negativeOffset: string; missingAttribute: string; duplicateId: string; hundredThousandBudget: string; pointBudget: string }>((resolve, reject) => { const worker = new Worker("/src/workers/nonmesh-binary-malformed-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.hash).toContain("NON_MESH_BINARY_INVALID"); expect(result.length).toContain("NON_MESH_BINARY_INVALID"); expect(result.integerOverflow).toContain("NON_MESH_BINARY_INVALID"); expect(result.invalidAttributes).toContain("NON_MESH_BINARY_INVALID"); expect(result.attributeOverflow).toContain("NON_MESH_BINARY_INVALID"); expect(result.missingAttribute).toContain("NON_MESH_BINARY_INVALID"); expect(result.negativeOffset).toContain("NON_MESH_BINARY_INVALID"); expect(result.duplicateId).toContain("NON_MESH_BINARY_INVALID"); expect(result.hundredThousandBudget).toContain("NON_MESH_BINARY_INVALID"); expect(result.pointBudget).toContain("NON_MESH_BINARY_INVALID"); }); test("enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema budget", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ validStatus: string; malformed: string; budget: string }>((resolve, reject) => { const worker = new Worker("/src/workers/grease-pencil-schema-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ validStatus: string; malformed: string; budget: string }>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.validStatus).toBe("available"); expect(result.malformed).toContain("GREASE_PENCIL_SCHEMA_INVALID"); expect(result.budget).toBe("accepted-blocked-budget"); }); test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts"); const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] }); const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] }); const object = createGreasePencilObject({ id: "grease-pencil:onion", name: "Onion", geometryStatus: "available", layerCount: 1, frameCount: 3, strokeCount: 3, pointCount: 6, layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [ { frame: 1, drawing: drawing("drawing:1", -2) }, { frame: 5, drawing: drawing("drawing:5", 0) }, { frame: 9, drawing: drawing("drawing:9", 2) }, ] }], }, 5); if (!object) return null; return { onion: object.userData.greasePencilOnionStrokeCount, current: object.userData.greasePencilCurrentStrokeCount, kinds: object.children.map((child) => child.userData.greasePencilOnion), opacities: object.children.map((child) => "material" in child && !Array.isArray(child.material) ? (child.material as { opacity?: number }).opacity : undefined), }; }); expect(result).toEqual({ onion: 2, current: 1, kinds: ["PREVIOUS", "NONE", "NEXT"], opacities: [0.28, 1, 0.28] }); }); for (const offscreen of [false, true]) { test(`renders the N-016 Grease Pencil current-frame strokes in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => { await page.goto(offscreen ? "/?offscreen=1" : "/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 }); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr"); await expect(canvas).toHaveAttribute("data-grease-pencil-count", "1", { timeout: 20_000 }); await expect(canvas).toHaveAttribute("data-grease-pencil-blocked-count", "0"); if (offscreen) { await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); } else { const renderedPixels = await canvas.evaluate((element) => { const gl = element.getContext("webgl2") ?? element.getContext("webgl"); if (!gl) return 0; const pixels = new Uint8Array(4 * 16 * 16); gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); return pixels.reduce((count, value) => count + (value > 0 ? 1 : 0), 0); }); expect(renderedPixels).toBeGreaterThan(0); } expect(await canvas.getAttribute("data-renderer-error")).toBeNull(); }); } test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/paint-schema-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toBe("VERTEX_COLOR"); expect(result.weight).toBe("Group"); expect(result.hit).toContain("PAINT_SCHEMA_INVALID"); expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED"); }); test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const three = await import("/src/vendor/three/three.module.js"); const { paintHitFromIntersection } = await import("/src/three-adapter/paint-hit.ts"); const geometry = new three.BufferGeometry(); geometry.setAttribute("position", new three.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)); geometry.setAttribute("uv", new three.Float32BufferAttribute([0, 0, 1, 0, 0, 1], 2)); geometry.setIndex([0, 1, 2]); const mesh = new three.Mesh(geometry, new three.MeshBasicMaterial()); mesh.userData.blenderId = "object:paint"; mesh.userData.meshId = "mesh:paint"; mesh.userData.triangleFaceIndices = [17]; mesh.updateMatrixWorld(true); const raycaster = new three.Raycaster(new three.Vector3(0.25, 0.25, 1), new three.Vector3(0, 0, -1)); const intersection = raycaster.intersectObject(mesh)[0]; return intersection ? paintHitFromIntersection(intersection, 0.75) : null; }); expect(result?.objectId).toBe("object:paint"); expect(result?.dataId).toBe("mesh:paint"); expect(result?.faceIndex).toBe(17); expect(result?.pressure).toBe(0.75); expect(result?.barycentric).toEqual(expect.arrayContaining([0.5, 0.25, 0.25])); expect(result?.uv).toEqual([0.25, 0.25]); expect(result?.normal).toEqual([0, 0, 1]); }); test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/physics-simulation-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toBe("CLOTH:2"); expect(result.familyCount).toBe(7); expect(result.families).toEqual(["RIGID_BODY", "SOFT_BODY", "CLOTH", "FLUID", "DYNAMIC_PAINT", "PARTICLE", "HAIR"]); expect(result.inventoryBlocked).toBe(true); expect(result.cycle).toContain("PHYSICS_DEPENDENCY_CYCLE"); expect(result.budget).toContain("PHYSICS_BUDGET_EXCEEDED"); expect(result.missingFrame).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE"); expect(result.manifest).toBe("READY"); }); test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-view-transform", "AgX"); await expect(canvas).toHaveAttribute("data-view-look", "None"); await expect(canvas).toHaveAttribute("data-mist", "disabled"); const mapping = await page.evaluate(async () => { const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts"); const { Object3D } = await import("/src/vendor/three/three.module.js"); const definition = { id: "light:test", name: "Test", lightType: 0, color: [1, 1, 1], energy: 100, exposure: 2, radius: 0, spotAngle: 1, spotBlend: 0, areaShape: 0, areaSize: 1, areaSizeY: 1, sunAngle: 0, castsShadow: false, }; const light = createPBRLight(definition); configurePBRLight(light, { id: "object:test", name: "Test", type: "LIGHT", parentId: null, dataId: "light:test", visible: true, selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 }, }, new Object3D()); return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow }; }); expect(mapping).toEqual({ intensity: 40, castShadow: false, sourceShadow: false }); }); test("executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/compositor-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toEqual([0.25, 0, 0.5, 0.75]); expect(result.viewer).toBe(true); expect(result.gate).toBe("READY"); expect(result.cycle).toContain("COMPOSITOR_GRAPH_CYCLE"); expect(result.unsupported).toBe("COMPOSITOR_NODE_UNSUPPORTED"); expect(result.budget).toContain("COMPOSITOR_BUDGET_EXCEEDED"); expect(result.cancelled).toContain("COMPOSITOR_CANCELLED"); }); test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/sequencer-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toBe(105); expect(result.edit).toEqual([5, [["strip:Movie", 15, 20, 100, 105], ["strip:MovieRight", 20, 25, 105, 110]]]); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.path).toContain("SEQUENCER_RESOURCE_OUTSIDE_PROJECT"); expect(result.cycle).toContain("SEQUENCER_DEPENDENCY_CYCLE"); expect(result.budget).toContain("SEQUENCER_BUDGET_EXCEEDED"); expect(result.codec).toBe("SEQUENCER_CODEC_UNSUPPORTED"); expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED"); }); test("validates N-022 tracking markers, masks, resource bindings and solve gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/tracking-mask-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.edit).toEqual([6, [1, 10], [0.4, 0.6]]); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.path).toContain("TRACKING_RESOURCE_OUTSIDE_PROJECT"); expect(result.binding).toContain("TRACKING_BINDING_MISSING"); expect(result.budget).toContain("TRACKING_BUDGET_EXCEEDED"); expect(result.browserGate).toBe("TRACKING_SOLVE_UNAVAILABLE"); expect(result.verifiedGate).toBe("READY"); expect(result.solveGate).toBe("BLOCKED"); }); test("validates N-023 asset catalogs, library graphs, archive budgets and IO gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/asset-library-io-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toEqual(["CC0-1.0", ["library:b", "library:a"]]); expect(result.hash).toContain("ASSET_SOURCE_HASH_MISMATCH"); expect(result.license).toContain("ASSET_LICENSE_MISSING"); expect(result.cycle).toContain("LIBRARY_DEPENDENCY_CYCLE"); expect(result.archive).toContain("IO_ARCHIVE_UNSAFE"); expect(result.uri).toContain("IO_EXTERNAL_URI_BLOCKED"); expect(result.glb).toBe("READY"); expect(result.obj).toBe("IO_FORMAT_UNSUPPORTED"); expect(result.library).toBe("BLOCKED"); expect((result.storage as { contentAddressedIndex: string }).contentAddressedIndex).toBe("LOCAL_BOUNDED"); }); test("validates N-024 editor context, selection sync, layout budgets and workflow gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/editor-workflow-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.edit).toEqual([2, "TIMELINE", ["object:2"]]); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.overlap).toContain("EDITOR_LAYOUT_INVALID"); expect(result.budget).toContain("EDITOR_SELECTION_INVALID"); expect(result.view).toBe("READY"); expect(result.writer).toBe("EDITOR_WRITER_UNAVAILABLE"); expect(result.gizmo).toBe("EDITOR_GIZMO_UNAVAILABLE"); }); test("validates N-025 script policy, signatures, budgets and platform gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/scripting-platform-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toBe("scripts/clean.py"); expect(result.exec).toBe("SCRIPT_SANDBOX_UNAVAILABLE"); expect(result.server).toBe("SERVER_JOB_UNAVAILABLE"); expect(result.path).toContain("SCRIPT_MANIFEST_INVALID"); expect(result.policy).toContain("SCRIPT_POLICY_DENIED"); expect(result.budget).toContain("SCRIPT_BUDGET_EXCEEDED"); expect(result.signature).toContain("SCRIPT_SIGNATURE_INVALID"); expect((result.platform as { nativeWindow: string }).nativeWindow).toBe("BLOCKED"); }); test("keeps N-026 release manifest deterministic and blocks missing evidence", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/release-gate-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.valid).toEqual([2, true]); expect(result.gate).toEqual(["BLOCKED", expect.arrayContaining(["RELEASE_EVIDENCE_MISSING", "RELEASE_TEST_CHANNEL_MISSING", "RELEASE_PERFORMANCE_MISSING", "RELEASE_FAULT_EVIDENCE_MISSING", "RELEASE_PROVENANCE_MISSING"])]); expect(result.oldSchema).toContain("PROTOCOL_MISMATCH"); expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE"); expect(result.status).toContain("RELEASE_MANIFEST_INVALID"); }); test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/selection-history-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent>) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.history).toEqual([3, 1, [ ["curve:1", [1, 3], "HANDLE_LEFT"], ["curve:2", [2], "HANDLE_RIGHT"], ]]); expect(result.revision).toContain("REVISION_CONFLICT"); expect(result.budget).toContain("SELECTION_HISTORY_BUDGET_EXCEEDED"); expect(result.raycast).toContain("RAYCAST_HIT_INVALID"); expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]); expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]); expect(result.migrated).toEqual([2, "mesh:1"]); expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]); }); test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => { const worker = new Worker("/src/workers/vdb-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.decodedByteLength).toBe(4); expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT"); expect(result.cancelled).toBe(true); }); test("returns real Blender evaluations for legacy non-mesh geometry", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend)); const geometries = await page.evaluate((input) => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); worker.onmessage = (event) => { if (event.data.kind !== "result") return; if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "depsgraph evaluation failed")); } else if (event.data.requestId === "evaluation-open") { worker.postMessage({ requestId: "evaluation-run", command: { type: "evaluateDepsgraph" } }); } else if (event.data.requestId === "evaluation-run") { worker.terminate(); resolve(event.data.result?.depsgraph?.nonMeshGeometries ?? []); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: "evaluation-open", command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(new Set(geometries.filter((geometry) => geometry.status === "EVALUATED").map((geometry) => geometry.sourceType))).toEqual(new Set(["CURVE", "SURFACE", "FONT", "METABALL"])); for (const geometry of geometries) { if (geometry.status !== "EVALUATED") continue; expect(geometry.vertexCount).toBeGreaterThan(0); expect(geometry.positions).toHaveLength(geometry.vertexCount * 3); expect(geometry.indices).toHaveLength(geometry.triangleCount * 3); } }); test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ page }) => { await page.goto("/?offscreen=1"); await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker"); await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 }); await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2"); await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); expect(await canvas.getAttribute("data-renderer-error")).toBeNull(); }); test("runs Main-authoritative object and edit-mode modeling workflows", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 3"); await page.getByRole("button", { name: "添加立方体" }).click(); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 4"); await page.getByRole("button", { name: "复制对象", exact: true }).click(); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 5"); await page.getByRole("button", { name: "Object Mode" }).click(); await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible(); await page.getByRole("button", { name: "移动工具" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r"); await page.getByRole("button", { name: "Edit Mode" }).click(); await page.getByRole("button", { name: "删除对象" }).click(); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 4"); await page.getByText("BasicCube", { exact: true }).click(); await page.getByText("BasicCamera", { exact: true }).click({ modifiers: ["Shift"] }); await expect(page.getByText("2 selected", { exact: true })).toBeVisible(); }); test("saves the opened blend through StorageWorker before download", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "保存项目" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe("blender-web.blend"); }); test("autosaves a dirty project without starting a download", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "已保存" }).click(); await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 5_000 }); }); test("persists an operation log entry with an inverse payload", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ id: string; revision: number; persisted: boolean }>((resolve, reject) => { const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" }); const requestId = "storage-operation-e2e"; worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { id: string; revision: number; persisted: boolean }; error?: string }>) => { if (event.data.requestId !== requestId) return; worker.terminate(); if (!event.data.ok || !event.data.result) { reject(new Error(event.data.error ?? "operation log failed")); return; } resolve(event.data.result); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId, command: { type: "appendOperation", id: "op-e2e", projectId: "save-e2e", revision: 8, payload: { type: "transform" }, inversePayload: { type: "restore" } } }); })); expect(result).toEqual({ id: "op-e2e", projectId: "save-e2e", revision: 8, persisted: true }); }); test("retains bounded snapshots and returns a validated operation replay plan", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const projectId = `replay-e2e-${Date.now()}`; const client = new StorageClient(); for (let revision = 1; revision <= 4; revision++) { await client.saveSnapshot(projectId, revision, Uint8Array.from([revision, revision + 1]).buffer, 2, 16); } await client.appendOperation("op-r3", projectId, 3, { type: "setFrame", frame: 3 }); await client.appendOperation("op-r5", projectId, 5, { type: "setFrame", frame: 5 }); await client.appendOperation("op-r4", projectId, 4, { type: "setFrame", frame: 4 }); const db = await new Promise((resolve, reject) => { const request = indexedDB.open("blender-web-metadata", 6); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const corrupt = db.transaction("operation_log", "readwrite"); corrupt.objectStore("operation_log").put({ id: "broken-op", projectId, revision: "bad", payload: null, createdAt: "never" }); await new Promise((resolve, reject) => { corrupt.oncomplete = () => resolve(); corrupt.onerror = () => reject(corrupt.error); }); db.close(); const snapshots = await client.listSnapshots(projectId); const latest = await client.readSnapshot(projectId, snapshots.snapshots[0].revision); const replay = await client.listOperations(projectId, 3); const pruned = await client.pruneOperations(projectId, 4); client.terminate(); return { snapshotRevisions: snapshots.snapshots.map((item) => item.revision), latest: Array.from(new Uint8Array(latest.buffer)), replayRevisions: replay.operations.map((item) => item.revision), quarantined: replay.quarantined, pruned: pruned.removed, }; }); expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], replayRevisions: [4, 5], quarantined: 1, pruned: 2 }); }); test("reports quota exhaustion without replacing the committed project", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const client = new StorageClient(); const projectId = `quota-e2e-${Date.now()}`; await client.saveProject(projectId, 1, Uint8Array.from([1, 2, 3]).buffer); let error = ""; try { await client.saveProject(projectId, 2, Uint8Array.from([9, 9, 9]).buffer, "quota"); } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); } const restored = await client.readProject(projectId); client.terminate(); return { error, revision: restored.revision, bytes: Array.from(new Uint8Array(restored.buffer)) }; }); expect(result.error).toContain("QuotaExceededError"); expect(result.revision).toBe(1); expect(result.bytes).toEqual([1, 2, 3]); }); test("enforces a real OPFS quota without replacing the committed project", async ({ page }) => { await page.goto("/"); const cdp = await page.context().newCDPSession(page); await cdp.send("Storage.overrideQuotaForOrigin", { origin: new URL(page.url()).origin, quotaSize: 64 * 1024 }); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const projectId = `real-quota-e2e-${Date.now()}`; const client = new StorageClient(); const initial = await client.saveProject(projectId, 1, Uint8Array.from({ length: 1024 }, (_, index) => index & 0xff).buffer); let error = ""; try { await client.saveProject(projectId, 2, Uint8Array.from({ length: 128 * 1024 }, (_, index) => (index * 7) & 0xff).buffer); } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); } const restored = await client.readProject(projectId); client.terminate(); const restarted = new StorageClient(); const reopened = await restarted.readProject(projectId); restarted.terminate(); return { backend: initial.backend, error, revision: restored.revision, reopenedRevision: reopened.revision, bytes: Array.from(new Uint8Array(reopened.buffer.slice(0, 4))), }; }); expect(result.backend).toBe("opfs"); expect(result.error).toMatch(/QuotaExceededError|quota/i); expect(result.revision).toBe(1); expect(result.reopenedRevision).toBe(1); expect(result.bytes).toEqual([0, 1, 2, 3]); }); test("persists and revalidates content-addressed Simulation caches across Worker restarts", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const digest = async (data: ArrayBuffer): Promise => { const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join(""); }; const projectId = `simulation-e2e-${Date.now()}`; const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer; const source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]); const frameOne = source.buffer.slice(0, 3); const frameTwo = source.buffer.slice(3); const payload = source.buffer.slice(0); const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer); const manifest = { schemaVersion: 1 as const, graphId: "geometry-node-tree:simulation-e2e", graphHash: fixedHash, sourceBlendSha256: await digest(sourceBlend), inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer), cacheSha256: await digest(payload), blenderVersion: "5.2.0", frameStart: 1, frameEnd: 2, byteLength: payload.byteLength, frames: [ { frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) }, { frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) }, ], }; const first = new StorageClient(); await first.saveProject(projectId, 1, sourceBlend.slice(0)); const stored = await first.putSimulationCache(projectId, manifest, payload); first.terminate(); const restarted = new StorageClient(); const listed = await restarted.listSimulationCaches(projectId); const read = await restarted.readSimulationCache(projectId, stored.cacheKey); const frameRead = await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 2); let missingFrameCode = ""; try { await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, 3); } catch (error) { missingFrameCode = String((error as Error & { code?: string }).code ?? ""); } let corruptCode = ""; try { await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0)); } catch (error) { corruptCode = String((error as Error & { code?: string }).code ?? ""); } restarted.terminate(); return { cacheKey: stored.cacheKey, path: stored.path, listed: listed.caches.map((cache) => cache.cacheKey), bytes: Array.from(new Uint8Array(read.data)), frame: frameRead.frame, frameOffset: frameRead.byteOffset, frameBytes: Array.from(new Uint8Array(frameRead.data)), missingFrameCode, corruptCode, }; }); expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/); expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/); expect(result.listed).toContain(result.cacheKey); expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]); expect(result.frame).toBe(2); expect(result.frameOffset).toBe(3); expect(result.frameBytes).toEqual([21, 22, 23, 24]); expect(result.missingFrameCode).toBe("SIMULATION_CACHE_MISSING"); expect(result.corruptCode).toBe("SIMULATION_CACHE_HASH_MISMATCH"); }); test("stores LOD cache bytes in OPFS and indexes its manifest", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { buildLODCacheKey } = await import("/src/three-adapter/lod.ts"); return new Promise<{ bytes: number; path: string; persisted: boolean; manifest?: { cacheKey: string } }>((resolve, reject) => { const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" }); const cacheKey = buildLODCacheKey({ objectId: "object-e2e", meshRevision: 7, modifierStackHash: "stack-hash", profileHash: "profile-hash", poseOrRestState: "REST" }); const manifest = { schemaVersion: 1, meshId: "mesh-e2e", sourceMeshRevision: 7, cacheKey, objectId: "object-e2e", modifierStackHash: "stack-hash", profileHash: "profile-hash", poseOrRestState: "REST", generatedAt: "2026-08-06T00:00:00.000Z", levels: [ { level: 0, sourceMeshRevision: 7, triangleBudget: 1200, meshId: "mesh-e2e-l0" }, { level: 1, sourceMeshRevision: 7, triangleBudget: 400, meshId: "mesh-e2e-l1" }, ], }; const bytes = Uint8Array.from([0x4c, 0x4f, 0x44, 0x31]).buffer; worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { bytes?: number; path?: string; persisted?: boolean; manifest?: { cacheKey: string }; manifests?: Array<{ cacheKey: string }> }; error?: string }>) => { if (event.data.requestId === "lod-save-e2e" && event.data.ok) { worker.postMessage({ requestId: "lod-put-e2e", command: { type: "putLODManifest", projectId: "lod-e2e", manifest } }); } else if (event.data.requestId === "lod-put-e2e" && event.data.ok) { worker.postMessage({ requestId: "lod-get-e2e", command: { type: "getLODManifest", projectId: "lod-e2e", cacheKey } }); } else if (event.data.requestId === "lod-get-e2e") { if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "LOD manifest read failed")); else worker.postMessage({ requestId: "lod-list-e2e", command: { type: "listLODManifests", projectId: "lod-e2e" } }); } else if (event.data.requestId === "lod-list-e2e") { worker.terminate(); if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "LOD manifest list failed")); else resolve({ bytes: 4, path: `projects/lod-e2e/cache/lod/${cacheKey}.mesh`, persisted: event.data.result.manifests?.some((item: { cacheKey: string }) => item.cacheKey === cacheKey) ?? false, manifest: { cacheKey } }); } else if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error ?? "LOD storage failed")); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId: "lod-save-e2e", command: { type: "saveLOD", projectId: "lod-e2e", cacheKey, data: bytes } }, [bytes]); }); }); expect(result.bytes).toBe(4); expect(result.path).toMatch(/^projects\/lod-e2e\/cache\/lod\/lod-[0-9a-f]+\.mesh$/); expect(result.persisted).toBe(true); expect(result.manifest?.cacheKey).toMatch(/^lod-[0-9a-f]+$/); }); test("selects Three.js LOD levels with hysteresis and stable cache keys", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { buildLODCacheKey, selectLODLevel } = await import("/src/three-adapter/lod.ts"); const policy = { screenHeightThresholds: [512, 128, 32], hysteresis: 0.1 }; return { near: selectLODLevel(600, policy).level, boundary: selectLODLevel(490, policy, 0).level, far: selectLODLevel(20, policy, 1).level, keyA: buildLODCacheKey({ objectId: "cube", meshRevision: 4, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" }), keyB: buildLODCacheKey({ objectId: "cube", meshRevision: 4, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" }), }; }); expect(result.near).toBe(0); expect(result.boundary).toBe(0); expect(result.far).toBe(2); expect(result.keyA).toBe(result.keyB); }); test("reads and evicts LOD cache entries by byte budget", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { buildLODCacheKey } = await import("/src/three-adapter/lod.ts"); return new Promise<{ readBytes: number; removed: number; removedBytes: number }>((resolve, reject) => { const worker = new Worker("/src/workers/storage.worker.ts", { type: "module" }); const cacheKey = buildLODCacheKey({ objectId: "evict-object", meshRevision: 3, modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST" }); const manifest = { schemaVersion: 1, meshId: "evict-mesh", sourceMeshRevision: 3, cacheKey, objectId: "evict-object", modifierStackHash: "stack", profileHash: "profile", poseOrRestState: "REST", generatedAt: "2026-08-06T00:00:00.000Z", byteLength: 6, levels: [{ level: 0, sourceMeshRevision: 3, triangleBudget: 100, meshId: "evict-l0" }], }; const bytes = Uint8Array.from([1, 2, 3, 4, 5, 6]).buffer; let readBytes = 0; worker.onmessage = (event: MessageEvent<{ requestId: string; ok: boolean; result?: { data?: ArrayBuffer; bytes?: number; removed?: number; removedBytes?: number }; error?: string }>) => { if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error ?? "LOD cache operation failed")); } else if (event.data.requestId === "lod-read-e2e") { readBytes = event.data.result?.bytes ?? 0; worker.postMessage({ requestId: "lod-prune-e2e", command: { type: "pruneLOD", projectId: "lod-evict-e2e", maxBytes: 0 } }); } else if (event.data.requestId === "lod-prune-e2e") { worker.terminate(); resolve({ readBytes, removed: event.data.result?.removed ?? 0, removedBytes: event.data.result?.bytes ?? 0 }); } else if (event.data.requestId === "lod-save-e2e") worker.postMessage({ requestId: "lod-put-e2e", command: { type: "putLODManifest", projectId: "lod-evict-e2e", manifest } }); else if (event.data.requestId === "lod-put-e2e") worker.postMessage({ requestId: "lod-read-e2e", command: { type: "readLOD", projectId: "lod-evict-e2e", cacheKey } }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ requestId: "lod-save-e2e", command: { type: "saveLOD", projectId: "lod-evict-e2e", cacheKey, data: bytes } }, [bytes]); }); }); expect(result.readBytes).toBe(6); expect(result.removed).toBe(1); expect(result.removedBytes).toBe(6); }); test("renders local UV/color/material-slot geometry", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible(); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects 1 · Vertices 5 · Faces 2"); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-renderer-backend", "webgl-pbr"); await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1"); await expect(canvas).toHaveAttribute("data-tone-mapping", "aces"); await expect(canvas).toHaveAttribute("data-shadow-map", "pcf-1024"); const renderedPixels = await canvas.evaluate((canvas) => { const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); if (!gl) return 0; const pixels = new Uint8Array(4 * 16 * 16); gl.readPixels(Math.max(0, (gl.drawingBufferWidth - 16) / 2), Math.max(0, (gl.drawingBufferHeight - 16) / 2), 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels); return pixels.reduce((count, value, index) => count + (index % 4 !== 3 && value > 50 ? 1 : 0), 0); }); expect(renderedPixels).toBeGreaterThan(0); }); test("maps Blender Principled values to the bounded physical viewport material", async ({ page }) => { await page.goto("/"); const mapped = await page.evaluate(async () => { const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts"); const material = createPBRMaterial({ id: "material:pbr", name: "PBR", baseColor: [0.2, 0.4, 0.8, 0.9], roughness: 0.21, metallic: 0.62, emissionColor: [0.05, 0.1, 0.2, 1], alpha: 0.9, ior: 1.52, specularIORLevel: 0.35, transmissionWeight: 0.27, coatWeight: 0.64, coatRoughness: 0.12, emissionStrength: 3.5, }); const result = { type: material.type, roughness: material.roughness, metalness: material.metalness, ior: material.ior, specularIntensity: material.specularIntensity, transmission: material.transmission, clearcoat: material.clearcoat, clearcoatRoughness: material.clearcoatRoughness, emissiveIntensity: material.emissiveIntensity, opacity: material.opacity, transparent: material.transparent, depthWrite: material.depthWrite, profile: material.userData.pbrProfile, }; material.dispose(); return result; }); expect(mapped).toEqual({ type: "MeshPhysicalMaterial", roughness: 0.21, metalness: 0.62, ior: 1.52, specularIntensity: 0.7, transmission: 0.27, clearcoat: 0.64, clearcoatRoughness: 0.12, emissiveIntensity: 3.5, opacity: 0.9, transparent: true, depthWrite: false, profile: "physical-v1", }); }); test("authors advanced Principled PBR controls through Blender Main", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible(); await page.getByRole("slider", { name: "材质 IOR" }).fill("1.52"); await page.getByRole("slider", { name: "材质镜面 IOR" }).fill("0.35"); await page.getByRole("slider", { name: "材质透射" }).fill("0.27"); await page.getByRole("slider", { name: "材质涂层", exact: true }).fill("0.64"); await page.getByRole("slider", { name: "材质涂层粗糙度" }).fill("0.12"); await page.getByRole("spinbutton", { name: "材质自发光强度" }).fill("3.5"); await page.getByRole("button", { name: "应用 Principled" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText(/SceneIR r\d+/, { timeout: 15_000 }); }); test("captures nonblank PBR viewport framing at desktop and mobile sizes", async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible(); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1"); await page.screenshot({ path: testInfo.outputPath("pbr-desktop-1440x900.png"), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await expect(canvas).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); const bounds = await canvas.boundingBox(); expect(bounds?.width ?? 0).toBeGreaterThan(100); expect(bounds?.height ?? 0).toBeGreaterThan(100); const sampleCanvas = (): Promise<{ visible: number; hash: number }> => canvas.evaluate((element) => { const gl = element.getContext("webgl2") ?? element.getContext("webgl"); if (!gl) return { visible: 0, hash: 0 }; const size = 32; const pixels = new Uint8Array(size * size * 4); gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - size) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - size) / 2)), size, size, gl.RGBA, gl.UNSIGNED_BYTE, pixels); let visible = 0; let hash = 2166136261; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] > 40 || pixels[index + 1] > 40 || pixels[index + 2] > 40) visible++; hash = Math.imul(hash ^ pixels[index], 16777619); hash = Math.imul(hash ^ pixels[index + 1], 16777619); hash = Math.imul(hash ^ pixels[index + 2], 16777619); } return { visible, hash: hash >>> 0 }; }); const beforeOrbit = await sampleCanvas(); expect(beforeOrbit.visible).toBeGreaterThan(0); if (!bounds) throw new Error("mobile viewport has no bounds"); await page.mouse.move(bounds.x + bounds.width * 0.25, bounds.y + bounds.height * 0.45); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width * 0.35, bounds.y + bounds.height * 0.5, { steps: 6 }); await page.mouse.up(); await page.waitForTimeout(200); const afterOrbit = await sampleCanvas(); expect(afterOrbit.visible).toBeGreaterThan(0); expect(afterOrbit.hash).not.toBe(beforeOrbit.hash); await page.screenshot({ path: testInfo.outputPath("pbr-mobile-390x844.png"), fullPage: true }); }); test("shows mode-specific Blender Decimate inspector controls", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); const mode = page.getByRole("combobox", { name: "Decimate 模式" }); await mode.selectOption("UNSUBDIV"); await expect(page.getByRole("spinbutton", { name: "Un-Subdivide 迭代" })).toHaveValue("1"); await mode.selectOption("DISSOLVE_PLANAR"); await expect(page.getByRole("slider", { name: "Dissolve 角度" })).toHaveValue("5"); await expect(page.getByRole("checkbox", { name: "Dissolve 边界" })).toBeVisible(); await expect(page.getByRole("group", { name: "分隔" })).toBeVisible(); await mode.selectOption("COLLAPSE"); await expect(page.getByRole("slider", { name: "Decimate 比例" })).toHaveValue("0.5"); await expect(page.getByRole("checkbox", { name: "Decimate 三角化" })).toBeVisible(); await expect(page.getByRole("checkbox", { name: "Decimate 对称" })).toBeVisible(); await expect(page.getByRole("combobox", { name: "Decimate 属性策略" })).toHaveValue("PRESERVE"); await page.getByRole("button", { name: "预览 Decimate" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText("Preview", { timeout: 10_000 }); await page.getByRole("button", { name: "取消 Decimate 预览" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r1"); await page.getByRole("button", { name: "生成 LOD" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText("LOD:", { timeout: 15_000 }); }); test("discovers and reuses a valid LOD cache after an application refresh", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "生成 LOD" }).click(); await expect(page.locator("[data-testid=engine-status]")).toContainText("LOD:", { timeout: 15_000 }); await page.reload(); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 }); }); test("workspace context routes mode and operator search state", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Modeling" }).click(); await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling"); await page.getByRole("button", { name: "Object Mode" }).click(); await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible(); await page.getByRole("button", { name: "操作搜索" }).click(); await page.getByRole("textbox", { name: "搜索操作" }).fill("cube"); await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible(); }); test("keeps Outliner selection bound to SceneIR activeObjectId", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.locator(".tree-row.child", { hasText: "BasicCamera" }).click(); await expect(page.locator(".tree-row.selected")).toContainText("BasicCamera"); }); test("Outliner visibility button applies a native command", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); const visibilityButton = page.getByRole("button", { name: "隐藏 BasicCube", exact: true }); await visibilityButton.click(); await expect(page.getByRole("button", { name: "显示 BasicCube", exact: true })).toBeVisible(); await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (3 objects)"); }); test("timeline transport controls update the imported frame range", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "跳到最后一帧" }).click(); await expect(page.locator(".frame-number")).toHaveText("24"); await page.getByRole("button", { name: "跳到第一帧" }).click(); await expect(page.locator(".frame-number")).toHaveText("1"); }); test("loads the local web_engine WASM worker", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ ok: boolean; ready?: boolean; error?: string }>((resolve) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const requestId = "e2e-web-engine"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { status: { ready: boolean } }; error?: { message: string } }>) => { if (event.data.requestId !== requestId) return; worker.terminate(); resolve({ ok: event.data.kind === "result" && event.data.ok === true, ready: event.data.result?.status.ready, error: event.data.error?.message }); }; worker.onerror = (event) => { worker.terminate(); resolve({ ok: false, error: event.message }); }; worker.postMessage({ requestId, command: { type: "init" } }); })); expect(result).toEqual({ ok: true, ready: true }); }); test("transfers binary mesh buffers from the WebEngine worker", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ status: string; positions: number; indices: number; byteLength: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const requestId = "e2e-binary-geometry"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { meshes: Array<{ geometryStatus: string }> }; geometryBuffers?: Array<{ positions: ArrayBuffer; indices: ArrayBuffer; byteLength: number }> }; error?: { message: string } }>) => { if (event.data.requestId !== requestId) return; if (event.data.kind !== "result") return; worker.terminate(); if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.geometryBuffers?.[0]) { reject(new Error(event.data.error?.message ?? `binary geometry response missing: ${JSON.stringify(event.data)}`)); return; } const mesh = event.data.result.snapshot.meshes[0]; const payload = event.data.result.geometryBuffers[0]; resolve({ status: mesh.geometryStatus, positions: payload.positions.byteLength, indices: payload.indices.byteLength, byteLength: payload.byteLength }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.status).toBe("binary"); expect(result.positions).toBe(8 * 3 * 4); expect(result.indices).toBe(12 * 3 * 4); expect(result.byteLength).toBeGreaterThan(result.positions + result.indices); }); test("returns a revisioned native SceneDelta", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ baseRevision: number; nextRevision: number; added: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-delta-open"; const deltaRequestId = "e2e-delta-read"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { delta?: { baseRevision: number; nextRevision: number; nodes?: { added?: unknown[] } } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok) { worker.postMessage({ requestId: deltaRequestId, command: { type: "delta" } }); return; } if (event.data.requestId !== deltaRequestId) return; worker.terminate(); const delta = event.data.result?.delta; if (!event.data.ok || !delta) { reject(new Error(event.data.error?.message ?? "delta response missing")); return; } resolve({ baseRevision: delta.baseRevision, nextRevision: delta.nextRevision, added: delta.nodes?.added?.length ?? 0 }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result).toEqual({ baseRevision: 0, nextRevision: 1, added: 3 }); }); test("applies an object-mode command and returns the matching SceneDelta", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ frame: number; frameBase: number; frameNext: number; visible: boolean; visibilityBase: number; visibilityNext: number; }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-command-open"; const frameRequestId = "e2e-command-frame"; const visibilityRequestId = "e2e-command-visibility"; let openedRevision = 0; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; frame: { current: number }; nodes: Array<{ id: string; visible: boolean }> }; delta?: { baseRevision: number; nextRevision: number; frame?: { current: number } }; }; error?: { message: string }; }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) { openedRevision = event.data.result.snapshot.revision; worker.postMessage({ requestId: frameRequestId, command: { type: "applyCommand", payload: { type: "setFrame", frame: 12 } } }); return; } if (event.data.requestId === frameRequestId && event.data.ok && event.data.result?.delta) { const delta = event.data.result.delta; if (delta.baseRevision !== openedRevision || delta.nextRevision !== openedRevision + 1 || delta.frame?.current !== 12) { worker.terminate(); reject(new Error(`unexpected frame command delta: ${JSON.stringify(delta)}`)); return; } worker.postMessage({ requestId: visibilityRequestId, command: { type: "applyCommand", payload: { type: "setObjectVisibility", objectId: "object:BasicCube", visible: false } } }); return; } if (event.data.requestId !== visibilityRequestId) return; worker.terminate(); if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.delta) { reject(new Error(event.data.error?.message ?? "visibility command failed")); return; } const snapshot = event.data.result.snapshot; const delta = event.data.result.delta; resolve({ frame: snapshot.frame.current, frameBase: openedRevision, frameNext: openedRevision + 1, visible: snapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible ?? true, visibilityBase: delta.baseRevision, visibilityNext: delta.nextRevision, }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.frame).toBe(12); expect(result.frameBase).toBe(1); expect(result.frameNext).toBe(2); expect(result.visible).toBe(false); expect(result.visibilityBase).toBe(2); expect(result.visibilityNext).toBe(3); }); test("undoes and redoes native scene commands without a Three.js shadow state", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ undoFrame: number; redoFrame: number; undoRevision: number; redoRevision: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const ids = { open: "history-open", apply: "history-apply", undo: "history-undo", redo: "history-redo" }; let undoFrame = 0; let undoRevision = 0; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; frame: { current: number } } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "history command failed")); return; } if (event.data.requestId === ids.open) worker.postMessage({ requestId: ids.apply, command: { type: "applyCommand", payload: { type: "setFrame", frame: 12 } } }); else if (event.data.requestId === ids.apply) worker.postMessage({ requestId: ids.undo, command: { type: "applyCommand", payload: { type: "undo" } } }); else if (event.data.requestId === ids.undo && event.data.result?.snapshot) { undoFrame = event.data.result.snapshot.frame.current; undoRevision = event.data.result.snapshot.revision; worker.postMessage({ requestId: ids.redo, command: { type: "applyCommand", payload: { type: "redo" } } }); } else if (event.data.requestId === ids.redo && event.data.result?.snapshot) { worker.terminate(); resolve({ undoFrame, redoFrame: event.data.result.snapshot.frame.current, undoRevision, redoRevision: event.data.result.snapshot.revision }); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: ids.open, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.undoFrame).toBe(1); expect(result.redoFrame).toBe(12); expect(result.undoRevision).toBe(3); expect(result.redoRevision).toBe(4); }); test("wires Blender Web top-bar undo and redo to native history", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "跳到最后一帧" }).click(); await expect(page.locator(".frame-number")).toHaveText("24"); await page.getByRole("button", { name: "撤销" }).click(); await expect(page.locator(".frame-number")).toHaveText("1"); await page.getByRole("button", { name: "重做" }).click(); await expect(page.locator(".frame-number")).toHaveText("24"); }); test("applies Blender's native Decimate topology through the WebEngine command path", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ originalTriangles: number; outputTriangles: number; reportedOriginal: number; reportedOutput: number; originalVertices: number; outputVertices: number; geometryBytes: number; normals: number; corners: number; uvs: number; materials: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-decimate-open"; const decimateRequestId = "e2e-decimate-apply"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; vertexCount: number; triangleCount?: number }> }; geometryDelta?: { replaced: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer; normals?: ArrayBuffer; triangleCornerIndices?: ArrayBuffer; uvs?: ArrayBuffer; triangleMaterialIndices?: ArrayBuffer }> }; simplify?: { originalTriangleCount: number; outputTriangleCount: number } }; error?: { message: string }; }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) { const mesh = event.data.result.snapshot.meshes[0]; worker.postMessage({ requestId: decimateRequestId, command: { type: "applyCommand", payload: { type: "decimateMesh", meshId: mesh.id, profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, vertexGroup: null, vertexGroupFactor: 1, vertexGroupInvert: false, triangulate: false, useSymmetry: false, symmetryAxis: 0, symmetryTolerance: 1e-4 }, } } }); return; } if (event.data.requestId !== decimateRequestId) return; worker.terminate(); if (!event.data.ok || !event.data.result?.snapshot || !event.data.result.geometryDelta?.replaced[0] || !event.data.result.simplify) { reject(new Error(event.data.error?.message ?? "native decimate command failed")); return; } const mesh = event.data.result.snapshot.meshes[0]; const geometry = event.data.result.geometryDelta.replaced[0]; resolve({ originalTriangles: 12, outputTriangles: mesh.triangleCount ?? 0, reportedOriginal: event.data.result.simplify.originalTriangleCount, reportedOutput: event.data.result.simplify.outputTriangleCount, originalVertices: 8, outputVertices: mesh.vertexCount, geometryBytes: geometry.positions.byteLength + geometry.indices.byteLength, normals: geometry.normals ? new Float32Array(geometry.normals).length : 0, corners: geometry.triangleCornerIndices ? new Uint32Array(geometry.triangleCornerIndices).length : 0, uvs: geometry.uvs ? new Float32Array(geometry.uvs).length : 0, materials: geometry.triangleMaterialIndices ? new Uint32Array(geometry.triangleMaterialIndices).length : 0, }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.outputTriangles).toBeLessThan(result.originalTriangles); expect(result.reportedOriginal).toBe(result.originalTriangles); expect(result.reportedOutput).toBe(result.outputTriangles); expect(result.outputVertices).toBeLessThan(result.originalVertices); expect(result.geometryBytes).toBeGreaterThan(0); expect(result.normals).toBe(result.outputVertices * 3); expect(result.corners).toBe(result.outputTriangles * 3); expect(result.uvs).toBe(result.outputTriangles * 6); expect(result.materials).toBe(result.outputTriangles); }); test("previews Decimate on an isolated WASM handle without changing source revision", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ openedRevision: number; previewRevision: number; previewTriangles: number; sourceRevision: number; sourceTriangles: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-preview-open"; const previewRequestId = "e2e-preview-run"; const sourceRequestId = "e2e-preview-source"; let previewTriangles = 0; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; simplify?: { outputTriangleCount: number } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) { const snapshot = event.data.result.snapshot; const mesh = snapshot.meshes[0]; worker.postMessage({ requestId: previewRequestId, command: { type: "applyCommand", payload: { type: "previewDecimateMesh", meshId: mesh.id, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false }, } } }); return; } if (event.data.requestId === previewRequestId && event.data.ok && event.data.result?.snapshot && event.data.result.simplify) { previewTriangles = event.data.result.snapshot.meshes[0].triangleCount ?? 0; worker.postMessage({ requestId: sourceRequestId, command: { type: "snapshot" } }); return; } if (event.data.requestId !== sourceRequestId) return; worker.terminate(); if (!event.data.ok || !event.data.result?.snapshot) { reject(new Error(event.data.error?.message ?? "preview source snapshot failed")); return; } resolve({ openedRevision: 1, previewRevision: event.data.result.snapshot.revision, previewTriangles, sourceRevision: event.data.result.snapshot.revision, sourceTriangles: event.data.result.snapshot.meshes[0].triangleCount ?? 0, }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.openedRevision).toBe(1); expect(result.previewRevision).toBe(result.openedRevision); expect(result.previewTriangles).toBeLessThan(result.sourceTriangles); expect(result.sourceRevision).toBe(result.openedRevision); expect(result.sourceTriangles).toBe(12); }); test("generates independent multi-level LOD geometry from the original mesh", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ sourceRevision: number; sourceTriangles: number; levels: Array<{ level: number; meshId: string; budget: number; triangles: number; vertices: number; bytes: number }>; manifestBudgets: number[] }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-lod-open"; const generateRequestId = "e2e-lod-generate"; const sourceRequestId = "e2e-lod-source"; let generated: { levels: Array<{ level: number; meshId: string; triangleBudget: number; outputTriangleCount: number; outputVertexCount: number; geometryBuffers: Array<{ positions: ArrayBuffer; indices: ArrayBuffer }> }>; manifest: { sourceMeshRevision: number; levels: Array<{ triangleBudget: number }> } } | undefined; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; lod?: typeof generated }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok && event.data.result?.snapshot) { const snapshot = event.data.result.snapshot; const mesh = snapshot.meshes[0]; worker.postMessage({ requestId: generateRequestId, command: { type: "generateLOD", payload: { meshId: mesh.id, sourceMeshRevision: snapshot.revision, levels: [ { triangleBudget: 10, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.9, triangulate: false, useSymmetry: false } }, { triangleBudget: 6, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false } }, ], } } }); return; } if (event.data.requestId === generateRequestId && event.data.ok && event.data.result?.lod) { generated = event.data.result.lod; worker.postMessage({ requestId: sourceRequestId, command: { type: "snapshot" } }); return; } if (event.data.requestId !== sourceRequestId) return; worker.terminate(); if (!event.data.ok || !event.data.result?.snapshot || !generated) { reject(new Error(event.data.error?.message ?? "LOD generation failed")); return; } resolve({ sourceRevision: event.data.result.snapshot.revision, sourceTriangles: event.data.result.snapshot.meshes[0].triangleCount ?? 0, manifestBudgets: generated.manifest.levels.map((level) => level.triangleBudget), levels: generated.levels.map((level) => ({ level: level.level, meshId: level.meshId, budget: level.triangleBudget, triangles: level.outputTriangleCount, vertices: level.outputVertexCount, bytes: level.geometryBuffers.reduce((total, geometry) => total + geometry.positions.byteLength + geometry.indices.byteLength, 0), })), }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.sourceRevision).toBe(1); expect(result.sourceTriangles).toBe(12); expect(result.manifestBudgets).toEqual([10, 6]); expect(result.levels).toHaveLength(2); expect(result.levels[0].meshId).toBe("mesh:Cube.001:lod:0"); expect(result.levels[1].meshId).toBe("mesh:Cube.001:lod:1"); expect(result.levels.every((level) => level.triangles > 0 && level.vertices > 0 && level.bytes > 0)).toBe(true); }); test("isolates unstable native Collapse targets and keeps successful LOD levels", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ levels: number; errors: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; triangleCount?: number }> }; lod?: { levels: unknown[]; errors?: unknown[] } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "LOD isolation failed")); return; } if (event.data.requestId === "lod-unstable-open" && event.data.result?.snapshot) { const mesh = event.data.result.snapshot.meshes[0]; worker.postMessage({ requestId: "lod-unstable-generate", command: { type: "generateLOD", payload: { meshId: mesh.id, sourceMeshRevision: event.data.result.snapshot.revision, levels: [ { triangleBudget: 9, profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.75, triangulate: false, useSymmetry: false } }, { triangleBudget: 6, profile: { schemaVersion: 1, sourceMeshRevision: event.data.result.snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: false, useSymmetry: false } }, ] } } }); } else if (event.data.requestId === "lod-unstable-generate" && event.data.result?.lod) { worker.terminate(); resolve({ levels: event.data.result.lod.levels.length, errors: event.data.result.lod.errors?.length ?? 0 }); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: "lod-unstable-open", command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.levels).toBeGreaterThan(0); expect(result.errors).toBeGreaterThanOrEqual(0); }); test("validates Blender Decimate and LOD profiles at the protocol boundary", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { return await new Promise<{ ok: boolean; profile?: { mode: string; ratio?: number; attributePolicy: string; skinMaxInfluences?: number }; levels?: number; errorCode?: string }>((resolve, reject) => { const worker = new Worker("/src/workers/simplify-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; profile?: { mode: string; ratio?: number; attributePolicy: string; skinMaxInfluences?: number }; levels?: number; errorCode?: string; error?: string }>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error ?? "simplify protocol failed")); else resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ profile: { schemaVersion: 1, mode: "COLLAPSE", sourceMeshRevision: 4, ratio: 0.25, attributePolicy: "PRESERVE", skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" }, triangleBudget: 1200, vertexGroup: "KeepHead", vertexGroupFactor: 0.8, vertexGroupInvert: false, triangulate: true, useSymmetry: true, symmetryAxis: 0, symmetryTolerance: 0.0001, }, manifest: { schemaVersion: 1, meshId: "mesh:Character", sourceMeshRevision: 4, levels: [ { level: 0, sourceMeshRevision: 4, triangleBudget: 4800, meshId: "mesh:Character:l0" }, { level: 1, sourceMeshRevision: 4, triangleBudget: 1200, meshId: "mesh:Character:l1", screenSpaceError: 2 }, ], }, invalidProfile: { schemaVersion: 1, mode: "COLLAPSE", sourceMeshRevision: 4, ratio: 0.5, vertexGroupFactor: 0.2 }, }); }); }); expect(result).toEqual({ ok: true, profile: { mode: "COLLAPSE", ratio: 0.25, attributePolicy: "PRESERVE", skinMaxInfluences: 4 }, levels: 2, errorCode: "MISSING_VERTEX_GROUP" }); }); test("normalizes skin influences and rejects shape-key loss explicitly", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ rejected: string; preservedAllowed: boolean; normalized?: number[] }>((resolve, reject) => { const worker = new Worker("/src/workers/skin-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: { reason?: string; normalized?: { weights: number[] } }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "skin gate failed")); else resolve({ rejected: event.data.result.reason ?? "", preservedAllowed: event.data.result.allowed, normalized: event.data.result.normalized?.weights.slice(0, 4) }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const skin = { boneNames: ["Root", "Arm"], indices: [0, 1, 0, 1, 0, 1, 0, 1], weights: [0.7, 0.3, 0, 0, 0.2, 0.8, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }; const shapeKeys = [{ name: "Smile", positions: [0, 0, 0, 1, 0, 0] }]; const policy = { maxInfluences: 2, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" as const }; worker.postMessage({ vertexCount: 2, skin, shapeKeys, policy: { ...policy, shapeKeys: "PRESERVE" } }); })); const rejected = await page.evaluate(() => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/skin-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: { reason?: string }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.result) reject(new Error(event.data.error ?? "skin gate failed")); else resolve(event.data.result.reason ?? ""); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ vertexCount: 2, skin: { boneNames: ["Root", "Arm"], indices: [0, 1, 0, 1, 0, 1, 0, 1], weights: [0.7, 0.3, 0, 0, 0.2, 0.8, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, shapeKeys: [{ name: "Smile", positions: [0, 0, 0, 1, 0, 0] }], policy: { maxInfluences: 2, minWeight: 0.001, maxPositionError: 0.01, shapeKeys: "REJECT" } }); })); result.rejected = rejected; expect(result).toEqual({ rejected: "SHAPE_KEYS_REJECTED", preservedAllowed: true, normalized: [0.7, 0.3, 0, 0] }); }); test("remaps skin weights and shape keys through the native Collapse worker path", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend)); const result = await page.evaluate((input) => new Promise<{ vertices: number; skinEntries: number; shapeEntries: number; normalized: boolean }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openId = "skin-remap-open"; const disableDecimateId = "skin-remap-disable-decimate"; const disableArmatureId = "skin-remap-disable-armature"; const collapseId = "skin-remap-collapse"; let meshId = ""; let decimateUuid = ""; let armatureUuid = ""; const fail = (message: string) => { worker.terminate(); reject(new Error(message)); }; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { revision: number; meshes: Array<{ id: string; vertexCount: number; shapeKeys?: Array<{ positions: number[] }>; skinWeights?: { indices: number[]; weights: number[] }; modifierStack?: Array<{ uuid: string; type: string; name: string }> }> } }; error?: { message: string }; }>) => { if (event.data.kind !== "result") return; if (!event.data.ok || !event.data.result?.snapshot) return fail(event.data.error?.message ?? "skin remap worker request failed"); const snapshot = event.data.result.snapshot; const mesh = snapshot.meshes.find((candidate) => candidate.id === meshId) ?? snapshot.meshes[0]; if (!mesh) return fail("rigged mesh is missing"); if (event.data.requestId === openId) { meshId = mesh.id; decimateUuid = mesh.modifierStack?.find((modifier) => modifier.name === "Preview Decimate")?.uuid ?? ""; armatureUuid = mesh.modifierStack?.find((modifier) => modifier.type === "ARMATURE")?.uuid ?? ""; if (!decimateUuid || !armatureUuid) return fail("rigged modifier stack is missing"); worker.postMessage({ requestId: disableDecimateId, command: { type: "applyCommand", payload: { type: "setModifierEnabled", meshId, modifierUuid: decimateUuid, enabled: false } } }); return; } if (event.data.requestId === disableDecimateId) { worker.postMessage({ requestId: disableArmatureId, command: { type: "applyCommand", payload: { type: "setModifierEnabled", meshId, modifierUuid: armatureUuid, enabled: false } } }); return; } if (event.data.requestId === disableArmatureId) { worker.postMessage({ requestId: collapseId, command: { type: "applyCommand", payload: { type: "decimateMesh", meshId, profile: { schemaVersion: 1, sourceMeshRevision: snapshot.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, vertexGroup: null, vertexGroupFactor: 1, vertexGroupInvert: false, triangulate: false, useSymmetry: false, symmetryAxis: 0, symmetryTolerance: 0.0001, skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.001, shapeKeys: "PRESERVE" } } } } }); return; } if (event.data.requestId !== collapseId || !mesh.skinWeights || !mesh.shapeKeys?.[0]) return fail("skin remap result is missing"); const normalized = Array.from({ length: mesh.vertexCount }, (_, vertex) => { const total = mesh.skinWeights?.weights.slice(vertex * 4, vertex * 4 + 4).reduce((sum, weight) => sum + weight, 0) ?? 0; return Math.abs(total - 1) < 1e-5; }).every(Boolean); worker.terminate(); resolve({ vertices: mesh.vertexCount, skinEntries: mesh.skinWeights.indices.length, shapeEntries: mesh.shapeKeys[0].positions.length, normalized }); }; worker.onerror = (event) => fail(event.message); const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result).toEqual({ vertices: 3, skinEntries: 12, shapeEntries: 9, normalized: true }); }); test("reports GLB export blockers before any binary export", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ canExport: boolean; codes: string[] }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean; warnings: Array<{ code: string }> }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB warning report missing")); else resolve({ canExport: event.data.report.canExport, codes: event.data.report.warnings.map((warning) => warning.code) }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { meshes: [{ id: "mesh:Summary", name: "Summary", geometryStatus: "summary-only", vertexCount: 0, edgeCount: 0, faceCount: 0, cornerCount: 0 }], images: [{ id: "image:External", name: "External", assetId: "asset:External", sourcePath: "textures/a.png" }], materials: [], nodes: [], collections: [], scenes: [] } }); })); expect(result.canExport).toBe(false); expect(result.codes).toEqual(expect.arrayContaining(["SUMMARY_ONLY_MESH", "EXTERNAL_IMAGE"])); }); test("blocks Shader graphs that cannot be mapped to glTF PBR", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ canExport: boolean; codes: string[] }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean; warnings: Array<{ code: string }> }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB Shader graph report missing")); else resolve({ canExport: event.data.report.canExport, codes: event.data.report.warnings.map((warning) => warning.code) }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { meshes: [{ id: "mesh:Graph", name: "Graph", geometryStatus: "available", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, topology: "triangles", positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] }], materials: [{ id: "material:Graph", name: "Graph", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, nodes: [ { id: "rgb", type: "RGB", name: "Color" }, { id: "principled", type: "PRINCIPLED", name: "Principled" }, { id: "output", type: "OUTPUT", name: "Output" }, ], links: [ { fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" }, { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, ] }], images: [], animations: [], nodes: [], collections: [], scenes: [], frame: { current: 1, start: 1, end: 1 }, } }); })); expect(result.canExport).toBe(false); expect(result.codes).toContain("SHADER_GRAPH_UNMAPPABLE"); }); test("maps bounded RGB and Value Shader constants to glTF PBR factors", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ canExport: boolean; pbr?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number } }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB Shader factor report missing")); else resolve({ canExport: event.data.report.canExport, pbr: event.data.pbrSummary }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { meshes: [{ id: "mesh:Constants", name: "Constants", geometryStatus: "available", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, topology: "triangles", positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] }], materials: [{ id: "material:Constants", name: "Constants", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0.1, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, nodes: [ { id: "rgb", type: "RGB", name: "Color", defaultValue: [0.1, 0.3, 0.7, 1] }, { id: "roughness", type: "VALUE", name: "Roughness", defaultValue: [0.42] }, { id: "metallic", type: "VALUE", name: "Metallic", defaultValue: [0.75] }, { id: "principled", type: "PRINCIPLED", name: "Principled" }, { id: "output", type: "OUTPUT", name: "Output" }, ], links: [ { fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" }, { fromNodeId: "roughness", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" }, { fromNodeId: "metallic", fromSocket: "Value", toNodeId: "principled", toSocket: "Metallic" }, { fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }, ] }], images: [], animations: [], nodes: [], collections: [], scenes: [], frame: { current: 1, start: 1, end: 1 }, } }); })); expect(result.canExport).toBe(true); expect(result.pbr?.baseColorFactor).toEqual([0.1, 0.3, 0.7, 1]); expect(result.pbr?.roughnessFactor).toBeCloseTo(0.42); expect(result.pbr?.metallicFactor).toBeCloseTo(0.75); }); test("exports a local SceneIR mesh as a standards-shaped GLB", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ canExport: boolean; byteLength: number; magic?: number; version?: number; firstMorphDelta?: number[] }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer; const indices = new Uint32Array([0, 1, 2]).buffer; worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; byteLength?: number; magic?: number; version?: number; firstMorphDelta?: number[]; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "GLB export failed")); else resolve({ canExport: event.data.report.canExport, byteLength: event.data.byteLength ?? 0, magic: event.data.magic, version: event.data.version, firstMorphDelta: event.data.firstMorphDelta }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { schemaVersion: 1, revision: 1, sceneId: "scene:test", source: { kind: "mock" }, coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, nodes: [{ id: "object:Triangle", name: "Triangle", type: "MESH", parentId: null, dataId: "mesh:Triangle", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }], meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles", shapeKeys: [{ name: "Lift", positions: [0, 0, 0.5, 1, 0, 0, 0, 1, 0] }] }], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], activeObjectId: "object:Triangle", frame: { current: 1, start: 1, end: 1 }, }, geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength, positions, indices }], }, [positions, indices]); })); expect(result.canExport).toBe(true); expect(result.byteLength).toBeGreaterThan(20); expect(result.magic).toBe(0x46546c67); expect(result.version).toBe(2); expect(result.firstMorphDelta?.map((value) => Number(value.toFixed(4)))).toEqual([0, 0.5, 0]); }); test("keeps per-vertex UV and color attributes in GLB output", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ ok: boolean; error?: string }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer; const indices = new Uint32Array([0, 1, 2]).buffer; const uvs = new Float32Array([0, 0, 1, 0, 0, 1]).buffer; const colors = new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]).buffer; worker.onmessage = (event: MessageEvent<{ ok: boolean; attributes?: string[]; error?: string }>) => { worker.terminate(); if (!event.data.ok) reject(new Error(event.data.error ?? "GLB vertex attributes failed")); else resolve({ ok: event.data.attributes?.includes("TEXCOORD_0") === true && event.data.attributes?.includes("COLOR_0") === true, error: event.data.error }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { schemaVersion: 1, revision: 1, sceneId: "scene:attributes", source: { kind: "mock" }, nodes: [], meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles" }], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [], activeObjectId: null, frame: { current: 1, start: 1, end: 1 }, }, geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength + uvs.byteLength + colors.byteLength, positions, indices, uvs, colors }], }, [positions, indices, uvs, colors]); })); expect(result).toEqual({ ok: true }); }); test("evaluates modifier dependency order and blocks unevaluated or cyclic stacks", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ status: string; order: string[]; cycleStatus: string; cycleBlockedBy: string[] }>((resolve, reject) => { const worker = new Worker("/src/workers/modifier-graph-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { status: string; order: string[] }; cycle?: { status: string; blockedBy: string[] }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report || !event.data.cycle) reject(new Error(event.data.error ?? "modifier graph report missing")); else resolve({ status: event.data.report.status, order: event.data.report.order, cycleStatus: event.data.cycle.status, cycleBlockedBy: event.data.cycle.blockedBy }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result).toEqual({ status: "METADATA_ONLY", order: ["mod:source", "mod:decimate"], cycleStatus: "BLOCKED", cycleBlockedBy: ["MODIFIER_DEPENDENCY_CYCLE"] }); }); test("embeds local textures and exports glTF skin and animation records", async ({ page }) => { await page.goto("/"); const textureBytes = await import("node:fs").then((fs) => fs.readFileSync(localTexturePng)); const result = await page.evaluate((input) => new Promise<{ canExport: boolean; imageCount: number; skinCount: number; animationCount: number; imageByteLength: number; imageMimeType?: string; imageSignature: number[]; skinJointCount: number; animationChannelCount: number; inverseBindType?: string; attributes: string[]; roundTripCompatible: boolean; roundTripMismatches: string[] }>((resolve, reject) => { const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" }); const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer; const indices = new Uint32Array([0, 1, 2]).buffer; const texture = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { canExport: boolean }; attributes?: string[]; gltfSummary?: { imageCount: number; skinCount: number; animationCount: number; imageByteLength: number; imageMimeType?: string; imageSignature: number[]; skinJointCount: number; animationChannelCount: number; inverseBindType?: string }; roundTrip?: { compatible: boolean; mismatches: string[] }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report || !event.data.gltfSummary || !event.data.roundTrip) reject(new Error(event.data.error ?? "GLB skin export failed")); else resolve({ canExport: event.data.report.canExport, ...event.data.gltfSummary, attributes: event.data.attributes ?? [], roundTripCompatible: event.data.roundTrip.compatible, roundTripMismatches: event.data.roundTrip.mismatches }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ snapshot: { schemaVersion: 1, revision: 3, sceneId: "scene:skin", source: { kind: "mock" }, nodes: [ { id: "object:Armature", name: "Armature", type: "ARMATURE", parentId: null, dataId: "armature:Rig", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }, { id: "object:Triangle", name: "Triangle", type: "MESH", parentId: null, dataId: "mesh:Triangle", visible: true, selectable: true, localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }, ], meshes: [{ id: "mesh:Triangle", name: "Triangle", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:Triangle", topology: "triangles", materialSlotIds: ["material:Skin"], skinWeights: { boneNames: ["Root", "Tip"], indices: [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0], weights: [0.5, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0.5, 0.5, 0, 0], bindMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], armatureId: "armature:Rig", jointIds: ["bone:Root", "bone:Tip"] } }], armatures: [{ id: "armature:Rig", name: "Rig", objectId: "object:Armature", bones: [ { id: "bone:Root", name: "Root", parentId: null, head: [0, 0, 0], tail: [0, 0, 1], restMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], poseMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }, { id: "bone:Tip", name: "Tip", parentId: "bone:Root", head: [0, 0, 1], tail: [0, 0, 2], restMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1], poseMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1] }, ] }], materials: [{ id: "material:Skin", name: "Skin", baseColor: [1, 1, 1, 1], roughness: 0.5, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, imageIds: ["image:Texture"] }], cameras: [], lights: [], worlds: [], images: [{ id: "image:Texture", name: "Texture", assetId: "asset:Texture", mimeType: "image/png", sourcePath: "texture.png" }], collections: [], scenes: [], activeObjectId: "object:Triangle", animations: [{ id: "action:Move", name: "Move", targetId: "object:Triangle", frameStart: 1, frameEnd: 2, channels: [{ path: "location[0]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [1] }] }, { path: "location[1]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [0] }] }, { path: "location[2]", keyframes: [{ frame: 1, value: [0] }, { frame: 2, value: [0] }] }] }], frame: { current: 1, start: 1, end: 2 }, }, geometryBuffers: [{ schemaVersion: 1, meshId: "mesh:Triangle", byteLength: positions.byteLength + indices.byteLength, positions, indices }], assetBuffers: [{ assetId: "asset:Texture", mimeType: "image/png", data: texture }], }, [positions, indices, texture]); }), new Uint8Array(textureBytes)); expect(result.canExport).toBe(true); expect(result.imageCount).toBe(1); expect(result.skinCount).toBe(1); expect(result.animationCount).toBe(1); expect(result.imageByteLength).toBe(textureBytes.byteLength); expect(result.imageMimeType).toBe("image/png"); expect(result.imageSignature).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); expect(result.skinJointCount).toBe(2); expect(result.animationChannelCount).toBeGreaterThan(0); expect(result.inverseBindType).toBe("MAT4"); expect(result.attributes).toEqual(expect.arrayContaining(["JOINTS_0", "WEIGHTS_0"])); expect(result.roundTripCompatible, result.roundTripMismatches.join("; ")).toBe(true); expect(result.roundTripMismatches).toEqual([]); }); test("reports lightweight budget violations without altering usage", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ withinBudget: boolean; violations: Array<[string, number]>; usage: Record }>((resolve, reject) => { const worker = new Worker("/src/workers/budget-test.worker.ts", { type: "module" }); const usage = { triangles: 1200, vertices: 700, indexBytes: 14400, materialSlots: 3, textureBytes: 4096, gpuBytes: 32768 }; worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { withinBudget: boolean; violations: Array<{ metric: string; excess: number }>; usage: Record }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report) reject(new Error(event.data.error ?? "budget report missing")); else resolve({ withinBudget: event.data.report.withinBudget, violations: event.data.report.violations.map((item) => [item.metric, item.excess]), usage: event.data.report.usage }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ budget: { schemaVersion: 1, triangles: 1000, vertices: 800, gpuBytes: 20000 }, usage }); })); expect(result.withinBudget).toBe(false); expect(result.violations).toEqual([["triangles", 200], ["gpuBytes", 12768]]); expect(result.usage).toEqual({ triangles: 1200, vertices: 700, indexBytes: 14400, materialSlots: 3, textureBytes: 4096, gpuBytes: 32768 }); }); test("aggregates project, collection, object and LOD budgets without double counting LOD", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ project: number; collection: number; object: number; lod: number; projectActual: number }>((resolve, reject) => { const worker = new Worker("/src/workers/budget-aggregation-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; report?: { project?: { violations: Array<{ excess: number }>; usage: { triangles: number } }; collections: Record }>; objects: Record }>; lod: Record }>; }; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.report?.project) reject(new Error(event.data.error ?? "budget aggregation report missing")); else resolve({ project: event.data.report.project.violations[0]?.excess ?? 0, collection: event.data.report.collections.B?.violations[0]?.excess ?? 0, object: event.data.report.objects.A?.violations[0]?.excess ?? 0, lod: event.data.report.lod.L0?.violations[0]?.excess ?? 0, projectActual: event.data.report.project.usage.triangles, }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const usage = (triangles: number) => ({ triangles, vertices: triangles, indexBytes: triangles * 12, materialSlots: 1, textureBytes: 0, gpuBytes: triangles * 16 }); worker.postMessage({ objects: [{ objectId: "A", collectionIds: ["A", "B"], usage: usage(100) }, { objectId: "B", collectionIds: ["B"], usage: usage(50) }], lod: [{ lodId: "L0", objectId: "A", usage: usage(25) }], scopes: { project: { schemaVersion: 1, triangles: 120 }, collections: { B: { schemaVersion: 1, triangles: 120 } }, objects: { A: { schemaVersion: 1, vertices: 90 } }, lod: { L0: { schemaVersion: 1, triangles: 20 } } }, }); })); expect(result).toEqual({ project: 30, collection: 30, object: 10, lod: 5, projectActual: 150 }); }); test("round-trips LOD geometry through the local binary mesh cache container", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ byteLength: number; meshId: string; positionBytes: number; uvBytes: number }>((resolve, reject) => { const worker = new Worker("/src/workers/mesh-cache-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent<{ ok: boolean; byteLength?: number; levels?: Array<{ geometryBuffers: Array<{ meshId: string; positions: ArrayBuffer; uvs?: ArrayBuffer }> }>; error?: string }>) => { worker.terminate(); if (!event.data.ok || !event.data.levels?.[0]?.geometryBuffers[0]) reject(new Error(event.data.error ?? "mesh cache round trip failed")); else { const geometry = event.data.levels[0].geometryBuffers[0]; resolve({ byteLength: event.data.byteLength ?? 0, meshId: geometry.meshId, positionBytes: geometry.positions.byteLength, uvBytes: geometry.uvs?.byteLength ?? 0 }); } }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer; const indices = new Uint32Array([0, 1, 2]).buffer; const uvs = new Float32Array([0, 0, 1, 0, 0, 1]).buffer; worker.postMessage({ levels: [{ level: 0, meshId: "mesh-cache-l0", triangleBudget: 1, outputTriangleCount: 1, outputVertexCount: 3, geometryBuffers: [{ schemaVersion: 1, meshId: "mesh-cache-l0", byteLength: positions.byteLength + indices.byteLength + uvs.byteLength, positions, indices, uvs }] }] }, [positions, indices, uvs]); })); expect(result.byteLength).toBeGreaterThan(result.positionBytes + result.uvBytes); expect(result.meshId).toBe("mesh-cache-l0"); expect(result.positionBytes).toBe(36); expect(result.uvBytes).toBe(24); }); test("blocks ImageIR paths outside the project asset sandbox", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(attributeBlend)); const result = await page.evaluate((input) => new Promise<{ status: string; assetId: string; sourcePath?: string; safeSourcePath?: string; pathStatus?: string; errorCode?: string; hasBase64: boolean }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-asset-open"; const assetRequestId = "e2e-asset-request"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { asset?: { status: string; assetId: string; sourcePath?: string; [key: string]: unknown } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok) { worker.postMessage({ requestId: assetRequestId, command: { type: "requestAsset", assetId: "image:AttributeTexture" } }); return; } if (event.data.requestId !== assetRequestId) return; worker.terminate(); if (!event.data.ok || !event.data.result?.asset) { reject(new Error(event.data.error?.message ?? "asset request failed")); return; } const asset = event.data.result.asset; resolve({ status: asset.status, assetId: asset.assetId, sourcePath: asset.sourcePath, safeSourcePath: typeof asset.safeSourcePath === "string" ? asset.safeSourcePath : undefined, pathStatus: typeof asset.pathStatus === "string" ? asset.pathStatus : undefined, errorCode: typeof asset.errorCode === "string" ? asset.errorCode : undefined, hasBase64: Object.keys(asset).some((key) => key.toLowerCase().includes("base64")), }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.status).toBe("blocked"); expect(result.assetId).toBe("image:AttributeTexture"); expect(result.sourcePath).toMatch(/textures\/attribute\.png$/); expect(result.safeSourcePath).toBeUndefined(); expect(result.pathStatus).toBe("blocked"); expect(result.errorCode).toBe("ASSET_PATH_OUTSIDE_PROJECT"); expect(result.hasBase64).toBe(false); }); test("extracts Blender packed image bytes through the local asset request API", async ({ page }) => { await page.goto("/"); const packedBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend"); const expectedTexture = path.resolve(import.meta.dirname, "../../../tests/golden/W-010/desktop-1440x900.png"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(packedBlend)); const expected = await import("node:fs").then((fs) => fs.readFileSync(expectedTexture)); const result = await page.evaluate((input) => new Promise<{ status: string; byteLength: number; packedByteLength?: number; signature: number[]; mimeType?: string }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-packed-open"; const assetRequestId = "e2e-packed-request"; let packedByteLength: number | undefined; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { images: Array<{ assetId: string; packedByteLength?: number }> }; asset?: { status: string; data?: ArrayBuffer; mimeType?: string } }; error?: { message: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok) { const image = event.data.result?.snapshot?.images.find((candidate) => candidate.assetId === "image:PackedTexture"); if (!image) { worker.terminate(); reject(new Error("packed SceneIR image missing")); return; } packedByteLength = image.packedByteLength; worker.postMessage({ requestId: assetRequestId, command: { type: "requestAsset", assetId: "image:PackedTexture" } }); return; } if (event.data.requestId !== assetRequestId) return; worker.terminate(); const asset = event.data.result?.asset; if (!event.data.ok || !asset?.data) { reject(new Error(event.data.error?.message ?? "packed asset request failed")); return; } const data = new Uint8Array(asset.data); resolve({ status: asset.status, byteLength: data.byteLength, packedByteLength, signature: Array.from(data.slice(0, 8)), mimeType: asset.mimeType }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.status).toBe("packed"); expect(result.byteLength).toBe(expected.byteLength); expect(result.packedByteLength).toBe(expected.byteLength); expect(result.mimeType).toBe("image/png"); expect(result.signature).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); }); test("matches Blender Depsgraph deformation golden within the declared error budget", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend)); const golden = JSON.parse(await import("node:fs").then((fs) => fs.readFileSync(deformationGolden, "utf8"))) as { positions: number[]; vertexCount: number; tolerance: { maxPositionError: number; rmsPositionError: number } }; const result = await page.evaluate((input) => new Promise<{ vertexCount: number; maxError: number; rmsError: number; positions: number[] }>((resolve, reject) => { const engine = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-deformation-open"; engine.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: unknown; geometryBuffers?: Array<{ meshId: string; positions: ArrayBuffer }> }; error?: { message: string } }>) => { if (event.data.kind !== "result" || event.data.requestId !== openRequestId) return; if (!event.data.ok || !event.data.result?.snapshot) { engine.terminate(); reject(new Error(event.data.error?.message ?? "deformation fixture open failed")); return; } const deformation = new Worker("/src/workers/deformation-test.worker.ts", { type: "module" }); deformation.onmessage = (deformationEvent: MessageEvent<{ ok: boolean; positions?: number[]; error?: string }>) => { engine.terminate(); deformation.terminate(); if (!deformationEvent.data.ok || !deformationEvent.data.positions) reject(new Error(deformationEvent.data.error ?? "deformation evaluation failed")); else { const positions = deformationEvent.data.positions; resolve({ vertexCount: positions.length / 3, maxError: 0, rmsError: 0, positions }); } }; deformation.onerror = (error) => { engine.terminate(); deformation.terminate(); reject(new Error(error.message)); }; deformation.postMessage({ snapshot: event.data.result.snapshot, meshId: "mesh:RiggedShapeMesh", geometryBuffers: event.data.result.geometryBuffers }); }; engine.onerror = (error) => { engine.terminate(); reject(new Error(error.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); engine.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); const errors = result.positions.map((value, index) => value - golden.positions[index]); const maxError = Math.max(...errors.map((value) => Math.abs(value))); const rmsError = Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length); expect(result.vertexCount).toBe(golden.vertexCount); expect(maxError).toBeLessThanOrEqual(golden.tolerance.maxPositionError); expect(rmsError).toBeLessThanOrEqual(golden.tolerance.rmsPositionError); }); test("evaluates the full Blender Depsgraph or reports its safe capability gate", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(riggedBlend)); const golden = JSON.parse(await import("node:fs").then((fs) => fs.readFileSync(deformationGolden, "utf8"))) as { positions: number[]; tolerance: { maxPositionError: number; rmsPositionError: number } }; const result = await page.evaluate((input) => new Promise<{ report?: DepsgraphE2EReport; blocked?: { code?: string; message?: string } }>((resolve, reject) => { const engine = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const openRequestId = "e2e-depsgraph-open"; const evalRequestId = "e2e-depsgraph-evaluate"; engine.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { depsgraph?: DepsgraphE2EReport }; error?: { code?: string; message?: string } }>) => { if (event.data.kind !== "result") return; if (event.data.requestId === openRequestId && event.data.ok) { engine.postMessage({ requestId: evalRequestId, command: { type: "evaluateDepsgraph" } }); return; } if (event.data.requestId !== evalRequestId) return; engine.terminate(); if (!event.data.ok || !event.data.result?.depsgraph) { if (event.data.error?.code === "CAPABILITY_MISSING" && event.data.error.message?.includes("headless frame evaluation is memory-safe")) { resolve({ blocked: event.data.error }); return; } reject(new Error(event.data.error?.message ?? "Depsgraph evaluation failed")); return; } resolve({ report: event.data.result.depsgraph }); }; engine.onerror = (error) => { engine.terminate(); reject(new Error(error.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); engine.postMessage({ requestId: openRequestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); if (result.blocked) { expect(result.blocked.code).toBe("CAPABILITY_MISSING"); expect(result.blocked.message).toContain("headless frame evaluation is memory-safe"); return; } expect(result.report).toBeDefined(); if (!result.report) throw new Error("Depsgraph report missing after successful evaluation"); expect(result.report.status).toBe("EVALUATED"); expect(result.report.objectCount).toBeGreaterThanOrEqual(result.report.meshObjectCount); expect(result.report.meshObjectCount).toBeGreaterThan(0); const mesh = result.report.meshes.find((candidate) => candidate.sourceMeshId === "mesh:RiggedShapeMesh"); expect(mesh).toBeDefined(); expect(mesh?.vertexCount).toBeGreaterThan(0); expect(mesh?.triangleCount).toBeGreaterThan(0); expect(mesh?.positions).toHaveLength((mesh?.vertexCount ?? 0) * 3); expect(mesh?.indices).toHaveLength((mesh?.triangleCount ?? 0) * 3); expect(mesh?.modifierCount).toBeGreaterThan(0); expect(mesh?.modifiers.map((modifier) => modifier.type)).toEqual(["Armature", "Decimate"]); expect(mesh?.modifiers.map((modifier) => modifier.status)).toEqual(["EVALUATED", "BLOCKED"]); expect(mesh?.modifiers[1]?.showViewport).toBe(true); expect(mesh?.modifiers[1]?.errorCode).toBe("BLENDER_MODIFIER_ERROR"); expect(mesh?.modifiers[0]?.targetObjectIds).toEqual(["object:RiggedArmatureObject"]); expect(mesh?.modifiers[1]?.dependsOn).toEqual([mesh?.modifiers[0]?.uuid]); const errors = (mesh?.positions ?? []).map((value, index) => value - golden.positions[index]); const maxError = Math.max(...errors.map((value) => Math.abs(value))); const rmsError = Math.sqrt(errors.reduce((sum, value) => sum + value * value, 0) / errors.length); expect(maxError).toBeLessThanOrEqual(golden.tolerance.maxPositionError); expect(rmsError).toBeLessThanOrEqual(golden.tolerance.rmsPositionError); }); test("exports layered Action keyframes through SceneIR", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(animationBlend)); const result = await page.evaluate((input) => new Promise<{ frame: { start: number; end: number }; target: string; channels: Array<{ path: string; keyframes: number }> }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const requestId = "e2e-animation-open"; worker.onmessage = (event: MessageEvent<{ kind: string; requestId: string; ok?: boolean; result?: { snapshot?: { frame: { start: number; end: number }; animations: Array<{ targetId: string; channels: Array<{ path: string; keyframes: unknown[] }> }>; }; }; error?: { message: string }; }>) => { if (event.data.requestId !== requestId || event.data.kind !== "result") return; worker.terminate(); const snapshot = event.data.result?.snapshot; const animation = snapshot?.animations.find((item) => item.targetId === "object:AnimatedObject"); if (!event.data.ok || !snapshot || !animation) { reject(new Error(event.data.error?.message ?? "animation response missing")); return; } resolve({ frame: snapshot.frame, target: animation.targetId, channels: animation.channels.map((channel) => ({ path: channel.path, keyframes: channel.keyframes.length })) }); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId, command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result.frame).toEqual({ current: 1, start: 1, end: 10 }); expect(result.target).toBe("object:AnimatedObject"); expect(result.channels).toEqual([ { path: "location[0]", keyframes: 2 }, { path: "location[1]", keyframes: 2 }, { path: "location[2]", keyframes: 2 }, ]); }); test("patches changed mesh buffer ranges without replacing stable topology", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { applyMeshGeometryDelta, diffMeshGeometryBuffers } = await import("/src/three-adapter/geometry-delta.ts"); const before = [{ schemaVersion: 1 as const, meshId: "mesh:range", byteLength: 36, positions: new Float32Array([0, 0, 0, 1, 1, 1]).buffer, indices: new Uint32Array([0, 1, 0]).buffer }]; const after = [{ ...before[0], positions: new Float32Array([0, 0, 0, 1, 1.25, 1]).buffer }]; const delta = diffMeshGeometryBuffers(before, after); const applied = applyMeshGeometryDelta(before, delta); return { patches: delta.patches.length, replaced: delta.replaced.length, patchBytes: delta.patches.reduce((total, patch) => total + patch.data.byteLength, 0), fullBytes: after[0].positions.byteLength, value: new Float32Array(applied[0].positions)[4], }; }); expect(result.replaced).toBe(0); expect(result.patches).toBeGreaterThan(0); expect(result.patchBytes).toBeLessThan(result.fullBytes); expect(result.value).toBe(1.25); }); test("renders through the capability-gated OffscreenCanvas worker", async ({ page }) => { await page.goto("/?offscreen=1"); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker"); await expect(canvas).toHaveAttribute("data-pbr-profile", "physical-v1"); await expect(canvas).toHaveAttribute("data-tone-mapping", "aces"); await expect(canvas).toHaveAttribute("data-shadow-map", "pcf-1024"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); expect(await canvas.getAttribute("data-renderer-error")).toBeNull(); }); test("coalesces linked mesh objects into a raycastable instance group", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", basicBlend); await expect(page.getByText("BasicCube", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "链接复制对象" }).click(); await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-instance-groups", "1"); }); test("returns structured gates for the undeclared capability protocols", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { type Response = { requestId: string; kind: string; ok?: boolean; error?: { code?: string; message?: string } }; const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const pending = new Map void>(); worker.onmessage = (event: MessageEvent) => pending.get(event.data.requestId)?.(event.data); const send = (payload: unknown, requestId: string): Promise => new Promise((resolve) => { pending.set(requestId, resolve); worker.postMessage({ requestId, command: { type: "applyCommand", payload } }); }); const sample = { position: [0, 0, 0], normal: [0, 0, 1], radius: 0.25, strength: 0.5, pressure: 1, time: 0 }; const sculpt = await send({ type: "sculptStroke", stroke: { schemaVersion: 1, meshId: "mesh:missing", brush: "DRAW", samples: Array.from({ length: 2049 }, () => sample), symmetry: [false, false, false], mirrorObjectSpace: true } }, "gate-sculpt"); const sculptMirror = await send({ type: "sculptStroke", stroke: { schemaVersion: 1, meshId: "mesh:missing", brush: "DRAW", samples: [sample], symmetry: [true, false, false], mirrorObjectSpace: false } }, "gate-sculpt-mirror"); const cycle = await send({ type: "setGeometryNodeGraph", meshId: "mesh:missing", graph: { schemaVersion: 1, id: "graph:cycle", name: "Cycle", interfaceInputs: [], interfaceOutputs: [], nodes: [ { id: "a", type: "GeometryNodeTransform", name: "A", sockets: [{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" }, { id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" }] }, { id: "b", type: "GeometryNodeTransform", name: "B", sockets: [{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" }, { id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" }] }, ], links: [{ fromNodeId: "a", fromSocketId: "out", toNodeId: "b", toSocketId: "in" }, { fromNodeId: "b", fromSocketId: "out", toNodeId: "a", toSocketId: "in" }], } }, "gate-gn"); const external = await send({ type: "setGeometryNodeGraph", meshId: "mesh:missing", graph: { schemaVersion: 1, id: "graph:external", name: "External", interfaceInputs: [], interfaceOutputs: [], links: [], nodes: [{ id: "object-info", type: "GeometryNodeObjectInfo", name: "Object Info", properties: { resourceId: "object:missing" }, sockets: [] }], } }, "gate-gn-external"); const shader = await send({ type: "setShaderGraph", materialId: "material:missing", graph: { schemaVersion: 1, id: "shader:valid", materialId: "material:missing", outputNodeId: "out", nodes: [{ id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] }], links: [], } }, "gate-shader"); const arbitraryShader = await send({ type: "setShaderGraph", materialId: "material:missing", graph: { schemaVersion: 1, id: "shader:math", materialId: "material:missing", outputNodeId: "out", nodes: [ { id: "mix", type: "MIX", name: "Mix", sockets: [] }, { id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] }, ], links: [], } }, "gate-shader-arbitrary"); const unsupportedMath = await send({ type: "setShaderGraph", materialId: "material:missing", graph: { schemaVersion: 1, id: "shader:power", materialId: "material:missing", outputNodeId: "out", nodes: [ { id: "math", type: "MATH", name: "Power", properties: { operation: "POWER" }, sockets: [] }, { id: "out", type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [] }, ], links: [], } }, "gate-shader-math-operation"); const nla = await send({ type: "setNLAStack", objectId: "object:missing", tracks: [{ schemaVersion: 1, id: "track:1", ownerId: "object:missing", name: "Track", muted: false, solo: false, selected: true, strips: [{ id: "strip:1", actionId: "action:missing", frameStart: 1, frameEnd: 10, actionFrameStart: 1, actionFrameEnd: 10, scale: 1, repeat: 1, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "HOLD", muted: false, selected: true }], }] }, "gate-nla"); worker.terminate(); return { sculpt: sculpt.error, sculptMirror: sculptMirror.error, cycle: cycle.error, external: external.error, shader: shader.error, arbitraryShader: arbitraryShader.error, unsupportedMath: unsupportedMath.error, nla: nla.error }; }); expect(result.sculpt?.code).toBe("SCULPT_STROKE_BUDGET_EXCEEDED"); expect(result.sculptMirror?.code).toBe("CAPABILITY_MISSING"); expect(result.cycle?.code).toBe("GN_DEPENDENCY_CYCLE"); expect(result.external?.code).toBe("GN_EXTERNAL_RESOURCE_MISSING"); expect(result.shader?.code).toBe("SHADER_EXTERNAL_RESOURCE_MISSING"); expect(result.shader?.message).toContain("N-013"); expect(result.arbitraryShader?.code).toBe("SHADER_NODE_UNSUPPORTED"); expect(result.unsupportedMath?.code).toBe("SHADER_NODE_UNSUPPORTED"); expect(result.nla?.code).toBe("NLA_PATH_INCOMPATIBLE"); }); test("exposes PBR-007 to PBR-012 renderer security gates", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { type Response = { requestId: string; kind: string; ok?: boolean; result?: { capabilityGate?: { taskId: string; status: string; issues: Array<{ code: string }> } }; error?: { message?: string } }; const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); const pending = new Map void>(); worker.onmessage = (event: MessageEvent) => pending.get(event.data.requestId)?.(event.data); const send = (requestId: string, request: unknown): Promise => new Promise((resolveRequest) => { pending.set(requestId, resolveRequest); worker.postMessage({ requestId, command: { type: "queryRenderCapability", request } }); }); void Promise.all([ send("gpu", { kind: "WEBGPU_BACKEND" }), send("bloom", { kind: "POSTPROCESS", passes: ["BLOOM"] }), send("volume", { kind: "VOLUME" }), send("sss", { kind: "SUBSURFACE" }), send("shader", { kind: "ARBITRARY_SHADER", nodeTypes: ["MIX"] }), ]).then((responses) => { worker.terminate(); const output: Record = {}; for (const [index, name] of ["gpu", "bloom", "volume", "sss", "shader"].entries()) { const gate = responses[index].result?.capabilityGate; output[name] = { taskId: gate?.taskId ?? "", status: gate?.status ?? "", code: gate?.issues[0]?.code }; } resolve(output); }).catch(reject); })); expect(result.gpu).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "WEBGPU_RENDERER_UNAVAILABLE" }); expect(result.bloom).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "POSTPROCESS_PASS_UNAVAILABLE" }); expect(result.volume).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE" }); expect(result.sss).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "SUBSURFACE_SHADER_UNAVAILABLE" }); expect(result.shader).toEqual({ taskId: "PBR-012", status: "BLOCKED", code: "SHADER_NODE_UNSUPPORTED" }); }); test("transfers packed raster assets into the PBR viewport with an explicit status", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend")); await expect(page.locator("[data-testid=scene-stats]")).toContainText("Objects", { timeout: 20_000 }); const canvas = page.locator("canvas.viewport-canvas"); await expect.poll(async () => Number(await canvas.getAttribute("data-texture-loaded") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); await expect(canvas).toHaveAttribute("data-texture-status", "ready"); }); test("uses the same packed texture payload in the OffscreenCanvas renderer", async ({ page }) => { await page.goto("/?offscreen=1"); await page.setInputFiles("[data-testid=blend-file-input]", path.resolve(import.meta.dirname, "../../../tests/files/web/packed_image_scene.blend")); const canvas = page.locator("canvas.viewport-canvas"); await expect.poll(async () => Number(await canvas.getAttribute("data-texture-loaded") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); await expect(canvas).toHaveAttribute("data-texture-status", "ready"); await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0); });