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 compositorBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/compositor_scene.blend"); const sequencerBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/sequencer_scene.blend"); const maskBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/mask_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("Web Blender Modeler V1", { 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(7); expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "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("deduplicates identical project revisions across independent StorageWorkers", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async () => { const { StorageClient } = await import("/src/storage/StorageClient.ts"); const first = new StorageClient(); const second = new StorageClient(); const projectId = `save-cross-worker-e2e-${Date.now()}`; const bytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7]).buffer; const saves = await Promise.all([ first.saveProject(projectId, 7, bytes.slice(0)), second.saveProject(projectId, 7, bytes.slice(0)), ]); const restored = await second.readProject(projectId); first.terminate(); second.terminate(); return { revisions: saves.map((save) => save.revision), bytes: Array.from(new Uint8Array(restored.buffer)), recovered: restored.recovered }; }); expect(result).toEqual({ revisions: [7, 7], bytes: [0x42, 0x4c, 0x45, 0x4e, 0x44, 7], 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", "1"); await expect(canvas).toHaveAttribute("data-volume-status", "blocked"); await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING"); 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 = (drawingId: string, x: number, index: number) => ({ id: `grease-pencil-point:onion:${drawingId}:${index}`, 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: [{ id: `grease-pencil-stroke:onion:${id}`, cyclic: false, pointCount: 2, materialIndex: 0, points: [point(id, x, 0), point(id, x + 1, 1)] }] }); const object = createGreasePencilObject({ id: "grease-pencil:onion", name: "Onion", geometryStatus: "available", layerCount: 1, frameCount: 3, strokeCount: 3, pointCount: 6, activeLayerId: "grease-pencil-layer:onion:1", layers: [{ id: "grease-pencil-layer:onion: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("validates the N-016 Grease Pencil editor context transaction boundary", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/grease-pencil-editor-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, 5, [0, 2], [{ strokeIndex: 2, pointIndex: 1 }]]); expect(result.stale).toContain("REVISION_CONFLICT"); expect(result.duplicate).toContain("GREASE_PENCIL_EDITOR_INVALID"); expect(result.negative).toContain("GREASE_PENCIL_EDITOR_INVALID"); expect(result.translation).toEqual([1, [0, 0, 0], { position: [1.5, 1, 5], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] }]); expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID"); }); test("raycasts and highlights N-016 Grease Pencil points with stable drawing identity", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/grease-pencil-viewport-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.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, drawingId: "grease-pencil-drawing:Viewport", strokeId: "grease-pencil-stroke:Viewport:0:0", pointId: "grease-pencil-point:Viewport:0:0:1", strokeIndex: 0, pointIndex: 1 }); expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]); expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]); expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]); expect(result.proxyCount).toBe(1); }); for (const offscreen of [false, true]) test(`previews N-016 Grease Pencil points and commits Main once 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 }); await page.getByText("GreasePencilObject", { exact: true }).click(); await page.getByRole("button", { name: "Object Mode" }).click(); await page.getByRole("button", { name: "Select All" }).click(); await expect(page.getByText(/\d+ vert selected/)).toBeVisible(); const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); const axis = page.getByRole("button", { name: "X 轴变换手柄" }); const bounds = await axis.boundingBox(); if (!bounds) throw new Error("Grease Pencil gizmo X axis is unavailable"); const x = bounds.x + bounds.width / 2; const y = bounds.y + bounds.height / 2; await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + 24, y, { steps: 3 }); const canvas = page.locator("canvas.viewport-canvas"); await expect(canvas).toHaveAttribute("data-grease-pencil-preview", /[1-9]\d*/); await page.mouse.up(); await expect(canvas).toHaveAttribute("data-grease-pencil-preview", "0"); await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); }); test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 }); await page.getByText("GreasePencilObject", { exact: true }).click(); const editor = page.getByTestId("grease-pencil-editor"); await expect(editor).toContainText("1 layers / 1 frames / 1 strokes"); await editor.getByLabel("Grease Pencil new layer name").fill("Browser Draft"); await editor.getByRole("button", { name: "Add Layer" }).click(); await expect(editor).toContainText("2 layers / 1 frames / 1 strokes"); await editor.getByLabel("Grease Pencil layer").selectOption({ label: "Browser Draft" }); await editor.getByRole("button", { name: "Add Frame" }).click(); await expect(editor).toContainText("2 layers / 2 frames / 1 strokes"); }); test("navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); await page.getByText("GreasePencilObject", { exact: true }).click(); const dopeSheet = page.getByLabel("Dope Sheet"); await expect(dopeSheet).toContainText("GreasePencilData"); await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true })).toBeVisible(); await page.getByLabel("当前帧").fill("12"); await expect(page.locator("output.frame-number")).toHaveText("12"); await page.getByTestId("grease-pencil-editor").getByRole("button", { name: "Add Frame" }).click(); await expect(dopeSheet.getByRole("button", { name: "Grease Pencil 帧 12" })).toBeVisible({ timeout: 20_000 }); await dopeSheet.getByRole("button", { name: "Grease Pencil 帧 1", exact: true }).click(); await expect(page.locator("output.frame-number")).toHaveText("1"); }); test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend); await page.getByText("GreasePencilObject", { exact: true }).click(); const editor = page.getByTestId("grease-pencil-editor"); await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1.5, 0, 0"); await editor.getByLabel("Grease Pencil point X delta").fill("0.5"); await editor.getByRole("button", { name: "Move Point" }).click(); await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 }); }); test("reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(greasePencilBlend)); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const first = new WebEngineClient({ timeoutMs: 20_000 }); const opened = await first.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const data = opened.snapshot.greasePencils?.[0]; const layer = data?.layers[0]; const drawingFrame = layer?.frames[0]; if (!data || !layer || !drawingFrame) throw new Error("Grease Pencil fixture is incomplete"); const strokes = drawingFrame.drawing.strokes.map((stroke, strokeIndex) => ({ cyclic: stroke.cyclic, materialIndex: stroke.materialIndex, points: (stroke.points ?? []).map((point, pointIndex) => ({ ...point, position: strokeIndex === 0 && pointIndex === 0 ? [point.position[0] + 0.25, point.position[1], point.position[2]] as [number, number, number] : [...point.position] as [number, number, number], })), })); const edited = await first.applyCommand({ type: "setGreasePencilStrokes", dataId: data.id, layerId: layer.id, frame: drawingFrame.frame, baseRevision: opened.snapshot.revision, strokes }); const saved = await first.saveBlend(); first.terminate(); const restarted = new WebEngineClient({ timeoutMs: 20_000 }); const reopened = await restarted.openBlend(saved); restarted.terminate(); const reopenedData = reopened.snapshot.greasePencils?.find((candidate) => candidate.id === data.id); const point = reopenedData?.layers.find((candidate) => candidate.id === layer.id)?.frames.find((candidate) => candidate.frame === drawingFrame.frame)?.drawing.strokes[0]?.points?.[0]; return { revision: edited.snapshot.revision, identity: [reopenedData?.id, reopenedData?.layers[0]?.id, reopenedData?.layers[0]?.frames[0]?.drawing.id], position: point?.position, radius: point?.radius, opacity: point?.opacity, }; }, new Uint8Array(bytes)); expect(result.revision).toBeGreaterThan(0); expect(result.identity).toEqual(["grease-pencil:GreasePencilData", "grease-pencil-layer:GreasePencilData:Lines", "grease-pencil-drawing:GreasePencilData:0"]); expect(result.position).toEqual([-1.25, 0, 0]); expect(result.radius).toBeGreaterThan(0); expect(result.opacity).toBeCloseTo(0.9); }); test("enforces the N-017 paint stroke, bounded brush and UDIM patch 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"); expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]); expect(result.spatialBrush).toEqual([16, 64, [450, 549, 550, 551, 650]]); expect(result.selectedMasked).toEqual([{ index: 450, weight: expect.closeTo(0.005823, 5) }, { index: 550, weight: 0.5 }]); expect(result.spatialVisibility).toContain("PAINT_SCHEMA_INVALID"); expect(result.spatialSelection).toContain("PAINT_SCHEMA_INVALID"); expect(result.selectionDuplicate).toContain("PAINT_SCHEMA_INVALID"); expect(result.maskInvalid).toContain("PAINT_SCHEMA_INVALID"); expect(result.selectionUnknown).toContain("PAINT_SCHEMA_INVALID"); expect(result.spatialForgery).toContain("PAINT_SCHEMA_INVALID"); expect(result.spatialMutation).toBe(16); expect(result.weightPatch).toMatchObject({ indices: [0, 2], values: [1, 0.7], normalize: false }); expect(result.colorPatch).toEqual({ indices: [0, 2], colors: [0, 1, 0, 0.5, 0.75, 0.25, 0, 0.875] }); expect(result.patchRevision).toContain("REVISION_CONFLICT"); expect(result.patchIdentity).toContain("PAINT_SCHEMA_INVALID"); expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]); expect(result.udimRevision).toContain("REVISION_CONFLICT"); expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH"); }); 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("commits N-017 vertex color and weight patches from the bounded paint panel", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible({ timeout: 20_000 }); await page.getByText("AttributeMeshObject", { exact: true }).click(); await page.getByRole("button", { name: /Object Mode/ }).click(); await page.getByRole("button", { name: "1 Vertex" }).click(); await page.getByRole("button", { name: "Select All" }).click(); const editor = page.getByTestId("paint-editor"); await expect(editor).toContainText("5 selected vertices"); await editor.getByLabel("Paint vertex color").fill("#336699"); await editor.getByRole("button", { name: "Apply Color" }).click(); await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 }); await editor.getByLabel("Paint vertex group").fill("BrowserPaint"); await editor.getByRole("button", { name: "Apply Weight" }).click(); await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 }); }); test("blends N-017 selection-masked color and weight patches through one Main transaction each", async ({ page }) => { await page.goto("/"); await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend); await page.getByText("AttributeMeshObject", { exact: true }).click(); await page.getByRole("button", { name: /Object Mode/ }).click(); await page.getByRole("button", { name: "1 Vertex" }).click(); await page.getByRole("button", { name: "Select All" }).click(); const editor = page.getByTestId("paint-editor"); await editor.getByLabel("Paint selection mask").fill("0.5"); await editor.getByLabel("Paint vertex color").fill("#0080ff"); let revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); await editor.getByRole("button", { name: "Blend Color" }).click(); await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); await expect(editor.getByTestId("paint-color-attribute")).toHaveText("WebPaintColor POINT"); revision += 1; await editor.getByLabel("Paint vertex group").fill("SelectionMaskPaint"); await editor.getByLabel("Paint vertex weight").fill("0.8"); await editor.getByRole("button", { name: "Blend Weight" }).click(); await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); await expect(editor.getByTestId("paint-vertex-group")).toHaveText("SelectionMaskPaint"); }); 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"); expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]); expect(result.browserPreview).toEqual([7, [1, 2, 3], [1, 2, 3]]); expect(result.browserFrameMismatch).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH"); expect(result.browserSession).toEqual(["COMPLETED", 2, 8, [7, 8]]); expect(result.browserSupersede).toEqual([true, 8, [8]]); expect(result.browserCancel).toEqual([true, []]); }); 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 { blenderLightColor, 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()); const warmDefinition = { ...definition, color: [1, 1, 1] as [number, number, number], useTemperature: true, temperature: 5000 }; const neutralDefinition = { ...warmDefinition, temperature: 6500 }; const disabledDefinition = { ...warmDefinition, color: [0.25, 0.5, 0.75] as [number, number, number], useTemperature: false }; return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow, warm: blenderLightColor(warmDefinition), neutral: blenderLightColor(neutralDefinition), disabled: blenderLightColor(disabledDefinition), appliedWarm: createPBRLight(warmDefinition).color.toArray(), }; }); expect(mapping.intensity).toBe(40); expect(mapping.castShadow).toBe(false); expect(mapping.sourceShadow).toBe(false); expect(mapping.neutral).toEqual([1, 1, 1]); expect(mapping.disabled).toEqual([0.25, 0.5, 0.75]); expect(mapping.warm[0]).toBe(1); expect(mapping.warm[1]).toBeGreaterThan(0.7); expect(mapping.warm[1]).toBeLessThan(0.9); expect(mapping.warm[2]).toBeGreaterThan(0.5); expect(mapping.warm[2]).toBeLessThan(0.75); expect(mapping.appliedWarm).toEqual(mapping.warm); }); test("preserves N-019 World and Scene color management in renderer-bound SceneDelta", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/scene-delta-render-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.collections).toEqual([1, 1]); expect(result.applied).toEqual([[0.8, 0.4, 0.2], 2, "Standard", 1]); expect(result.rebuild).toBe(true); expect(result.invalid).toContain("SceneDelta.worlds is invalid"); }); 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"); expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]); }); test("blocks the N-020 Main graph when it also preserves an unsupported Blender node", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend)); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const { executeCompositorGraph, gateCompositorGraph } = await import("/src/compositor/CompositorExecutor.ts"); const client = new WebEngineClient({ timeoutMs: 20_000 }); try { const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene"); if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing"); const before = JSON.stringify(scene.compositorGraph); let executionCode = ""; try { executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 }); } catch (error) { executionCode = (error as { code?: string }).code ?? String(error); } return { status: scene.compositorStatus, executionCode, preserved: JSON.stringify(scene.compositorGraph) === before, unsupported: scene.compositorGraph.nodes.find((node) => node.type === "UNSUPPORTED")?.blenderType, gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code, }; } finally { client.terminate(); } }, new Uint8Array(bytes)); expect(result.status).toBe("AVAILABLE"); expect(result.executionCode).toBe("COMPOSITOR_NODE_UNSUPPORTED"); expect(result.preserved).toBe(true); expect(result.unsupported).toBe("CompositorNodeGlare"); expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED"); }); 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.frame).toEqual([["strip:MovieRight", 3, 105]]); 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.transition).toEqual(["CROSS", 0.5, "strip:From", 105, "strip:To", 205]); expect(result.transitionBoundary).toContain("SEQUENCER_SCHEMA_INVALID"); expect((result.runtime as { localEncoding: string }).localEncoding).toBe("BLOCKED"); }); test("resolves the N-021 transition frame from a real Blender 5.2 sequencer", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(sequencerBlend)); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const { resolveSequencerTransitionFrame } = await import("/src/sequencer/SequencerTimeline.ts"); const client = new WebEngineClient({ timeoutMs: 20_000 }); try { const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "SequencerScene"); const timeline = scene?.sequencerTimeline; const effect = timeline?.strips.find((strip) => strip.name === "WebCross"); if (!timeline || !effect) throw new Error("Real sequencer transition is missing"); const transition = resolveSequencerTransitionFrame(timeline, effect.id, 22); const names = new Map(timeline.strips.map((strip) => [strip.id, strip.name])); return { status: scene.sequencerStatus, type: transition.effectType, factor: transition.factor, from: [names.get(transition.from.stripId), transition.from.sourceFrame], to: [names.get(transition.to.stripId), transition.to.sourceFrame] }; } finally { client.terminate(); } }, new Uint8Array(bytes)); expect(result).toEqual({ status: "AVAILABLE", type: "CROSS", factor: 0.5, from: ["WebImage", 1], to: ["WebImageB", 1] }); }); 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.raycast).toMatchObject({ maskId: "mask:1", layerId: "layer:1", splineId: "spline:1", kind: "POINT", pointId: "point:1", distance: 0 }); 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("raycasts and marquee-selects editable N-022 points from a real Blender 5.2 Mask", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(maskBlend)); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const { raycastMaskProject, selectMaskPointsInBounds } = await import("/src/tracking/MaskSelection.ts"); const client = new WebEngineClient({ timeoutMs: 20_000 }); try { const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)); const project = opened.snapshot.trackingMasks; if (!project) throw new Error("Real Mask project is missing"); const locked = raycastMaskProject(project, [0.1, 0.2], 0.001); const editable = raycastMaskProject(project, [0.2, 0.2], 0.001); const selected = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25]); const toggled = selectMaskPointsInBounds(project, [0.05, 0.15], [0.25, 0.25], selected, "TOGGLE"); const layerNames = new Map(project.masks[0].layers.map((layer) => [layer.id, layer.name])); return { status: opened.snapshot.trackingMaskStatus, locked, editable: editable && { ...editable, layerName: layerNames.get(editable.layerId) }, selected: selected.map((item) => [layerNames.get(item.layerId), item.pointId]), toggled }; } finally { client.terminate(); } }, new Uint8Array(bytes)); expect(result.status).toBe("AVAILABLE"); expect(result.locked).toBeNull(); expect(result.editable).toMatchObject({ kind: "POINT", layerName: "WebEditableLayer", pointId: "mask-point:1:0:0" }); expect(result.editable?.distance).toBeLessThan(1e-7); expect(result.selected).toEqual([["WebEditableLayer", "mask-point:1:0:0"]]); expect(result.toggled).toEqual([]); }); 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.archivePath).toContain("IO_ARCHIVE_UNSAFE"); expect(result.archiveLength).toContain("IO_ARCHIVE_UNSAFE"); expect(result.archivePlan).toEqual([ { path: "a.bin", compressedBytes: 2, uncompressedBytes: 2, compressedOffset: 0 }, { path: "z.bin", compressedBytes: 3, uncompressedBytes: 4, compressedOffset: 2 }, ]); 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"); expect(result.preview).toMatch(/^[a-f0-9]{64}$/); expect(result.previewSize).toContain("ASSET_MANIFEST_INVALID"); }); 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"); expect(result.keymap).toBe("object.delete"); expect(result.keymapConflict).toContain("EDITOR_KEYMAP_INVALID"); expect(result.scopedKeymap).toEqual(["view.command", "timeline.command"]); }); 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.audit).toEqual(["DENY", "SCRIPT_SANDBOX_UNAVAILABLE", true, ["READ_MAIN"], 1000, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]); expect(result.auditLog).toEqual([2, null, expect.stringMatching(/^[a-f0-9]{64}$/), expect.stringMatching(/^[a-f0-9]{64}$/)]); expect(result.auditReplay).toContain("SCRIPT_MANIFEST_INVALID"); expect(result.auditTamper).toContain("SCRIPT_MANIFEST_INVALID"); expect(result.auditDate).toContain("SCRIPT_MANIFEST_INVALID"); 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"); expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING"); expect(result.excludedOverlap).toContain("RELEASE_MANIFEST_INVALID"); expect(result.disabledEvidence).toContain("RELEASE_MANIFEST_INVALID"); expect(result.emptyArtifact).toContain("RELEASE_MANIFEST_INVALID"); expect(result.generatedAt).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", "READY"]); }); test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise>((resolve, reject) => { const worker = new Worker("/src/workers/nonmesh-interaction-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.preview).toEqual([3, [1.25, 2, 3]]); expect(result.commit).toEqual([4, [1.25, 2, 3]]); expect(result.rendererPreview).toEqual([[ -0.25, -0.25 ], -0.25, -0.5]); expect(result.localFrame).toEqual([[0, 1, 0], [[0, 1, 0], [-1, 0, 0], [0, -0, 1]], [0, 1.25, 0]]); expect(result.stale).toContain("REVISION_CONFLICT"); expect(result.duplicate).toContain("CURVE_GIZMO_INVALID"); expect(result.axis).toContain("CURVE_GIZMO_INVALID"); }); for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag and commits Main once in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(offscreen ? "/?offscreen=1" : "/"); await page.setInputFiles("[data-testid=blend-file-input]", nonMeshBlend); await expect(page.getByText("WebCurveObject", { exact: true })).toBeVisible({ timeout: 20_000 }); await page.getByText("WebCurveObject", { exact: true }).click(); await page.getByRole("button", { name: "Object Mode" }).click(); const bytes = await import("node:fs").then((fs) => fs.readFileSync(nonMeshBlend)); const handle = await page.evaluate((input) => new Promise<[number, number, number]>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); worker.onmessage = (event) => { if (event.data.kind !== "result" || event.data.requestId !== "curve-handle-open") return; worker.terminate(); if (!event.data.ok) { reject(new Error(event.data.error?.message ?? "Curve fixture open failed")); return; } const curve = event.data.result?.snapshot?.nonMeshData?.find((item: { id: string }) => item.id === "curve:WebCurveData"); if (!curve?.handlePoints?.length) { reject(new Error("Curve handle metadata is missing")); return; } resolve(curve.handlePoints.slice(0, 3)); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); worker.postMessage({ requestId: "curve-handle-open", command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); const hit = await page.locator("canvas.viewport-canvas").evaluate(async (canvas, input) => { const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js"); const bounds = canvas.getBoundingClientRect(); const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000); camera.position.set(4.219781, -4.219781, 3.658811); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(true); camera.updateProjectionMatrix(); const projected = new Vector3(input.position[0], input.position[2], -input.position[1]).project(camera); return { x: bounds.left + (projected.x + 1) * bounds.width / 2, y: bounds.top + (1 - projected.y) * bounds.height / 2 }; }, { position: handle, offscreen }); await page.mouse.click(hit.x, hit.y); if (!offscreen) await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-non-mesh-last-pick", /HANDLE_LEFT/); await expect(page.getByText("1 vert selected", { exact: true })).toBeVisible(); const gizmo = page.getByLabel("变换 Gizmo"); await expect(gizmo).toHaveAttribute("data-gizmo-space", "HANDLE_LOCAL"); const localAxis = await page.getByRole("button", { name: "X 轴变换手柄" }).getAttribute("data-local-axis"); expect(localAxis).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6},-?\d+\.\d{6}$/); const revision = Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1"); const axis = page.getByRole("button", { name: "X 轴变换手柄" }); const bounds = await axis.boundingBox(); if (!bounds) throw new Error("Curve gizmo X axis is unavailable"); const screenAxis = (await axis.getAttribute("data-screen-axis"))?.split(",").map(Number) ?? []; expect(screenAxis).toHaveLength(2); expect(Math.hypot(screenAxis[0], screenAxis[1])).toBeGreaterThan(0.5); await page.screenshot({ path: testInfo.outputPath(`curve-handle-local-${offscreen ? "offscreen" : "main"}-1440x900.png`), fullPage: true }); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width / 2 + screenAxis[0] * 24, bounds.y + bounds.height / 2 + screenAxis[1] * 24, { steps: 3 }); await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "1"); await page.mouse.up(); await expect(page.locator("canvas.viewport-canvas")).toHaveAttribute("data-curve-gizmo-preview", "0"); await expect.poll(async () => Number((await page.getByTestId("engine-status").textContent())?.match(/r(\d+)/)?.[1] ?? "-1")).toBe(revision + 1); }); test("validates the VDB conversion boundary and NanoVDB streaming contract", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(() => new Promise<{ preparedByteLength: number; conversionTarget: string; conversionRequestSha256: string; relocationKeepsContentKey: boolean; ranges: Array<{ chunkIndex: number; start: number; endExclusive: number }>; consumed: number[]; progress: number[]; stream: { completedChunks: number; completedBytes: number; totalBytes: number }; httpRangeByteLength: number; invalidHttpRange: string; outsideProject: string; tamperedChunk: string; incompleteStream: string; cancelled: boolean; rawBrowserGate: { status: string; issues: Array<{ code: string }> }; streamGate: { status: string }; renderGate: { status: string; issues: Array<{ code: string }> }; }>((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.preparedByteLength).toBe(64); expect(result.conversionTarget).toBe("SERVER"); expect(result.conversionRequestSha256).toMatch(/^[a-f0-9]{64}$/); expect(result.relocationKeepsContentKey).toBe(true); expect(result.ranges).toEqual([ { chunkIndex: 0, start: 0, endExclusive: 32, sha256: expect.any(String) }, { chunkIndex: 1, start: 32, endExclusive: 64, sha256: expect.any(String) }, ]); expect(result.consumed).toEqual([0, 1]); expect(result.progress).toEqual([32, 64]); expect(result.stream).toMatchObject({ completedChunks: 2, completedBytes: 64, totalBytes: 64 }); expect(result.httpRangeByteLength).toBe(32); expect(result.invalidHttpRange).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(result.outsideProject).toContain("NON_MESH_RESOURCE_OUTSIDE_PROJECT"); expect(result.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH"); expect(result.incompleteStream).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(result.cancelled).toBe(true); expect(result.rawBrowserGate.status).toBe("BLOCKED"); expect(result.rawBrowserGate.issues[0].code).toBe("VDB_CONVERSION_REQUIRED"); expect(result.streamGate.status).toBe("READY"); expect(result.renderGate.status).toBe("BLOCKED"); expect(result.renderGate.issues[0].code).toBe("VOLUME_SHADER_UNAVAILABLE"); }); test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ page }) => { await page.goto("/"); const run = (action: "commit" | "reopen", state?: unknown) => page.evaluate(({ action, state }) => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({ action, state }); }), { action, state }); const committed = await run("commit"); expect(committed.committed).toMatchObject({ chunks: committed.realChunkCount, deduplicated: false }); expect(committed.deduplicated).toBe(true); expect(committed.cancelled).toBe(true); expect(committed.recovered.removedIncompleteBundles).toBe(1); expect(committed.realBundleBytes).toBeGreaterThan(10_000_000); expect(committed.realChunkCount).toBeGreaterThan(1); // A new Worker proves discovery does not depend on temporary in-memory state. const reopened = await run("reopen", committed.state); expect(reopened.bindingStatus.status).toBe("READY"); expect(reopened.staleStatus).toMatchObject({ status: "BLOCKED", code: "VDB_SOURCE_CHANGED" }); expect(reopened.bundleHash).toBe(reopened.expectedHash); expect(reopened.tamperedChunk).toContain("NANOVDB_HASH_MISMATCH"); expect(reopened.manifestRollback).toContain("NANOVDB_HASH_MISMATCH"); expect(reopened.pruned).toMatchObject({ removed: [committed.state.bundleSha256], retainedBytes: 0 }); }); test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => { test.setTimeout(90_000); await page.goto("/"); const result = await page.evaluate(() => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(result.capability.available).toBe(true); expect(result.payloadBytes).toBeGreaterThan(1_000_000); expect(result.cpuSamples).toHaveLength(result.nativeSamples.length); expect(result.gpuSamples).toHaveLength(result.nativeSamples.length); result.nativeSamples.forEach((sample: any, index: number) => { expect(result.cpuSamples[index].active).toBe(sample.active); expect(result.cpuSamples[index].value).toBeCloseTo(sample.value, 6); expect(result.gpuSamples[index].valid).toBe(true); expect(result.gpuSamples[index].active).toBe(sample.active); expect(result.gpuSamples[index].value).toBeCloseTo(sample.value, 6); }); expect(result.visiblePixels).toBeGreaterThan(500); expect(result.alphaSum).toBeGreaterThan(10_000); expect(result.imageSha256).toBe("718e824bda68197e5bf63e2261396f47afe35efbc3ec84d3b38e4afe5b07e33f"); expect(result.viewAxis).toBe("X"); expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]); expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] }); expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual(["VOLUME_VELOCITY_RENDER_UNSUPPORTED"]); expect(result.emissionMapping.material.emissionGrid).toBe("temperature"); expect(result.emissionMapping.losses).toEqual([]); expect(result.emissionVisiblePixels).toBeGreaterThan(100); }); test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => { test.setTimeout(180_000); await page.goto("/"); const result = await page.evaluate(async () => { const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([ import("/src/volume/nanovdb-viewport.ts"), import("/src/three-adapter/viewport.ts"), import("/src/three-adapter/offscreen-viewport.ts"), ]); const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal); asset.material = { ...asset.manifest.material, interpolation: "NEAREST", emissionGrid: asset.manifest.material.temperatureGrid, }; const snapshot: any = { schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" }, coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, nodes: [{ id: "object:Volume", name: "Viewport Volume", type: "VOLUME", dataId: asset.dataId, parentId: null, visible: true, localMatrix: [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] }, }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [{ id: "scene:Volume", name: "Volume" }], nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }], activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 }, }; const waitFor = async (condition: () => boolean, timeoutMs = 80_000): Promise => { const deadline = performance.now() + timeoutMs; while (!condition()) { if (performance.now() > deadline) throw new Error("viewport volume timed out"); await new Promise((resolve) => setTimeout(resolve, 25)); } }; const createCanvas = (): HTMLCanvasElement => { const canvas = document.createElement("canvas"); canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000"; document.body.append(canvas); return canvas; }; const mainCanvas = createCanvas(); const main = new ViewportRenderer(mainCanvas); main.setSnapshot(snapshot); main.setVolumeAssets([asset]); await waitFor(() => ["ready", "blocked"].includes(mainCanvas.dataset.volumeStatus ?? "")); if (mainCanvas.dataset.volumeStatus !== "ready") throw new Error(`main viewport volume ${mainCanvas.dataset.volumeErrorCode ?? "blocked"}`); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); let mainVolumeObjects = 0; main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; }); const mainPixels = new Uint8Array(64 * 64 * 4); const gl = main.renderer.getContext(); gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels); const mainVisible = Array.from({ length: 64 * 64 }, (_, index) => mainPixels[index * 4 + 3] > 0 && (mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2]) > 40).filter(Boolean).length; main.dispose(); mainCanvas.remove(); const offscreenCanvas = createCanvas(); const offscreen = new OffscreenViewportRenderer(offscreenCanvas); offscreen.setSnapshot(snapshot); offscreen.setVolumeAssets([asset]); await waitFor(() => ["ready", "blocked"].includes(offscreenCanvas.dataset.volumeStatus ?? "")); if (offscreenCanvas.dataset.volumeStatus !== "ready") throw new Error(`offscreen viewport volume ${offscreenCanvas.dataset.volumeErrorCode ?? "blocked"}`); await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0); const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) }; offscreen.dispose(); offscreenCanvas.remove(); return { payloadBytes: asset.grids[0].data.byteLength, main: { status: mainCanvas.dataset.volumeStatus, count: Number(mainCanvas.dataset.volumeCount), volumeObjects: mainVolumeObjects, visible: mainVisible }, offscreen: offscreenResult, }; }); expect(result.payloadBytes).toBeGreaterThan(1_000_000); expect(result.main).toMatchObject({ status: "ready", count: 1, volumeObjects: 1 }); expect(result.main.visible).toBeGreaterThan(100); expect(result.offscreen).toMatchObject({ status: "ready", count: 1 }); expect(result.offscreen.visible).toBeGreaterThan(10); }); test("recovers NanoVDB paging from network, Worker and WebGPU device faults", async ({ page }) => { await page.goto("/"); const gpu = await page.evaluate(() => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/vdb-fault-test.worker.ts", { type: "module" }); worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; worker.postMessage({}); })); expect(gpu.network.attempts).toBe(3); expect(gpu.network.ifRanges[0]).toBe(""); expect(gpu.network.ifRanges[2]).toMatch(/^"vdb-/); expect(gpu.network.resumeRanges).toHaveLength(2); expect(gpu.network.resumeRanges[0]).toMatch(/^bytes=\d+-\d+$/); const originalStart = Number(gpu.network.resumeRanges[0].match(/^bytes=(\d+)-/)?.[1]); const resumedStart = Number(gpu.network.resumeRanges[1].match(/^bytes=(\d+)-/)?.[1]); expect(resumedStart).toBe(originalStart + 4096); expect(gpu.network.resumeIfRanges[0]).toBe(""); expect(gpu.network.resumeIfRanges[1]).toMatch(/^"vdb-/); expect(gpu.network.resumedBytes).toBe(gpu.network.firstBytes); expect(gpu.network.shortResponse).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(gpu.network.changedEtag).toContain("NANOVDB_HASH_MISMATCH"); expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] }); expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED"); expect(gpu.demandPaging.pageCount).toBeGreaterThan(gpu.demandPaging.residentPageCapacity); expect(gpu.demandPaging.initialVirtualPages).toEqual([]); expect(gpu.demandPaging.residentBytes).toBe(512 * 1024); expect(gpu.demandPaging.maxResidentBytes).toBe(512 * 1024); expect(gpu.demandPaging.evictions).toBe(1); expect(gpu.demandPaging.requestedPageResident).toBe(true); expect(gpu.demandPaging.touchedPageResident).toBe(true); expect(gpu.demandPaging.evictedPageResident).toBe(false); expect(gpu.demandPaging.residentVirtualPages).toContain(gpu.demandPaging.pageCount - 1); expect(gpu.demandPaging.pageRanges.length).toBeGreaterThanOrEqual(3); expect(gpu.demandPaging.pageRanges.every((range: string) => /^bytes=\d+-\d+$/.test(range))).toBe(true); expect(gpu.demandPaging.demandWords).toEqual(gpu.demandPaging.expectedWords); expect(gpu.demandPaging.incompletePage).toContain("NANOVDB_STREAM_INCOMPLETE"); expect(gpu.globalResidency.totalResidentBytes).toBeLessThanOrEqual(gpu.globalResidency.maxResidentBytes); expect(Object.values(gpu.globalResidency.gridResidentBytes)).toEqual([256 * 1024, 256 * 1024, 256 * 1024]); expect(gpu.paging.pageCount).toBeGreaterThan(1); expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount); expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1); expect(gpu.deviceLoss.residentBeforeReplay).toEqual([]); expect(gpu.deviceLoss.visiblePageIds).toEqual([0, 1, 2]); expect(gpu.deviceLoss.replayedPageIds).toEqual([0, 1]); expect(gpu.deviceLoss.skippedPageIds).toEqual([2]); expect(gpu.deviceLoss.recoveredResidentPages).toEqual([0, 1]); expect(gpu.deviceLoss.recoveredResidentPages).not.toContain(gpu.deviceLoss.oldNonVisiblePage); expect(gpu.deviceLoss.recoveredPageTable.slice(0, 2)).toEqual([0, 1]); expect(gpu.deviceLoss.recoveredPageTable.slice(2).every((slot: number) => slot === 0xffffffff)).toBe(true); expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.deviceLoss.expectedDemandWords); expect(gpu.samplesStable).toBe(true); const interrupted = await page.evaluate(() => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); const timeout = setTimeout(() => { worker.terminate(); reject(new Error("OPFS interrupt gate timed out")); }, 20_000); worker.onmessage = (event) => { if (!event.data.staged) return; clearTimeout(timeout); worker.terminate(); setTimeout(() => { const recovery = new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); recovery.onmessage = (recoveryEvent) => { recovery.terminate(); recoveryEvent.data.error ? reject(new Error(recoveryEvent.data.error)) : resolve(recoveryEvent.data); }; recovery.onerror = (error) => { recovery.terminate(); reject(new Error(error.message)); }; recovery.postMessage({ action: "recoverInterrupted" }); }, 100); }; worker.onerror = (error) => { clearTimeout(timeout); worker.terminate(); reject(new Error(error.message)); }; worker.postMessage({ action: "interrupt" }); })); expect(interrupted.removedIncompleteBundles).toBeGreaterThanOrEqual(1); const runOPFS = (action: "prepareQuota" | "quota" | "verifyQuota", state?: unknown): Promise => page.evaluate(({ action, state }) => new Promise((resolve, reject) => { const holder = window as unknown as { vdbQuotaWorker?: Worker }; const worker = holder.vdbQuotaWorker ?? new Worker("/src/workers/vdb-opfs-test.worker.ts", { type: "module" }); holder.vdbQuotaWorker = worker; worker.onmessage = (event) => { event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); }; worker.onerror = (error) => { reject(new Error(error.message)); }; worker.postMessage({ action, state }); }), { action, state }); const baseline = await runOPFS("prepareQuota"); const cdp = await page.context().newCDPSession(page); const origin = new URL(page.url()).origin; const usage = await cdp.send("Storage.getUsageAndQuota", { origin }); await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 96 * 1024 }); const quota = await runOPFS("quota", baseline.state); expect(quota.quotaError).toMatch(/QuotaExceededError|quota/i); if (quota.recoveryWhileQuotaLimited) expect(quota.recoveryWhileQuotaLimited).toMatch(/QuotaExceededError|quota/i); await cdp.send("Storage.overrideQuotaForOrigin", { origin, quotaSize: usage.usage + 512 * 1024 * 1024 }); const verified = await runOPFS("verifyQuota", baseline.state); expect(verified.previousBundleReadable).toBe(true); expect((quota.recoveredWhileQuotaLimited?.removedIncompleteBundles ?? 0) + verified.recovered.removedIncompleteBundles).toBeGreaterThanOrEqual(1); await page.evaluate(() => { const holder = window as unknown as { vdbQuotaWorker?: Worker }; holder.vdbQuotaWorker?.terminate(); delete holder.vdbQuotaWorker; }); }); 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", "1"); await expect(canvas).toHaveAttribute("data-volume-status", "blocked"); await expect(canvas).toHaveAttribute("data-volume-error-code", "NON_MESH_RESOURCE_MISSING"); 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.getByTestId("dirty-status")).toHaveText("未保存"); await expect(page.getByTestId("dirty-status")).toHaveText("已保存", { timeout: 15_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", 7); 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 snapshotRows = await new Promise>((resolve, reject) => { const request = indexedDB.open("blender-web-metadata", 7); request.onsuccess = () => { const snapshotDb = request.result; const transaction = snapshotDb.transaction("snapshot", "readonly"); const rows = transaction.objectStore("snapshot").getAll(); rows.onsuccess = () => resolve((rows.result as Array<{ projectId: string; backend?: string; buffer?: ArrayBuffer }>).filter((row) => row.projectId === projectId)); rows.onerror = () => reject(rows.error); transaction.oncomplete = () => snapshotDb.close(); }; request.onerror = () => reject(request.error); }); const root = await navigator.storage.getDirectory(); const projects = await root.getDirectoryHandle("projects"); const project = await projects.getDirectoryHandle(projectId); const snapshotDirectory = await project.getDirectoryHandle("snapshots"); const snapshotFiles: string[] = []; for await (const [name, handle] of (snapshotDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) { if (handle.kind === "file") snapshotFiles.push(name); } 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)), snapshotBackends: snapshotRows.map((row) => row.backend).sort(), inlineSnapshotBuffers: snapshotRows.filter((row) => row.buffer).length, snapshotFiles: snapshotFiles.sort(), replayRevisions: replay.operations.map((item) => item.revision), quarantined: replay.quarantined, pruned: pruned.removed, }; }); expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], snapshotBackends: ["opfs", "opfs"], inlineSnapshotBuffers: 0, snapshotFiles: ["3.blend", "4.blend"], 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 { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.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 transformFrame = (frame: number, x: number): ArrayBuffer => { const bytes = new ArrayBuffer(88); const view = new DataView(bytes); view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, frame, true); view.setUint32(12, 1, true); const id = new TextEncoder().encode("object:CacheTarget"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id); [x, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true)); return bytes; }; const projectId = `simulation-e2e-${Date.now()}`; const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44]).buffer; const frameOne = transformFrame(1, 1); const frameTwo = transformFrame(2, 2); const payloadBytes = new Uint8Array(frameOne.byteLength + frameTwo.byteLength); payloadBytes.set(new Uint8Array(frameOne), 0); payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength); const payload = payloadBytes.buffer; const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer); const binding = { graphId: "geometry-node-tree:simulation-e2e", graphHash: fixedHash, sourceBlendSha256: await digest(sourceBlend), sourceRevision: 1, inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer), blenderVersion: "5.2.0", frameStart: 1, frameEnd: 2, }; const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([ "blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash, binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash, binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd), ])).buffer); const manifest = { schemaVersion: 2 as const, ...binding, revisionHash, cacheSha256: await digest(payload), byteLength: payload.byteLength, frames: [ { frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) }, { frame: 2, byteOffset: frameOne.byteLength, byteLength: frameTwo.byteLength, 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); const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] }; let publishedFrame = 0; let publishedTranslation: number[] = []; const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; }); await playback.seek(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) }, frameOne.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: read.data.byteLength, frame: frameRead.frame, frameOffset: frameRead.byteOffset, frameBytes: frameRead.data.byteLength, frameMagic: new DataView(frameRead.data).getUint32(0, true), publishedFrame, publishedTranslation, missingFrameCode, corruptCode, }; }); expect(result.cacheKey).toMatch(/^sim2-[a-f0-9]{64}$/); 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).toBe(176); expect(result.frame).toBe(2); expect(result.frameOffset).toBe(88); expect(result.frameBytes).toBe(88); expect(result.frameMagic).toBe(0x31465442); expect(result.publishedFrame).toBe(2); expect(result.publishedTranslation).toEqual([2, 0, 0]); 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("operator search executes context-filtered workspace, mode and Main commands", 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: "Modeling" }).click(); await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Modeling"); await page.keyboard.press("F3"); await page.getByRole("textbox", { name: "搜索操作" }).fill("switch to animation"); await page.keyboard.press("Enter"); await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", "Animation"); await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0); await page.keyboard.press("F3"); await page.getByRole("textbox", { name: "搜索操作" }).fill("enter edit"); await page.keyboard.press("Enter"); await expect(page.getByRole("button", { name: "Edit Mode" })).toBeVisible(); await page.keyboard.press("F3"); await page.getByRole("textbox", { name: "搜索操作" }).fill("cube"); await expect(page.getByRole("button", { name: "Add Cube" })).toHaveCount(0); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: "Operator Search" })).toHaveCount(0); await page.getByRole("button", { name: "Edit Mode" }).click(); await page.keyboard.press("F3"); await page.getByRole("textbox", { name: "搜索操作" }).fill("cube"); await expect(page.getByRole("button", { name: "Add Cube" })).toBeVisible(); await page.keyboard.press("Enter"); await expect(page.locator("[data-testid=engine-status]")).toContainText("SceneIR r2 (4 objects)"); }); 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("serializes concurrent WebEngine init and blend open before the first Main edit", async ({ page }) => { await page.goto("/"); const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend)); const result = await page.evaluate((input) => new Promise<{ initReady: boolean; frame: number; revision: number }>((resolve, reject) => { const worker = new Worker("/src/workers/web-engine.worker.ts", { type: "module" }); let initReady = false; worker.onmessage = (event) => { if (event.data.kind !== "result") return; if (!event.data.ok) { worker.terminate(); reject(new Error(event.data.error?.message ?? "WebEngine request failed")); return; } if (event.data.requestId === "concurrent-init") { initReady = event.data.result?.status?.ready === true; return; } if (event.data.requestId === "concurrent-open") { worker.postMessage({ requestId: "concurrent-edit", command: { type: "applyCommand", payload: { type: "setFrame", frame: 24 } } }); return; } if (event.data.requestId === "concurrent-edit") { const snapshot = event.data.result?.snapshot; worker.terminate(); if (!snapshot) { reject(new Error("WebEngine edit returned no SceneIR")); return; } resolve({ initReady, frame: snapshot.frame.current, revision: 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: "concurrent-init", command: { type: "init" } }); worker.postMessage({ requestId: "concurrent-open", command: { type: "openBlend", buffer } }, [buffer]); }), new Uint8Array(bytes)); expect(result).toEqual({ initReady: true, frame: 24, revision: 2 }); }); 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 Web Blender Modeler 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); });