Advance WebGPU volume and bounded workflows

This commit is contained in:
mes123456
2026-08-14 18:08:29 -04:00
parent 3da1dfc804
commit 68d50f810f
119 changed files with 9028 additions and 430 deletions

View File

@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("recovers the main-thread Chromium viewport after a real WebGL context loss", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-device-status", "ready");
const result = await canvas.evaluate(async (element) => {
const gl = element.getContext("webgl2") ?? element.getContext("webgl");
const extension = gl?.getExtension("WEBGL_lose_context");
if (!gl || !extension) return { supported: false, pixels: 0 };
const waitFor = (status: string): Promise<void> => new Promise((resolve, reject) => {
const started = performance.now();
const poll = (): void => {
if (element.dataset.deviceStatus === status) { resolve(); return; }
if (performance.now() - started > 10_000) { reject(new Error(`Timed out waiting for device status ${status}`)); return; }
requestAnimationFrame(poll);
};
poll();
});
extension.loseContext();
await waitFor("lost");
extension.restoreContext();
await waitFor("ready");
const pixels = new Uint8Array(16 * 16 * 4);
gl.readPixels(0, 0, 16, 16, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return { supported: true, pixels: pixels.reduce((total, value) => total + (value > 0 ? 1 : 0), 0) };
});
expect(result.supported).toBe(true);
expect(result.pixels).toBeGreaterThan(0);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
});

View File

@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("keeps Main edit and save-reopen available during a Chromium network interruption", async ({ context, page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 20_000 });
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
await context.setOffline(true);
try {
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r2 (4 objects)");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("blender-web.blend");
const savedPath = await download.path();
expect(savedPath).not.toBeNull();
await page.setInputFiles("[data-testid=blend-file-input]", savedPath!);
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r3 (4 objects)");
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
}
finally {
await context.setOffline(false);
}
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
test("meets the Chromium OPFS Simulation cache playback performance gate", async ({ page }) => {
test.setTimeout(45_000);
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 frameCount = 600;
const frameBytes = 88;
const digest = async (data: ArrayBuffer): Promise<string> => {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
};
const payload = new ArrayBuffer(frameCount * frameBytes);
const payloadBytes = new Uint8Array(payload);
const objectId = new TextEncoder().encode("object:CacheTarget");
for (let frame = 1; frame <= frameCount; frame += 1) {
const offset = (frame - 1) * frameBytes;
const view = new DataView(payload, offset, frameBytes);
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);
view.setUint8(16, objectId.length);
payloadBytes.set(objectId, offset + 17);
[frame / 10, 0, 0, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
}
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
frame: index + 1,
byteOffset: index * frameBytes,
byteLength: frameBytes,
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
})));
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer;
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
const manifest = {
schemaVersion: 1 as const,
graphId: "geometry-node-tree:simulation-performance",
graphHash: fixedHash,
sourceBlendSha256: await digest(sourceBlend),
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
cacheSha256: await digest(payload),
blenderVersion: "5.2.0",
frameStart: 1,
frameEnd: frameCount,
byteLength: payload.byteLength,
frames,
};
const projectId = `simulation-performance-${Date.now()}`;
const started = performance.now();
const writer = new StorageClient();
const saved = await writer.saveProject(projectId, 1, sourceBlend.slice(0));
const stored = await writer.putSimulationCache(projectId, manifest, payload);
writer.terminate();
const storedAt = performance.now();
const reader = new StorageClient();
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:CachePerformance",
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: frameCount },
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 publishedFrames = 0;
let lastTranslation = 0;
const playback = new BrowserTransformCachePlaybackSession(scene, {
frameStart: 1,
frameEnd: frameCount,
readFrame: async (frame, signal) => {
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame);
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
return read.data;
},
}, (preview) => {
publishedFrames += 1;
lastTranslation = preview.nodes[0].transform.translation[0];
});
const playbackResult = await playback.play();
const finished = performance.now();
reader.terminate();
return {
backend: saved.backend,
frameCount,
byteLength: manifest.byteLength,
status: playbackResult.status,
appliedFrames: playbackResult.appliedFrames,
lastFrame: playbackResult.lastFrame,
publishedFrames,
lastTranslation,
storeMs: Math.round(storedAt - started),
playbackMs: Math.round(finished - storedAt),
elapsedMs: Math.round(finished - started),
};
});
expect(result.backend).toBe("opfs");
expect(result).toMatchObject({ frameCount: 600, byteLength: 52_800, status: "COMPLETED", appliedFrames: 600, lastFrame: 600, publishedFrames: 600 });
expect(result.lastTranslation).toBeCloseTo(60, 5);
expect(result.storeMs).toBeLessThan(30_000);
expect(result.playbackMs).toBeLessThan(30_000);
expect(result.elapsedMs).toBeLessThan(30_000);
});

View File

@@ -7,6 +7,9 @@ const animationBlend = path.resolve(import.meta.dirname, "../../../tests/files/w
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");
@@ -298,7 +301,9 @@ test("reads bounded non-mesh data blocks and previews supported geometry", async
await expect(page.getByText("WebVolumeObject", { exact: true })).toBeVisible();
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6");
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
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;
@@ -518,6 +523,45 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as
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<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/grease-pencil-viewport-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { 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, 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);
@@ -533,6 +577,21 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel"
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);
@@ -544,6 +603,48 @@ test("moves an N-016 Grease Pencil point through one revision-bound Main transac
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<Record<string, unknown>>((resolve, reject) => {
@@ -557,6 +658,19 @@ test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", as
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");
@@ -607,6 +721,28 @@ test("commits N-017 vertex color and weight patches from the bounded paint panel
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<Record<string, unknown>>((resolve, reject) => {
@@ -625,7 +761,12 @@ test("validates the N-018 physics capability and cache manifests without claimin
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 }) => {
@@ -637,7 +778,7 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
await expect(canvas).toHaveAttribute("data-view-look", "None");
await expect(canvas).toHaveAttribute("data-mist", "disabled");
const mapping = await page.evaluate(async () => {
const { blenderLightIntensity, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
const { 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,
@@ -650,9 +791,44 @@ test("maps N-019 Scene exposure and light shadow metadata without using legacy W
selectable: true, localMatrix: new Array(16).fill(0), worldMatrix: new Array(16).fill(0),
transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 },
}, new Object3D());
return { intensity: blenderLightIntensity(definition), castShadow: light.castShadow, sourceShadow: light.userData.blenderCastsShadow };
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).toEqual({ intensity: 40, castShadow: false, sourceShadow: false });
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<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/scene-delta-render-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { 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 }) => {
@@ -670,6 +846,34 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes
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("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", 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 execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 });
return {
status: scene.compositorStatus,
pixel: Array.from(execution.composite.data),
evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name),
gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code,
};
}
finally { client.terminate(); }
}, new Uint8Array(bytes));
expect(result.status).toBe("AVAILABLE");
expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]);
expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]);
expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED");
});
test("validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates", async ({ page }) => {
@@ -682,14 +886,39 @@ test("validates N-021 sequencer strips, deterministic edits, sandbox paths and c
}));
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<Record<string, unknown>>((resolve, reject) => {
@@ -699,6 +928,7 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
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");
@@ -708,6 +938,34 @@ test("validates N-022 tracking markers, masks, resource bindings and solve gates
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<Record<string, unknown>>((resolve, reject) => {
@@ -721,11 +979,19 @@ test("validates N-023 asset catalogs, library graphs, archive budgets and IO gat
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 }) => {
@@ -743,6 +1009,9 @@ test("validates N-024 editor context, selection sync, layout budgets and workflo
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 }) => {
@@ -755,6 +1024,11 @@ test("validates N-025 script policy, signatures, budgets and platform gates", as
}));
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");
@@ -777,6 +1051,10 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
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 }) => {
@@ -797,7 +1075,7 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
expect(result.handleHit).toEqual(["object:1", "HANDLE_RIGHT"]);
expect(result.rangePatch).toEqual([["curve:1", [1, 2, 4]]]);
expect(result.migrated).toEqual([2, "mesh:1"]);
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
expect(result.gates).toEqual(["READY", "READY", "READY"]);
});
test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => {
@@ -808,23 +1086,324 @@ test("validates the N-015 curve gizmo interaction transaction boundary", async (
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");
});
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
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);
if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55));
else camera.position.set(4.5, -4.5, 3.5);
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<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {
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.decodedByteLength).toBe(4);
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<any>((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 }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<any>((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("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978");
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_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_COLOR_GRID_UNSUPPORTED",
"VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED",
"VOLUME_VELOCITY_RENDER_UNSUPPORTED",
]);
});
test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => {
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);
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 = 20_000): Promise<void> => {
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(() => mainCanvas.dataset.volumeStatus === "ready");
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(() => offscreenCanvas.dataset.volumeStatus === "ready" && 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<any>((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.paging.pageCount).toBeGreaterThan(1);
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
expect(gpu.samplesStable).toBe(true);
const interrupted = await page.evaluate(() => new Promise<any>((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<any> => page.evaluate(({ action, state }) => new Promise<any>((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 }) => {
@@ -865,7 +1444,9 @@ test("keeps N-015 non-mesh previews in the OffscreenCanvas renderer", async ({ p
const canvas = page.locator("canvas.viewport-canvas");
await expect(canvas).toHaveAttribute("data-renderer-backend", "offscreen-worker");
await expect(canvas).toHaveAttribute("data-non-mesh-count", "6", { timeout: 20_000 });
await expect(canvas).toHaveAttribute("data-non-mesh-blocked-count", "2");
await expect(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();
});
@@ -1025,16 +1606,27 @@ test("persists and revalidates content-addressed Simulation caches across Worker
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<string> => {
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 source = Uint8Array.from([11, 12, 13, 21, 22, 23, 24]);
const frameOne = source.buffer.slice(0, 3);
const frameTwo = source.buffer.slice(3);
const payload = source.buffer.slice(0);
const 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 manifest = {
schemaVersion: 1 as const,
@@ -1048,8 +1640,8 @@ test("persists and revalidates content-addressed Simulation caches across Worker
frameEnd: 2,
byteLength: payload.byteLength,
frames: [
{ frame: 1, byteOffset: 0, byteLength: 3, sha256: await digest(frameOne) },
{ frame: 2, byteOffset: 3, byteLength: 4, sha256: await digest(frameTwo) },
{ 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();
@@ -1061,6 +1653,12 @@ test("persists and revalidates content-addressed Simulation caches across Worker
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) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).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);
@@ -1070,7 +1668,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
}
let corruptCode = "";
try {
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, source.buffer.slice(0));
await restarted.putSimulationCache(projectId, { ...manifest, cacheSha256: "0".repeat(64) }, frameOne.slice(0));
}
catch (error) {
corruptCode = String((error as Error & { code?: string }).code ?? "");
@@ -1080,10 +1678,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
cacheKey: stored.cacheKey,
path: stored.path,
listed: listed.caches.map((cache) => cache.cacheKey),
bytes: Array.from(new Uint8Array(read.data)),
bytes: read.data.byteLength,
frame: frameRead.frame,
frameOffset: frameRead.byteOffset,
frameBytes: Array.from(new Uint8Array(frameRead.data)),
frameBytes: frameRead.data.byteLength,
frameMagic: new DataView(frameRead.data).getUint32(0, true),
publishedFrame,
publishedTranslation,
missingFrameCode,
corruptCode,
};
@@ -1091,10 +1692,13 @@ test("persists and revalidates content-addressed Simulation caches across Worker
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
expect(result.listed).toContain(result.cacheKey);
expect(result.bytes).toEqual([11, 12, 13, 21, 22, 23, 24]);
expect(result.bytes).toBe(176);
expect(result.frame).toBe(2);
expect(result.frameOffset).toBe(3);
expect(result.frameBytes).toEqual([21, 22, 23, 24]);
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");
});
@@ -1386,15 +1990,35 @@ test("discovers and reuses a valid LOD cache after an application refresh", asyn
await expect(page.locator("[data-testid=engine-status]")).toContainText("cached LOD mesh", { timeout: 15_000 });
});
test("workspace context routes mode and operator search state", async ({ page }) => {
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.getByRole("button", { name: "Object Mode" }).click();
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.getByRole("button", { name: "操作搜索" }).click();
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 }) => {
@@ -1425,6 +2049,42 @@ test("timeline transport controls update the imported frame range", async ({ pag
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) => {

View File

@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
test("decodes, uploads and renders a validated 4K texture in Chromium", async ({ page }) => {
test.setTimeout(45_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
const source = document.createElement("canvas");
source.width = 4096; source.height = 4096;
const context = source.getContext("2d", { alpha: false });
if (!context) throw new Error("2D texture fixture context is unavailable");
context.fillStyle = "#dd3322"; context.fillRect(0, 0, 2048, 4096);
context.fillStyle = "#22bb66"; context.fillRect(2048, 0, 2048, 4096);
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("4K PNG encoding failed")), "image/png"));
const data = await blob.arrayBuffer();
const asset = await createGPUTextureAsset({ assetId: "asset:4k", imageId: "image:4k", mimeType: "image/png", width: 4096, height: 4096, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
const started = performance.now();
const store = new GPUTextureStore();
const status = await store.upload([asset]);
const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; document.body.append(canvas);
const renderer = new WebGLRenderer({ canvas, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:4k", "BASE_COLOR") });
scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const gl = renderer.getContext(); const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); canvas.remove();
return { status, byteLength: data.byteLength, colored, elapsedMs };
});
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(30_000);
});

View File

@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
test("decodes, uploads and renders a validated 8K texture in Chromium", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { createGPUTextureAsset } = await import("/src/render/RenderAssets.ts");
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
const { Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, WebGLRenderer } = await import("/src/vendor/three/three.module.js");
const target = document.createElement("canvas"); target.width = 64; target.height = 64; document.body.append(target);
const renderer = new WebGLRenderer({ canvas: target, preserveDrawingBuffer: true }); renderer.setSize(64, 64, false);
const gl = renderer.getContext(); const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
if (maxTextureSize < 8192) { renderer.dispose(); target.remove(); return { supported: false, maxTextureSize, byteLength: 0, colored: 0, elapsedMs: 0, status: { loaded: 0, rejected: 0, bytes: 0 } }; }
const source = document.createElement("canvas"); source.width = 8192; source.height = 8192;
const context = source.getContext("2d", { alpha: false });
if (!context) throw new Error("2D texture fixture context is unavailable");
context.fillStyle = "#2266dd"; context.fillRect(0, 0, 4096, 8192);
context.fillStyle = "#ddcc22"; context.fillRect(4096, 0, 4096, 8192);
const blob = await new Promise<Blob>((resolve, reject) => source.toBlob((value) => value ? resolve(value) : reject(new Error("8K PNG encoding failed")), "image/png"));
const data = await blob.arrayBuffer();
const asset = await createGPUTextureAsset({ assetId: "asset:8k", imageId: "image:8k", mimeType: "image/png", width: 8192, height: 8192, usage: "BASE_COLOR", colorSpace: "SRGB" }, data);
const started = performance.now(); const store = new GPUTextureStore(); const status = await store.upload([asset]);
const scene = new Scene(); const camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const material = new MeshBasicMaterial({ map: store.get("image:8k", "BASE_COLOR") }); scene.add(new Mesh(new PlaneGeometry(2, 2), material)); renderer.render(scene, camera);
const pixels = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let colored = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] > 60 || pixels[index + 1] > 60 || pixels[index + 2] > 60) colored += 1;
const elapsedMs = Math.round(performance.now() - started);
material.dispose(); renderer.dispose(); store.dispose(); target.remove();
return { supported: true, maxTextureSize, status, byteLength: data.byteLength, colored, elapsedMs };
});
expect(result.supported, `WebGL MAX_TEXTURE_SIZE=${result.maxTextureSize}`).toBe(true);
expect(result.status).toMatchObject({ loaded: 1, rejected: 0, bytes: result.byteLength });
expect(result.byteLength).toBeGreaterThan(0);
expect(result.colored).toBeGreaterThan(3_000);
expect(result.elapsedMs).toBeLessThan(45_000);
});