Advance M8-M11 parity workflows
This commit is contained in:
43
web/app/src/workers/curve-topology-contract-test.worker.ts
Normal file
43
web/app/src/workers/curve-topology-contract-test.worker.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CURVE_TOPOLOGY_EDITOR_BUDGET,
|
||||
createCurveTopologyEditorManifest,
|
||||
parseCurveTopologyOperationClaim,
|
||||
} from "../../../protocol/curve-topology-editor";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage(value: unknown): void };
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
const manifest = createCurveTopologyEditorManifest();
|
||||
const accepted = parseCurveTopologyOperationClaim({
|
||||
schemaVersion: 1,
|
||||
operator: "ADD_SPLINE",
|
||||
dataId: "curve:BrowserCurve",
|
||||
baseRevision: 9,
|
||||
inputSplineCount: 1,
|
||||
inputPointCount: 2,
|
||||
selectedSplineIndices: [],
|
||||
selectedPointIndices: [],
|
||||
addedSplineCount: 1,
|
||||
addedPointCount: 2,
|
||||
outputSplineCount: 2,
|
||||
outputPointCount: 4,
|
||||
payloadBytes: 256,
|
||||
}, 9);
|
||||
const failure = (value: unknown, revision = 9): string => {
|
||||
try { parseCurveTopologyOperationClaim(value, revision); }
|
||||
catch (error) { return (error as { code?: string }).code ?? ""; }
|
||||
return "";
|
||||
};
|
||||
scope.postMessage({
|
||||
operatorCount: manifest.operators.length,
|
||||
blockedCount: manifest.operators.filter((operator) => operator.gate.status === "BLOCKED").length,
|
||||
readyOperators: manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id),
|
||||
sourceAuthority: manifest.sourceAuthority,
|
||||
atomicMainTransaction: manifest.atomicMainTransaction,
|
||||
accepted,
|
||||
unknownCode: failure({ ...accepted, operator: "SPIN" }),
|
||||
staleCode: failure(accepted, 10),
|
||||
budgetCode: failure({ ...accepted, payloadBytes: CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }),
|
||||
stage: "ONE_VERIFIED_OPERATOR",
|
||||
});
|
||||
};
|
||||
45
web/app/src/workers/external-vfont-test.worker.ts
Normal file
45
web/app/src/workers/external-vfont-test.worker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { validateExternalVFontImport } from "../../../protocol/external-vfont";
|
||||
|
||||
interface Request {
|
||||
data: ArrayBuffer;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const scope = self as unknown as { onmessage: ((event: MessageEvent<Request>) => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
void (async () => {
|
||||
const validated = await validateExternalVFontImport({
|
||||
sourcePath: "//fonts/browser-bfont.pfb",
|
||||
mimeType: "application/x-font-type1",
|
||||
byteLength: event.data.data.byteLength,
|
||||
sha256: event.data.sha256,
|
||||
data: event.data.data,
|
||||
});
|
||||
let spoofCode = "";
|
||||
try {
|
||||
await validateExternalVFontImport({
|
||||
sourcePath: "//fonts/browser-bfont.otf",
|
||||
mimeType: "font/otf",
|
||||
byteLength: validated.data.byteLength,
|
||||
sha256: event.data.sha256,
|
||||
data: validated.data,
|
||||
});
|
||||
}
|
||||
catch (error) { spoofCode = (error as { code?: string }).code ?? ""; }
|
||||
scope.postMessage({
|
||||
metadata: {
|
||||
schemaVersion: validated.schemaVersion,
|
||||
sourcePath: validated.sourcePath,
|
||||
fileName: validated.fileName,
|
||||
format: validated.format,
|
||||
mimeType: validated.mimeType,
|
||||
byteLength: validated.byteLength,
|
||||
sha256: validated.sha256,
|
||||
},
|
||||
copied: validated.data !== event.data.data && new Uint8Array(validated.data).every((value, index) => value === new Uint8Array(event.data.data)[index]),
|
||||
spoofCode,
|
||||
stage: "VALIDATED_BEFORE_STORAGE_OR_MAIN",
|
||||
});
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
import { GREASE_PENCIL_BUDGET, parseGreasePencilData } from "../../../protocol/grease-pencil";
|
||||
|
||||
const point = (x: number) => ({ position: [x, 0, 0], radius: 0.05, opacity: 1 });
|
||||
const point = (x: number, index: number) => ({ id: `grease-pencil-point:SchemaFixture:0:0:${index}`, position: [x, 0, 0], radius: 0.05, opacity: 1 });
|
||||
|
||||
const valid = {
|
||||
id: "gp:SchemaFixture",
|
||||
id: "grease-pencil:SchemaFixture",
|
||||
name: "SchemaFixture",
|
||||
geometryStatus: "available",
|
||||
layerCount: 1,
|
||||
frameCount: 1,
|
||||
strokeCount: 1,
|
||||
pointCount: 2,
|
||||
activeLayerId: "grease-pencil-layer:SchemaFixture:Lines",
|
||||
layers: [{
|
||||
id: "layer:Lines",
|
||||
id: "grease-pencil-layer:SchemaFixture:Lines",
|
||||
name: "Lines",
|
||||
visible: true,
|
||||
locked: false,
|
||||
@@ -19,10 +20,10 @@ const valid = {
|
||||
frames: [{
|
||||
frame: 1,
|
||||
drawing: {
|
||||
id: "drawing:1",
|
||||
id: "grease-pencil-drawing:SchemaFixture:0",
|
||||
strokeCount: 1,
|
||||
pointCount: 2,
|
||||
strokes: [{ id: "stroke:1", cyclic: false, pointCount: 2, points: [point(0), point(1)] }],
|
||||
strokes: [{ id: "grease-pencil-stroke:SchemaFixture:0:0", cyclic: false, pointCount: 2, points: [point(0, 0), point(1, 1)] }],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
@@ -49,13 +50,8 @@ function result(): Record<string, unknown> {
|
||||
...valid,
|
||||
geometryStatus: "blocked",
|
||||
pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1,
|
||||
layers: [{
|
||||
...valid.layers[0],
|
||||
frames: [{
|
||||
...valid.layers[0].frames[0],
|
||||
drawing: { ...valid.layers[0].frames[0].drawing, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, strokes: [{ cyclic: false, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1 }] },
|
||||
}],
|
||||
}],
|
||||
activeLayerId: undefined,
|
||||
layers: [],
|
||||
strokeCount: 1,
|
||||
frameCount: 1,
|
||||
errorCode: "GREASE_PENCIL_BUDGET_EXCEEDED",
|
||||
|
||||
@@ -23,10 +23,10 @@ self.onmessage = () => {
|
||||
id: "grease-pencil-drawing:Viewport",
|
||||
strokeCount: 1,
|
||||
pointCount: 3,
|
||||
strokes: [{ id: "grease-pencil-stroke:Viewport", cyclic: false, pointCount: 3, points: [
|
||||
{ position: [-1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ position: [1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
strokes: [{ id: "grease-pencil-stroke:Viewport:0:0", cyclic: false, pointCount: 3, points: [
|
||||
{ id: "grease-pencil-point:Viewport:0:0:0", position: [-1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ id: "grease-pencil-point:Viewport:0:0:1", position: [0, 0, 0], radius: 0.1, opacity: 1 },
|
||||
{ id: "grease-pencil-point:Viewport:0:0:2", position: [1, 0, 0], radius: 0.1, opacity: 1 },
|
||||
] }],
|
||||
} }] }],
|
||||
};
|
||||
|
||||
285
web/app/src/workers/m10-domain-gate-test.worker.ts
Normal file
285
web/app/src/workers/m10-domain-gate-test.worker.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
GEOMETRY_NODE_GRAPH_BUDGET,
|
||||
gateGeometryNodeGraph,
|
||||
parseGeometryNodeGraph,
|
||||
type GeometryNodeGraphIR,
|
||||
validateGeometryNodeGraph,
|
||||
} from "../../../protocol/geometry-nodes";
|
||||
import { NLA_STACK_BUDGET, gateNlaTracks } from "../../../protocol/nla";
|
||||
import {
|
||||
SHADER_COMPILE_BUDGET,
|
||||
compileMaterialGraph,
|
||||
} from "../../../protocol/shader-compiler";
|
||||
import {
|
||||
SIMULATION_CACHE_BUDGET,
|
||||
computeSimulationCacheRevisionHash,
|
||||
parseSimulationCacheManifest,
|
||||
verifySimulationCache,
|
||||
verifySimulationCacheFrame,
|
||||
} from "../../../protocol/simulation-cache";
|
||||
import type { MaterialIR } from "../../../protocol/scene-ir";
|
||||
|
||||
type Domain = "GN" | "SHADER" | "NLA" | "SIMULATION";
|
||||
type GateResult = {
|
||||
domain: Domain;
|
||||
performanceMs: number;
|
||||
workUnits: number;
|
||||
oomCode: string;
|
||||
maliciousCode: string;
|
||||
recovered: boolean;
|
||||
performanceStatus: string;
|
||||
oomPreventedBeforeAllocation: boolean;
|
||||
};
|
||||
|
||||
function geometryGraph(nodeCount: number): GeometryNodeGraphIR {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "node-group:M10Gate",
|
||||
name: "M10 Gate",
|
||||
interfaceInputs: [],
|
||||
interfaceOutputs: [],
|
||||
nodes: Array.from({ length: nodeCount }, (_value, index) => ({
|
||||
id: `node:${index}`,
|
||||
type: "ShaderNodeValue",
|
||||
name: `Value ${index}`,
|
||||
sockets: [],
|
||||
})),
|
||||
links: [],
|
||||
groupReferences: [],
|
||||
};
|
||||
}
|
||||
|
||||
function runGeometryNodes(): GateResult {
|
||||
const graph = geometryGraph(512);
|
||||
const workUnits = 10;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
const parsed = parseGeometryNodeGraph(graph);
|
||||
if (validateGeometryNodeGraph(parsed).status !== "SUPPORTED") throw new Error("GN performance fixture was blocked");
|
||||
}
|
||||
const oversized = geometryGraph(GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1);
|
||||
const oom = gateGeometryNodeGraph(oversized);
|
||||
const malicious = geometryGraph(2);
|
||||
malicious.nodes[0].sockets = [
|
||||
{ id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" },
|
||||
{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" },
|
||||
];
|
||||
malicious.nodes[1].sockets = structuredClone(malicious.nodes[0].sockets);
|
||||
malicious.links = [
|
||||
{ fromNodeId: "node:0", fromSocketId: "out", toNodeId: "node:1", toSocketId: "in" },
|
||||
{ fromNodeId: "node:1", fromSocketId: "out", toNodeId: "node:0", toSocketId: "in" },
|
||||
];
|
||||
const maliciousGate = gateGeometryNodeGraph(malicious);
|
||||
return {
|
||||
domain: "GN",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: maliciousGate.issues.find((issue) => issue.code === "GN_DEPENDENCY_CYCLE")?.code ?? "",
|
||||
recovered: gateGeometryNodeGraph(geometryGraph(1)).status === "READY",
|
||||
performanceStatus: "SUPPORTED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
function shaderMaterial(): MaterialIR {
|
||||
return {
|
||||
id: "material:M10Gate",
|
||||
name: "M10 Gate",
|
||||
baseColor: [0.2, 0.3, 0.4, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0.1,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
shaderGraphHash: "a".repeat(64),
|
||||
nodes: [
|
||||
{ id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
||||
{ id: "value-b", type: "VALUE", name: "B", defaultValue: [0.3] },
|
||||
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function runShader(): GateResult {
|
||||
const material = shaderMaterial();
|
||||
const workUnits = 100;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
if (compileMaterialGraph(material).status !== "COMPILED") throw new Error("Shader performance fixture was blocked");
|
||||
}
|
||||
const oversized = shaderMaterial();
|
||||
oversized.nodes = Array.from({ length: SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({
|
||||
id: `value:${index}`, type: "VALUE", name: `Value ${index}`, defaultValue: [0],
|
||||
}));
|
||||
oversized.links = [];
|
||||
const oom = compileMaterialGraph(oversized);
|
||||
const cyclic = shaderMaterial();
|
||||
cyclic.links = [
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
];
|
||||
const malicious = compileMaterialGraph(cyclic);
|
||||
return {
|
||||
domain: "SHADER",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: malicious.issues.find((issue) => issue.code === "SHADER_GRAPH_CYCLE")?.code ?? "",
|
||||
recovered: compileMaterialGraph(shaderMaterial()).status === "COMPILED",
|
||||
performanceStatus: "COMPILED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
function nlaTracks(trackCount: number, stripsPerTrack: number) {
|
||||
return Array.from({ length: trackCount }, (_trackValue, trackIndex) => ({
|
||||
schemaVersion: 1,
|
||||
id: `track:${trackIndex}`,
|
||||
ownerId: "object:M10Gate",
|
||||
name: `Track ${trackIndex}`,
|
||||
muted: false,
|
||||
solo: false,
|
||||
selected: false,
|
||||
strips: Array.from({ length: stripsPerTrack }, (_stripValue, stripIndex) => ({
|
||||
id: `strip:${trackIndex}:${stripIndex}`,
|
||||
actionId: "action:M10Gate",
|
||||
frameStart: stripIndex * 12,
|
||||
frameEnd: stripIndex * 12 + 10,
|
||||
actionFrameStart: 0,
|
||||
actionFrameEnd: 10,
|
||||
scale: 1,
|
||||
repeat: 1,
|
||||
blendIn: 0,
|
||||
blendOut: 0,
|
||||
influence: 1,
|
||||
blendMode: "REPLACE",
|
||||
extrapolation: "NOTHING",
|
||||
muted: false,
|
||||
selected: false,
|
||||
stripType: "CLIP",
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function runNla(): GateResult {
|
||||
const context = { actionIds: new Set(["action:M10Gate"]), ownerId: "object:M10Gate" };
|
||||
const tracks = nlaTracks(64, 4);
|
||||
const workUnits = 20;
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < workUnits; index += 1) {
|
||||
if (gateNlaTracks(tracks, context).status !== "READY") throw new Error("NLA performance fixture was blocked");
|
||||
}
|
||||
const oversized = new Array(NLA_STACK_BUDGET.maxTracks + 1).fill(tracks[0]);
|
||||
const oom = gateNlaTracks(oversized, context);
|
||||
const malicious = nlaTracks(1, 1) as Array<Record<string, unknown> & { strips: Array<Record<string, unknown>> }>;
|
||||
malicious[0].strips[0].proxySuccess = true;
|
||||
const maliciousGate = gateNlaTracks(malicious, context);
|
||||
return {
|
||||
domain: "NLA",
|
||||
performanceMs: Math.round(performance.now() - started),
|
||||
workUnits,
|
||||
oomCode: oom.issues[0]?.code ?? "",
|
||||
maliciousCode: maliciousGate.issues[0]?.code ?? "",
|
||||
recovered: gateNlaTracks(nlaTracks(1, 1), context).status === "READY",
|
||||
performanceStatus: "SUPPORTED",
|
||||
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
|
||||
};
|
||||
}
|
||||
|
||||
async function digest(value: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", value);
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function simulationFixture(frameCount: number, frameBytes: number) {
|
||||
const payload = new Uint8Array(frameCount * frameBytes);
|
||||
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
|
||||
const binding = {
|
||||
graphId: "node-group:M10Gate",
|
||||
graphHash: await digest(Uint8Array.from([1]).buffer),
|
||||
sourceBlendSha256: await digest(Uint8Array.from([2]).buffer),
|
||||
sourceRevision: 15,
|
||||
inputHash: await digest(Uint8Array.from([3]).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const frames = [];
|
||||
for (let frame = 1; frame <= frameCount; frame += 1) {
|
||||
const byteOffset = (frame - 1) * frameBytes;
|
||||
frames.push({ frame, byteOffset, byteLength: frameBytes, sha256: await digest(payload.buffer.slice(byteOffset, byteOffset + frameBytes)) });
|
||||
}
|
||||
return {
|
||||
payload: payload.buffer,
|
||||
manifest: {
|
||||
schemaVersion: 2,
|
||||
...binding,
|
||||
revisionHash: await computeSimulationCacheRevisionHash(binding),
|
||||
cacheSha256: await digest(payload.buffer),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function simulationError(operation: () => unknown | Promise<unknown>): Promise<string> {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
}
|
||||
|
||||
async function runSimulation(): Promise<GateResult> {
|
||||
const fixture = await simulationFixture(64, 16);
|
||||
const workUnits = 64;
|
||||
const started = performance.now();
|
||||
await verifySimulationCache(fixture.manifest, fixture.payload);
|
||||
const performanceMs = Math.round(performance.now() - started);
|
||||
const oomCode = await simulationError(() => parseSimulationCacheManifest({
|
||||
...fixture.manifest,
|
||||
byteLength: SIMULATION_CACHE_BUDGET.maxCacheBytes + 1,
|
||||
}));
|
||||
const maliciousCode = await simulationError(() => parseSimulationCacheManifest({
|
||||
...fixture.manifest,
|
||||
proxySuccess: true,
|
||||
}));
|
||||
const recoveredFrame = await verifySimulationCacheFrame(
|
||||
fixture.manifest,
|
||||
1,
|
||||
fixture.payload.slice(0, 16),
|
||||
);
|
||||
return {
|
||||
domain: "SIMULATION",
|
||||
performanceMs,
|
||||
workUnits,
|
||||
oomCode,
|
||||
maliciousCode,
|
||||
recovered: recoveredFrame.frame === 1,
|
||||
performanceStatus: "VERIFIED",
|
||||
oomPreventedBeforeAllocation: oomCode === "SIMULATION_CACHE_BUDGET_EXCEEDED",
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<{ domain?: Domain }>) => {
|
||||
try {
|
||||
const result = event.data.domain === "GN" ? runGeometryNodes()
|
||||
: event.data.domain === "SHADER" ? runShader()
|
||||
: event.data.domain === "NLA" ? runNla()
|
||||
: event.data.domain === "SIMULATION" ? await runSimulation()
|
||||
: undefined;
|
||||
if (!result) throw new Error("Unknown M10 gate domain");
|
||||
self.postMessage({ ok: true, result });
|
||||
}
|
||||
catch (error) {
|
||||
self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { validateNanoVDBBundleManifest } from "../../../protocol/volume-vdb";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
createNanoVDBPageFeedbackGPUBuffer,
|
||||
NanoVDBProgressivePageUploader,
|
||||
NanoVDBProgressiveRedrawScheduler,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
readNanoVDBPageFeedbackGPUBuffer,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function digest(pixels: Uint8Array): Promise<string> {
|
||||
const bytes = new Uint8Array(pixels.byteLength);
|
||||
bytes.set(pixels);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json());
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: offscreen fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: offscreen fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const coordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(coordinate);
|
||||
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen fixture has no leaf");
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
|
||||
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
|
||||
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen leaf page is not pageable");
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(pageCount * pageByteLength, 6);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
|
||||
for (let pageId = 0; pageId < pageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
|
||||
}
|
||||
const feedback = createNanoVDBPageFeedbackGPUBuffer(device, 4);
|
||||
const beforeLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedback);
|
||||
const missing = await readNanoVDBPageFeedbackGPUBuffer(device, feedback, 4, pageCount, 17);
|
||||
const start = density.byteOffset + missing.pageIds[0] * pageByteLength;
|
||||
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
|
||||
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` } });
|
||||
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: offscreen page range returned ${pageResponse.status}`);
|
||||
const frames: Array<() => void> = [];
|
||||
let redraws = 0;
|
||||
const scheduler = new NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
|
||||
const uploader = new NanoVDBProgressivePageUploader(grid, scheduler);
|
||||
const upload = uploader.upload(missing.pageIds[0], await pageResponse.arrayBuffer());
|
||||
const queuedBeforeRedraw = frames.length;
|
||||
frames.shift()?.();
|
||||
const afterLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
|
||||
const pixelsA = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
|
||||
const pixelsB = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
|
||||
const result = {
|
||||
pageCount,
|
||||
leafPageId,
|
||||
beforeLoad,
|
||||
missing,
|
||||
upload,
|
||||
queuedBeforeRedraw,
|
||||
redraws,
|
||||
afterLoad,
|
||||
pixels: { bytes: pixelsA.byteLength, sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
|
||||
scheduler: scheduler.stats(),
|
||||
};
|
||||
scheduler.dispose();
|
||||
grid.dispose();
|
||||
feedback.destroy();
|
||||
session.dispose();
|
||||
scope.postMessage(result);
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
126
web/app/src/workers/nanovdb-opfs-restart-test.worker.ts
Normal file
126
web/app/src/workers/nanovdb-opfs-restart-test.worker.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import { createNanoVDBFloat32GridPaged, NanoVDBWebGPUDeviceSession } from "../render/nanovdb-volume-renderer";
|
||||
import { commitNanoVDBToOPFS, openNanoVDBFromOPFS, pruneNanoVDBOPFS } from "../volume/nanovdb-opfs";
|
||||
import { loadNanoVDBGridPage } from "../volume/nanovdb-viewport";
|
||||
|
||||
interface RestartState {
|
||||
projectId: string;
|
||||
bundleSha256: string;
|
||||
claimedResidentPages: number[];
|
||||
}
|
||||
|
||||
const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "prepare" | "reopen"; state?: RestartState }>) => void) | null; postMessage: (value: unknown) => void };
|
||||
const projectId = "m8-14-worker-restart";
|
||||
|
||||
function buffer(seed: number): ArrayBuffer {
|
||||
const bytes = Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 29 + seed) & 0xff);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function digest(value: ArrayBuffer): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", value.slice(0));
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function manifestFor(data: ArrayBuffer): Promise<NanoVDBBundleManifestIR> {
|
||||
const bytes = new Uint8Array(data);
|
||||
const first = bytes.slice(0, 64 * 1024).buffer;
|
||||
const second = bytes.slice(64 * 1024).buffer;
|
||||
return validateNanoVDBBundleManifest({
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
sourcePath: "//volumes/m8-14.vdb",
|
||||
sourceSha256: "a".repeat(64),
|
||||
conversionRequestSha256: "b".repeat(64),
|
||||
bundlePath: "//volumes/m8-14.nvdb",
|
||||
bundleByteLength: data.byteLength,
|
||||
bundleSha256: await digest(data),
|
||||
converter: { target: "SERVER", blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) },
|
||||
grids: [{
|
||||
name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0,
|
||||
segmentByteOffset: 0, segmentByteLength: data.byteLength, byteOffset: 0, byteLength: data.byteLength,
|
||||
indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, voxelSize: [1, 1, 1],
|
||||
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
|
||||
}],
|
||||
chunks: [
|
||||
{ index: 0, byteOffset: 0, byteLength: first.byteLength, sha256: await digest(first) },
|
||||
{ index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await digest(second) },
|
||||
],
|
||||
material: { densityGrid: "density", densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR" },
|
||||
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: 64 * 1024, maxResidentBytes: 128 * 1024, shaderSemanticVersion: "volume-wgsl-v1" },
|
||||
});
|
||||
}
|
||||
|
||||
async function prepare(): Promise<unknown> {
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const data = buffer(19);
|
||||
const manifest = await manifestFor(data);
|
||||
await commitNanoVDBToOPFS(manifest, async (range) => data.slice(range.start, range.endExclusive), new AbortController().signal);
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(manifest.gpu.maxResidentBytes, 5);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, manifest.grids[0].byteLength, manifest.gpu.pageByteLength, manifest.gpu.maxResidentBytes);
|
||||
grid.uploadPage(0, data.slice(0, 64 * 1024));
|
||||
const residentBeforeRestart = [...grid.residentVirtualPages];
|
||||
grid.dispose();
|
||||
session.dispose();
|
||||
return { projectId, bundleSha256: manifest.bundleSha256, residentBeforeRestart, claimedResidentPages: residentBeforeRestart };
|
||||
}
|
||||
|
||||
async function overwriteFirstChunk(state: RestartState, data: ArrayBuffer): Promise<void> {
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const name of ["projects", state.projectId, "cache", "vdb", state.bundleSha256]) {
|
||||
directory = await directory.getDirectoryHandle(name);
|
||||
}
|
||||
const writer = await (await directory.getFileHandle("00000.chunk")).createWritable();
|
||||
await writer.write(data);
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
async function reopen(state: RestartState): Promise<unknown> {
|
||||
const opened = await openNanoVDBFromOPFS(state.projectId, state.bundleSha256);
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(opened.manifest.gpu.maxResidentBytes, 5);
|
||||
const grid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
|
||||
const residentBeforeRestore = [...grid.residentVirtualPages];
|
||||
const verificationGrid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
|
||||
try {
|
||||
const page = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
|
||||
grid.uploadPage(0, page);
|
||||
const residentAfterRestore = [...grid.residentVirtualPages];
|
||||
|
||||
const originalChunk = page.slice(0);
|
||||
const tamperedChunk = new Uint8Array(originalChunk.slice(0));
|
||||
tamperedChunk[0] ^= 0xff;
|
||||
await overwriteFirstChunk(state, tamperedChunk.buffer);
|
||||
let tamperedError = "";
|
||||
try {
|
||||
const unverified = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
|
||||
verificationGrid.uploadPage(0, unverified);
|
||||
}
|
||||
catch (error) {
|
||||
tamperedError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await overwriteFirstChunk(state, originalChunk);
|
||||
return {
|
||||
manifestSha256: opened.manifest.bundleSha256,
|
||||
claimedResidentPages: state.claimedResidentPages,
|
||||
residentBeforeRestore,
|
||||
residentAfterRestore,
|
||||
pageBytes: page.byteLength,
|
||||
tamperedError,
|
||||
residentAfterTamperedRestore: [...verificationGrid.residentVirtualPages],
|
||||
};
|
||||
}
|
||||
finally {
|
||||
grid.dispose();
|
||||
verificationGrid.dispose();
|
||||
session.dispose();
|
||||
await pruneNanoVDBOPFS(state.projectId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
void (event.data.action === "prepare" ? prepare() : reopen(event.data.state!))
|
||||
.then((result) => scope.postMessage(result))
|
||||
.catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
450
web/app/src/workers/nanovdb-page-feedback-test.worker.ts
Normal file
450
web/app/src/workers/nanovdb-page-feedback-test.worker.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import {
|
||||
createNanoVDBPageFeedbackBuffer,
|
||||
dispatchNanoVDBPageFeedbackBatch,
|
||||
} from "../../../protocol/nanovdb-page-feedback";
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
NanoVDBProgressivePageUploader,
|
||||
NanoVDBProgressiveRedrawScheduler,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
readNanoVDBPageFeedbackGPUBuffer,
|
||||
readNanoVDBWordsWebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import { createResumableHttpNanoVDBRangeSource, type NanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
const FEEDBACK_STRESS_PAGE_BYTES = 256 * 1024;
|
||||
const GUARD_WORDS = [0x13579bdf, 0x2468ace0] as const;
|
||||
|
||||
function createGuardedFeedback(device: GPUDevice, capacity: number): { buffer: GPUBuffer; logicalBytes: number; totalBytes: number } {
|
||||
const initial = new Uint32Array(createNanoVDBPageFeedbackBuffer(capacity));
|
||||
const guarded = new Uint32Array(initial.length + GUARD_WORDS.length);
|
||||
guarded.set(initial);
|
||||
guarded.set(GUARD_WORDS, initial.length);
|
||||
const buffer = device.createBuffer({
|
||||
label: `NanoVDB guarded page feedback ${capacity}`,
|
||||
size: guarded.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||
});
|
||||
device.queue.writeBuffer(buffer, 0, guarded);
|
||||
return { buffer, logicalBytes: initial.byteLength, totalBytes: guarded.byteLength };
|
||||
}
|
||||
|
||||
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
|
||||
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const words = new Uint32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
readback.destroy();
|
||||
return words;
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, label: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (condition()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`NanoVDB page request test timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function outcome<T>(promise: Promise<T>): Promise<{ status: "RESOLVED"; value: T } | { status: "REJECTED"; name: string }> {
|
||||
return promise.then(
|
||||
(value) => ({ status: "RESOLVED" as const, value }),
|
||||
(error: unknown) => ({ status: "REJECTED" as const, name: error instanceof DOMException ? error.name : "Error" }),
|
||||
);
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(
|
||||
await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR,
|
||||
);
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NanoVDB page feedback fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NanoVDB page feedback fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const activeCoordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(activeCoordinate);
|
||||
if (leafByteOffset === null) throw new Error("NanoVDB page feedback fixture does not reach a leaf");
|
||||
const manifestPageBytes = manifest.gpu.pageByteLength;
|
||||
const manifestPageCount = Math.ceil(payload.byteLength / manifestPageBytes);
|
||||
const stressPageCount = Math.ceil(payload.byteLength / FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const leafPageId = Math.floor(leafByteOffset / FEEDBACK_STRESS_PAGE_BYTES);
|
||||
if (stressPageCount < 4 || leafPageId < 1 || leafPageId >= stressPageCount || manifestPageCount < 1) throw new Error("NanoVDB page feedback fixture page layout is insufficient");
|
||||
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(Math.max(manifestPageBytes, payload.byteLength + FEEDBACK_STRESS_PAGE_BYTES), 6);
|
||||
|
||||
const leafGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, (stressPageCount - 1) * FEEDBACK_STRESS_PAGE_BYTES);
|
||||
for (let pageId = 0; pageId < stressPageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
leafGrid.uploadPage(pageId, payload.slice(pageId * FEEDBACK_STRESS_PAGE_BYTES, Math.min(payload.byteLength, (pageId + 1) * FEEDBACK_STRESS_PAGE_BYTES)));
|
||||
}
|
||||
const leafFeedback = createGuardedFeedback(device, 4);
|
||||
const leafSamples = await sampleNanoVDBFloat32WebGPU(
|
||||
device,
|
||||
leafGrid,
|
||||
Array.from({ length: 256 }, () => activeCoordinate),
|
||||
leafFeedback.buffer,
|
||||
);
|
||||
const leafResult = await readNanoVDBPageFeedbackGPUBuffer(device, leafFeedback.buffer, 4, stressPageCount, 17);
|
||||
const leafWords = await readWords(device, leafFeedback.buffer, leafFeedback.totalBytes);
|
||||
const leafGuard = [...leafWords.slice(leafFeedback.logicalBytes / 4)];
|
||||
const pageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
|
||||
const staleDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 18, async (pageId, renderRevision) => {
|
||||
pageIoRequests.push({ pageId, renderRevision });
|
||||
});
|
||||
const stalePageIoCount = pageIoRequests.length;
|
||||
const currentDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 17, async (pageId, renderRevision) => {
|
||||
pageIoRequests.push({ pageId, renderRevision });
|
||||
});
|
||||
|
||||
const manifestGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
|
||||
const manifestFeedback = createGuardedFeedback(device, 4);
|
||||
const manifestWords = await readNanoVDBWordsWebGPU(device, manifestGrid, [leafByteOffset], manifestFeedback.buffer);
|
||||
const manifestFeedbackResult = await readNanoVDBPageFeedbackGPUBuffer(device, manifestFeedback.buffer, 4, manifestPageCount, 19);
|
||||
const manifestRanges: Parameters<NanoVDBRangeSource>[0][] = [];
|
||||
const httpPageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const pageSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
manifestRanges.push({ ...range });
|
||||
return httpPageSource(range, signal);
|
||||
};
|
||||
const pageRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, pageSource);
|
||||
const manifestPageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
|
||||
const manifestStaleDispatch = await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
20,
|
||||
pageRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, renderRevision }) => { manifestPageIoRequests.push({ pageId, renderRevision }); },
|
||||
);
|
||||
const staleManifestRangeCount = manifestRanges.length;
|
||||
let manifestPageMatchesPayload = false;
|
||||
const manifestDispatch = await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
pageRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, renderRevision, data }) => {
|
||||
manifestPageIoRequests.push({ pageId, renderRevision });
|
||||
const expected = new Uint8Array(payload, pageId * manifestPageBytes, data.byteLength);
|
||||
const loaded = new Uint8Array(data);
|
||||
manifestPageMatchesPayload = loaded.length === expected.length && loaded.every((byte, index) => byte === expected[index]);
|
||||
},
|
||||
);
|
||||
pageRequests.dispose();
|
||||
const declaredManifestRanges = manifestRanges.map((range) => {
|
||||
const chunk = manifest.chunks[range.chunkIndex];
|
||||
return {
|
||||
chunkIndex: chunk.index,
|
||||
start: chunk.byteOffset,
|
||||
endExclusive: chunk.byteOffset + chunk.byteLength,
|
||||
sha256: chunk.sha256,
|
||||
};
|
||||
});
|
||||
|
||||
let coalescedRangeCalls = 0;
|
||||
let coalescedUnderlyingAborts = 0;
|
||||
let releaseCoalescedRange: (() => void) | undefined;
|
||||
const coalescedSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
coalescedRangeCalls++;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
coalescedUnderlyingAborts++;
|
||||
reject(new DOMException("coalesced range cancelled", "AbortError"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
releaseCoalescedRange = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
return httpPageSource(range, signal);
|
||||
};
|
||||
const coalescedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, coalescedSource);
|
||||
const firstSubscriber = new AbortController();
|
||||
const secondSubscriber = new AbortController();
|
||||
const coalescedConsumers: string[] = [];
|
||||
const firstCoalesced = outcome(loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
coalescedRequests,
|
||||
firstSubscriber.signal,
|
||||
async ({ pageId }) => { coalescedConsumers.push(`first:${pageId}`); },
|
||||
));
|
||||
const secondCoalesced = outcome(loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
coalescedRequests,
|
||||
secondSubscriber.signal,
|
||||
async ({ pageId }) => { coalescedConsumers.push(`second:${pageId}`); },
|
||||
));
|
||||
await waitFor(() => coalescedRangeCalls === 1 && coalescedRequests.stats().subscribers === 2, "two coalesced subscribers");
|
||||
const coalescedBeforeCancel = coalescedRequests.stats();
|
||||
firstSubscriber.abort();
|
||||
const coalescedAfterFirstCancel = coalescedRequests.stats();
|
||||
releaseCoalescedRange?.();
|
||||
const coalescedOutcomes = await Promise.all([firstCoalesced, secondCoalesced]);
|
||||
const coalescedAfterResolve = coalescedRequests.stats();
|
||||
coalescedRequests.dispose();
|
||||
|
||||
let lastCancelRangeCalls = 0;
|
||||
let lastCancelUnderlyingAborts = 0;
|
||||
const lastCancelSource: NanoVDBRangeSource = async (_range, signal) => {
|
||||
lastCancelRangeCalls++;
|
||||
return new Promise<ArrayBuffer>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
lastCancelUnderlyingAborts++;
|
||||
reject(new DOMException("last subscriber cancelled range", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
const lastCancelRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, lastCancelSource);
|
||||
const thirdSubscriber = new AbortController();
|
||||
const fourthSubscriber = new AbortController();
|
||||
const thirdCancelled = outcome(lastCancelRequests.request(0, thirdSubscriber.signal));
|
||||
const fourthCancelled = outcome(lastCancelRequests.request(0, fourthSubscriber.signal));
|
||||
await waitFor(() => lastCancelRangeCalls === 1 && lastCancelRequests.stats().subscribers === 2, "last-cancel subscribers");
|
||||
thirdSubscriber.abort();
|
||||
const lastCancelAfterFirst = {
|
||||
stats: lastCancelRequests.stats(),
|
||||
underlyingAborts: lastCancelUnderlyingAborts,
|
||||
};
|
||||
fourthSubscriber.abort();
|
||||
const lastCancelOutcomes = await Promise.all([thirdCancelled, fourthCancelled]);
|
||||
await waitFor(() => lastCancelUnderlyingAborts === 1, "underlying range abort");
|
||||
const lastCancelAfterLast = lastCancelRequests.stats();
|
||||
lastCancelRequests.dispose();
|
||||
|
||||
let tamperedRangeCalls = 0;
|
||||
let tamperedResidentWrites = 0;
|
||||
const tamperedSource: NanoVDBRangeSource = async (range, signal) => {
|
||||
tamperedRangeCalls++;
|
||||
const data = new Uint8Array(await httpPageSource(range, signal));
|
||||
data[0] ^= 0xff;
|
||||
return data.buffer;
|
||||
};
|
||||
const tamperedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, tamperedSource);
|
||||
const tamperedResidentGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
|
||||
let tamperedErrorCode = "";
|
||||
let tamperedErrorMessage = "";
|
||||
try {
|
||||
await loadNanoVDBFeedbackPages(
|
||||
manifestFeedbackResult,
|
||||
19,
|
||||
tamperedRequests,
|
||||
new AbortController().signal,
|
||||
async ({ pageId, data }) => {
|
||||
tamperedResidentWrites++;
|
||||
tamperedResidentGrid.uploadPage(pageId, data);
|
||||
},
|
||||
);
|
||||
}
|
||||
catch (error) {
|
||||
tamperedErrorCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
||||
tamperedErrorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const tamperedCache = {
|
||||
residentPageCount: tamperedResidentGrid.residentPageCount,
|
||||
residentBytes: tamperedResidentGrid.residentBytes,
|
||||
residentVirtualPages: [...tamperedResidentGrid.residentVirtualPages],
|
||||
coordinator: tamperedRequests.stats(),
|
||||
};
|
||||
tamperedResidentGrid.dispose();
|
||||
tamperedRequests.dispose();
|
||||
|
||||
const pinPageBytes = 64 * 1024;
|
||||
const pinGrid = createNanoVDBFloat32GridPaged(device, 4 * pinPageBytes, pinPageBytes, 2 * pinPageBytes);
|
||||
const pinPage = new ArrayBuffer(pinPageBytes);
|
||||
pinGrid.uploadPage(0, pinPage.slice(0));
|
||||
pinGrid.uploadPage(1, pinPage.slice(0));
|
||||
pinGrid.beginFrame();
|
||||
const pinnedPage0 = pinGrid.pinPage(0);
|
||||
const pinnedMissingPage = pinGrid.pinPage(3);
|
||||
pinGrid.uploadPage(2, pinPage.slice(0));
|
||||
const afterPinnedEviction = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page0: pinGrid.hasResidentPage(0),
|
||||
page1: pinGrid.hasResidentPage(1),
|
||||
page2: pinGrid.hasResidentPage(2),
|
||||
};
|
||||
pinGrid.beginFrame();
|
||||
pinGrid.pinPage(0);
|
||||
pinGrid.pinPage(2);
|
||||
let allPinnedError = "";
|
||||
try { pinGrid.uploadPage(3, pinPage.slice(0)); }
|
||||
catch (error) { allPinnedError = error instanceof Error ? error.message : String(error); }
|
||||
const afterAllPinned = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page3: pinGrid.hasResidentPage(3),
|
||||
};
|
||||
pinGrid.endFrame();
|
||||
pinGrid.beginFrame();
|
||||
pinGrid.pinPage(2);
|
||||
pinGrid.uploadPage(3, pinPage.slice(0));
|
||||
pinGrid.endFrame();
|
||||
const afterNextFrame = {
|
||||
residentVirtualPages: [...pinGrid.residentVirtualPages],
|
||||
evictionCount: pinGrid.evictionCount,
|
||||
page0: pinGrid.hasResidentPage(0),
|
||||
page2: pinGrid.hasResidentPage(2),
|
||||
page3: pinGrid.hasResidentPage(3),
|
||||
};
|
||||
const pinPageTable = [...await readWords(device, pinGrid.pageTable, 4 * Uint32Array.BYTES_PER_ELEMENT)];
|
||||
pinGrid.dispose();
|
||||
|
||||
const redrawGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
|
||||
const redrawFrames: Array<() => void> = [];
|
||||
let redrawCallbacks = 0;
|
||||
const redrawScheduler = new NanoVDBProgressiveRedrawScheduler(
|
||||
(callback) => { redrawFrames.push(callback); },
|
||||
() => { redrawCallbacks++; },
|
||||
);
|
||||
const redrawUploader = new NanoVDBProgressivePageUploader(redrawGrid, redrawScheduler);
|
||||
const firstRedrawUpload = redrawUploader.upload(0, pinPage.slice(0));
|
||||
const secondRedrawUpload = redrawUploader.upload(1, pinPage.slice(0));
|
||||
let failedRedrawUpload = "";
|
||||
try { redrawUploader.upload(3, pinPage.slice(0)); }
|
||||
catch (error) { failedRedrawUpload = error instanceof Error ? error.message : String(error); }
|
||||
const beforeFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawFrames.shift()?.();
|
||||
const afterFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
const thirdRedrawUpload = redrawUploader.upload(2, pinPage.slice(0));
|
||||
const beforeSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawFrames.shift()?.();
|
||||
const afterSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
|
||||
redrawScheduler.dispose();
|
||||
redrawGrid.dispose();
|
||||
|
||||
// A faulty producer may keep reporting an uploadable page forever. The render epoch
|
||||
// must stop scheduling after its bounded redraw budget instead of spinning indefinitely.
|
||||
const cappedGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
|
||||
const cappedFrames: Array<() => void> = [];
|
||||
let cappedCallbacks = 0;
|
||||
const cappedScheduler = new NanoVDBProgressiveRedrawScheduler(
|
||||
(callback) => { cappedFrames.push(callback); },
|
||||
() => { cappedCallbacks++; },
|
||||
{ maxRedraws: 2 },
|
||||
);
|
||||
const cappedUploader = new NanoVDBProgressivePageUploader(cappedGrid, cappedScheduler);
|
||||
const cappedUploads: Array<{ pageId: number; redrawScheduled: boolean }> = [];
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
cappedUploads.push(cappedUploader.upload(0, pinPage.slice(0)));
|
||||
cappedFrames.shift()?.();
|
||||
}
|
||||
const cappedRedraw = { uploads: cappedUploads, callbacks: cappedCallbacks, queuedFrames: cappedFrames.length, stats: cappedScheduler.stats() };
|
||||
cappedScheduler.beginRender();
|
||||
const afterCappedReset = cappedScheduler.stats();
|
||||
cappedScheduler.dispose();
|
||||
cappedGrid.dispose();
|
||||
|
||||
const missingGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const missingPageIds = [1, 2, 3];
|
||||
const overflowFeedback = createGuardedFeedback(device, 2);
|
||||
const missingOffsets = Array.from({ length: 384 }, (_value, index) => missingPageIds[index % missingPageIds.length] * FEEDBACK_STRESS_PAGE_BYTES);
|
||||
const missingWords = await readNanoVDBWordsWebGPU(device, missingGrid, missingOffsets, overflowFeedback.buffer);
|
||||
const overflowResult = await readNanoVDBPageFeedbackGPUBuffer(device, overflowFeedback.buffer, 2, stressPageCount, 18);
|
||||
const overflowWords = await readWords(device, overflowFeedback.buffer, overflowFeedback.totalBytes);
|
||||
const overflowGuard = [...overflowWords.slice(overflowFeedback.logicalBytes / 4)];
|
||||
|
||||
leafGrid.dispose();
|
||||
manifestGrid.dispose();
|
||||
missingGrid.dispose();
|
||||
leafFeedback.buffer.destroy();
|
||||
manifestFeedback.buffer.destroy();
|
||||
overflowFeedback.buffer.destroy();
|
||||
session.dispose();
|
||||
|
||||
return {
|
||||
pageCount: stressPageCount,
|
||||
manifestPageCount,
|
||||
manifestPageBytes,
|
||||
leafByteOffset,
|
||||
leafPageId,
|
||||
leafResult,
|
||||
leafGuard,
|
||||
staleDispatch,
|
||||
stalePageIoCount,
|
||||
currentDispatch,
|
||||
pageIoRequests,
|
||||
manifestFeedbackResult,
|
||||
manifestWordsZero: manifestWords.every((word) => word === 0),
|
||||
manifestStaleDispatch,
|
||||
staleManifestRangeCount,
|
||||
manifestDispatch,
|
||||
manifestPageIoRequests,
|
||||
manifestRanges,
|
||||
declaredManifestRanges,
|
||||
manifestPageMatchesPayload,
|
||||
coalesced: {
|
||||
rangeCalls: coalescedRangeCalls,
|
||||
underlyingAborts: coalescedUnderlyingAborts,
|
||||
beforeCancel: coalescedBeforeCancel,
|
||||
afterFirstCancel: coalescedAfterFirstCancel,
|
||||
afterResolve: coalescedAfterResolve,
|
||||
outcomes: coalescedOutcomes,
|
||||
consumers: coalescedConsumers,
|
||||
},
|
||||
lastCancel: {
|
||||
rangeCalls: lastCancelRangeCalls,
|
||||
underlyingAborts: lastCancelUnderlyingAborts,
|
||||
afterFirst: lastCancelAfterFirst,
|
||||
afterLast: lastCancelAfterLast,
|
||||
outcomes: lastCancelOutcomes,
|
||||
},
|
||||
tamperedPage: {
|
||||
rangeCalls: tamperedRangeCalls,
|
||||
residentWrites: tamperedResidentWrites,
|
||||
errorCode: tamperedErrorCode,
|
||||
errorMessage: tamperedErrorMessage,
|
||||
cache: tamperedCache,
|
||||
},
|
||||
framePin: {
|
||||
pinnedPage0,
|
||||
pinnedMissingPage,
|
||||
afterPinnedEviction,
|
||||
allPinnedError,
|
||||
afterAllPinned,
|
||||
afterNextFrame,
|
||||
pageTable: pinPageTable,
|
||||
},
|
||||
progressiveRedraw: {
|
||||
firstUpload: firstRedrawUpload,
|
||||
secondUpload: secondRedrawUpload,
|
||||
failedUpload: failedRedrawUpload,
|
||||
beforeFirst: beforeFirstRedraw,
|
||||
afterFirst: afterFirstRedraw,
|
||||
thirdUpload: thirdRedrawUpload,
|
||||
beforeSecond: beforeSecondRedraw,
|
||||
afterSecond: afterSecondRedraw,
|
||||
},
|
||||
progressiveRedrawCap: { cappedRedraw, afterCappedReset },
|
||||
leafSamplesFallback: leafSamples.every((sample) => sample.valid && !sample.active && sample.value === 0),
|
||||
leafSampleCount: leafSamples.length,
|
||||
overflowResult,
|
||||
overflowGuard,
|
||||
missingPageIds,
|
||||
missingWordsZero: missingWords.every((word) => word === 0),
|
||||
guardWords: [...GUARD_WORDS],
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => {
|
||||
scope.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
};
|
||||
79
web/app/src/workers/nanovdb-page-resume-test.worker.ts
Normal file
79
web/app/src/workers/nanovdb-page-resume-test.worker.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function sha256(bytes: ArrayBuffer): Promise<string> {
|
||||
const copy = bytes.slice(0);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", copy))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density) throw new Error("NANOVDB_MANIFEST_INVALID: resume fixture has no density grid");
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pagingManifest = validateNanoVDBBundleManifest({ ...manifest, gpu: { ...manifest.gpu, pageByteLength } });
|
||||
const pageId = 0;
|
||||
const pageStart = density.byteOffset + pageId * pageByteLength;
|
||||
const pageEnd = Math.min(density.byteOffset + density.byteLength, pageStart + pageByteLength);
|
||||
let interruptBody = true;
|
||||
const ranges: string[] = [];
|
||||
const ifRanges: string[] = [];
|
||||
const interruptedFetcher: typeof fetch = async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
ranges.push(headers.get("Range") ?? "");
|
||||
ifRanges.push(headers.get("If-Range") ?? "");
|
||||
const response = await fetch(input, init);
|
||||
if (!interruptBody) return response;
|
||||
interruptBody = false;
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const partial = bytes.slice(0, 4096);
|
||||
let emitted = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (!emitted) { emitted = true; controller.enqueue(partial); return; }
|
||||
controller.error(new TypeError("injected page response-body interruption"));
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: response.status, headers: response.headers });
|
||||
};
|
||||
const source = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: interruptedFetcher,
|
||||
retries: 2,
|
||||
retryDelayMs: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const coordinator = new NanoVDBGridPageRequestCoordinator(pagingManifest, density.name, source);
|
||||
const batch: NanoVDBPageFeedbackBatch = {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [pageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
};
|
||||
let consumed: ArrayBuffer | undefined;
|
||||
const dispatch = await loadNanoVDBFeedbackPages(batch, 17, coordinator, new AbortController().signal, ({ data }) => { consumed = data; });
|
||||
const direct = await (await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${pageStart}-${pageEnd - 1}` } })).arrayBuffer();
|
||||
const result = {
|
||||
dispatch,
|
||||
ranges,
|
||||
ifRanges,
|
||||
expectedRange: `bytes=${pageStart}-${pageEnd - 1}`,
|
||||
resumedRangeStart: Number(ranges[1]?.match(/^bytes=(\d+)-/)?.[1] ?? -1),
|
||||
consumedBytes: consumed?.byteLength ?? 0,
|
||||
expectedBytes: direct.byteLength,
|
||||
consumedSha256: consumed ? await sha256(consumed) : "",
|
||||
expectedSha256: await sha256(direct),
|
||||
coordinator: coordinator.stats(),
|
||||
};
|
||||
coordinator.dispose();
|
||||
scope.postMessage(result);
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
55
web/app/src/workers/nanovdb-render-golden-test.worker.ts
Normal file
55
web/app/src/workers/nanovdb-render-golden-test.worker.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
uploadNanoVDBFloat32Grid,
|
||||
type NanoVDBViewAxis,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown, transfer?: Transferable[]) => void };
|
||||
|
||||
async function sha256(value: Uint8Array): Promise<string> {
|
||||
const owned = new ArrayBuffer(value.byteLength);
|
||||
new Uint8Array(owned).set(value);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", owned))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const asset = await loadNanoVDBViewportAsset(
|
||||
"m8-19-offscreen",
|
||||
"/__vdb_fixture__/manifest",
|
||||
"/__vdb_fixture__/bundle",
|
||||
new AbortController().signal,
|
||||
);
|
||||
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid);
|
||||
const payload = asset.grids.find((grid) => grid.name === density?.name)?.data;
|
||||
if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing");
|
||||
const session = new NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(payload.byteLength, 4);
|
||||
const uploaded = uploadNanoVDBFloat32Grid(device, payload);
|
||||
const material = {
|
||||
...asset.manifest.material,
|
||||
temperatureGrid: undefined,
|
||||
colorGrid: undefined,
|
||||
emissionGrid: undefined,
|
||||
interpolation: "LINEAR" as const,
|
||||
};
|
||||
const images: Record<NanoVDBViewAxis, ArrayBuffer> = { X: new ArrayBuffer(0), Y: new ArrayBuffer(0), Z: new ArrayBuffer(0) };
|
||||
const hashes: Record<NanoVDBViewAxis, string> = { X: "", Y: "", Z: "" };
|
||||
for (const axis of ["X", "Y", "Z"] as const) {
|
||||
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis);
|
||||
hashes[axis] = await sha256(pixels);
|
||||
images[axis] = new ArrayBuffer(pixels.byteLength);
|
||||
new Uint8Array(images[axis]).set(pixels);
|
||||
}
|
||||
uploaded.dispose();
|
||||
session.dispose();
|
||||
scope.postMessage({
|
||||
sourceSha256: asset.manifest.sourceSha256,
|
||||
shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion,
|
||||
hashes,
|
||||
images,
|
||||
}, Object.values(images));
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
170
web/app/src/workers/nanovdb-sparse-performance-test.worker.ts
Normal file
170
web/app/src/workers/nanovdb-sparse-performance-test.worker.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
|
||||
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
interface RangeStats {
|
||||
rangeCalls: number;
|
||||
transferredBytes: number;
|
||||
peakRangeBytes: number;
|
||||
activeRangeBytes: number;
|
||||
abortedRequests: number;
|
||||
requestStarted: Promise<void>;
|
||||
resolveRequestStarted: () => void;
|
||||
}
|
||||
|
||||
function createRangeStats(): RangeStats {
|
||||
let resolveRequestStarted = (): void => undefined;
|
||||
const requestStarted = new Promise<void>((resolve) => { resolveRequestStarted = resolve; });
|
||||
return { rangeCalls: 0, transferredBytes: 0, peakRangeBytes: 0, activeRangeBytes: 0, abortedRequests: 0, requestStarted, resolveRequestStarted };
|
||||
}
|
||||
|
||||
function trackingFetcher(stats: RangeStats): typeof fetch {
|
||||
return async (input, init) => {
|
||||
stats.rangeCalls += 1;
|
||||
stats.resolveRequestStarted();
|
||||
const response = await fetch(input, init);
|
||||
const expectedBytes = Number(response.headers.get("Content-Length") ?? 0);
|
||||
stats.activeRangeBytes += expectedBytes;
|
||||
stats.peakRangeBytes = Math.max(stats.peakRangeBytes, stats.activeRangeBytes);
|
||||
let released = false;
|
||||
const release = (): void => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
stats.activeRangeBytes = Math.max(0, stats.activeRangeBytes - expectedBytes);
|
||||
};
|
||||
const signal = init?.signal;
|
||||
const onAbort = (): void => { stats.abortedRequests += 1; };
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (!response.body) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
return response;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
stats.transferredBytes += next.value.byteLength;
|
||||
controller.enqueue(next.value);
|
||||
}
|
||||
catch (error) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
release();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
await reader.cancel(reason);
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
||||
};
|
||||
}
|
||||
|
||||
function feedbackBatch(pageId: number): NanoVDBPageFeedbackBatch {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [pageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_sparse_64m__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
|
||||
const gridName = manifest.material.densityGrid;
|
||||
const successStats = createRangeStats();
|
||||
const successSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: trackingFetcher(successStats),
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const successCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, successSource);
|
||||
const requestedPageIds = [0, 16, 128, 192];
|
||||
const firstPageStarted = performance.now();
|
||||
let firstPageMs = 0;
|
||||
let loadedPageCount = 0;
|
||||
let pageBytes = 0;
|
||||
for (const [index, pageId] of requestedPageIds.entries()) {
|
||||
await loadNanoVDBFeedbackPages(feedbackBatch(pageId), 17, successCoordinator, new AbortController().signal, ({ data }) => {
|
||||
if (data.byteLength === 0) throw new Error("NANOVDB_STREAM_INCOMPLETE: sparse page is empty");
|
||||
pageBytes = data.byteLength;
|
||||
loadedPageCount += 1;
|
||||
});
|
||||
if (index === 0) firstPageMs = Math.round(performance.now() - firstPageStarted);
|
||||
}
|
||||
const successElapsedMs = Math.round(performance.now() - firstPageStarted);
|
||||
const successCoordinatorStats = successCoordinator.stats();
|
||||
successCoordinator.dispose();
|
||||
|
||||
const cancelStats = createRangeStats();
|
||||
const cancelSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle?delayMs=2", manifest.bundleByteLength, {
|
||||
fetcher: trackingFetcher(cancelStats),
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const cancelCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, cancelSource);
|
||||
const cancelController = new AbortController();
|
||||
let consumerCalls = 0;
|
||||
let cancelStatus = "COMPLETED";
|
||||
let cancelErrorName = "";
|
||||
const cancelStartedAt = performance.now();
|
||||
const cancelPromise = loadNanoVDBFeedbackPages(feedbackBatch(64), 17, cancelCoordinator, cancelController.signal, () => { consumerCalls += 1; });
|
||||
await cancelStats.requestStarted;
|
||||
const abortAt = performance.now();
|
||||
setTimeout(() => cancelController.abort(), 12);
|
||||
try {
|
||||
await cancelPromise;
|
||||
}
|
||||
catch (error) {
|
||||
cancelStatus = "CANCELLED";
|
||||
cancelErrorName = error instanceof DOMException ? error.name : error instanceof Error ? error.name : String(error);
|
||||
}
|
||||
const cancelLatencyMs = Math.round(performance.now() - abortAt);
|
||||
const cancelElapsedMs = Math.round(performance.now() - cancelStartedAt);
|
||||
const cancelCoordinatorStats = cancelCoordinator.stats();
|
||||
cancelCoordinator.dispose();
|
||||
scope.postMessage({
|
||||
bundleBytes: manifest.bundleByteLength,
|
||||
chunkBytes: manifest.chunks[0].byteLength,
|
||||
pageBytes,
|
||||
requestedPageIds,
|
||||
loadedPageCount,
|
||||
transferredBytes: successStats.transferredBytes,
|
||||
rangeCalls: successStats.rangeCalls,
|
||||
peakRangeBytes: successStats.peakRangeBytes,
|
||||
firstPageMs,
|
||||
successElapsedMs,
|
||||
successCoordinatorStats,
|
||||
cancel: {
|
||||
status: cancelStatus,
|
||||
errorName: cancelErrorName,
|
||||
consumerCalls,
|
||||
transferredBytes: cancelStats.transferredBytes,
|
||||
rangeCalls: cancelStats.rangeCalls,
|
||||
abortedRequests: cancelStats.abortedRequests,
|
||||
peakRangeBytes: cancelStats.peakRangeBytes,
|
||||
cancelLatencyMs,
|
||||
cancelElapsedMs,
|
||||
coordinatorStats: cancelCoordinatorStats,
|
||||
},
|
||||
});
|
||||
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
};
|
||||
87
web/app/src/workers/physics-cache-family-test.worker.ts
Normal file
87
web/app/src/workers/physics-cache-family-test.worker.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
PHYSICS_FAMILIES,
|
||||
PHYSICS_SIMULATION_BUDGET,
|
||||
parsePhysicsSimulationManifest,
|
||||
verifyPhysicsCachePayload,
|
||||
type PhysicsFamily,
|
||||
} from "../../../protocol/physics-simulation";
|
||||
|
||||
const digest = async (value: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", value)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
|
||||
async function cachedSystem(family: PhysicsFamily, index: number) {
|
||||
const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer;
|
||||
const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer;
|
||||
const settingsHash = await digest(Uint8Array.from([index + 11]).buffer);
|
||||
const cache = {
|
||||
schemaVersion: 1,
|
||||
cacheKey: `physics-${family.toLowerCase()}-1-2`,
|
||||
family,
|
||||
source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE",
|
||||
blenderVersion: "5.2.0",
|
||||
sourceBlendSha256: await digest(source),
|
||||
settingsHash,
|
||||
inputHash: await digest(Uint8Array.from([index + 21]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(payload.slice(0, 2)) },
|
||||
{ frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(payload.slice(2, 4)) },
|
||||
],
|
||||
status: "COMPLETE",
|
||||
};
|
||||
return {
|
||||
source,
|
||||
payload,
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
systems: [{
|
||||
id: `physics:${family.toLowerCase()}`,
|
||||
family,
|
||||
ownerObjectId: `object:${family}`,
|
||||
settingsHash,
|
||||
settings: { enabled: true },
|
||||
dependencyIds: [],
|
||||
cache,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function errorCode(operation: () => unknown | Promise<unknown>): Promise<string> {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
}
|
||||
|
||||
self.onmessage = async () => {
|
||||
const verified: Array<{ family: PhysicsFamily; source: string; bytes: number; frames: number }> = [];
|
||||
for (const [index, family] of PHYSICS_FAMILIES.entries()) {
|
||||
const value = await cachedSystem(family, index);
|
||||
const parsed = parsePhysicsSimulationManifest(value.manifest);
|
||||
const cache = await verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload);
|
||||
verified.push({ family, source: cache.source, bytes: cache.byteLength, frames: cache.frames.length });
|
||||
}
|
||||
|
||||
const value = await cachedSystem("FLUID", 3);
|
||||
const parsed = parsePhysicsSimulationManifest(value.manifest);
|
||||
const sourceMismatch = await errorCode(() => verifyPhysicsCachePayload(
|
||||
parsed.systems[0], Uint8Array.from([1]).buffer, value.payload,
|
||||
));
|
||||
const payloadMismatch = await errorCode(() => verifyPhysicsCachePayload(
|
||||
parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer,
|
||||
));
|
||||
const cache = value.manifest.systems[0].cache;
|
||||
const versionMismatch = await errorCode(() => parsePhysicsSimulationManifest({
|
||||
...value.manifest,
|
||||
systems: [{ ...value.manifest.systems[0], cache: { ...cache, blenderVersion: "5.3.0" } }],
|
||||
}));
|
||||
const budgetExceeded = await errorCode(() => parsePhysicsSimulationManifest({
|
||||
...value.manifest,
|
||||
systems: [{ ...value.manifest.systems[0], cache: { ...cache, byteLength: PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 } }],
|
||||
}));
|
||||
self.postMessage({ verified, sourceMismatch, payloadMismatch, versionMismatch, budgetExceeded });
|
||||
};
|
||||
@@ -18,15 +18,22 @@ const base = {
|
||||
settings: { quality: 5, usePressure: false },
|
||||
dependencyIds: ["object:Collision"],
|
||||
cache: {
|
||||
schemaVersion: 1,
|
||||
cacheKey: "cloth-cache-1-2",
|
||||
family: "CLOTH",
|
||||
source: "BLENDER_DESKTOP_BAKE",
|
||||
blenderVersion: "5.2.0",
|
||||
sourceBlendSha256: hash,
|
||||
settingsHash: hash,
|
||||
inputHash: hash,
|
||||
cacheSha256: hash,
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
cachedFrames: [1, 2],
|
||||
byteLength: 2,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 1, sha256: hash },
|
||||
{ frame: 2, byteOffset: 1, byteLength: 1, sha256: hash },
|
||||
],
|
||||
status: "COMPLETE",
|
||||
},
|
||||
};
|
||||
|
||||
48
web/app/src/workers/physics-solver-probe-test.worker.ts
Normal file
48
web/app/src/workers/physics-solver-probe-test.worker.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
PHYSICS_FAMILIES,
|
||||
gatePhysicsExecution,
|
||||
probePhysicsSolverCapabilities,
|
||||
selectPhysicsExecutionRoute,
|
||||
type PhysicsFamily,
|
||||
type PhysicsSolverInitializationIR,
|
||||
} from "../../../protocol/physics-simulation";
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
const initialization: Partial<Record<PhysicsFamily, PhysicsSolverInitializationIR>> = {
|
||||
RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib },
|
||||
FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib },
|
||||
DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN },
|
||||
};
|
||||
|
||||
self.onmessage = async () => {
|
||||
const defaultCapabilities = await probePhysicsSolverCapabilities(undefined, {
|
||||
threadMode: "PTHREAD",
|
||||
memoryLimitBytes: 2_048 * mib,
|
||||
});
|
||||
const capabilities = await probePhysicsSolverCapabilities({
|
||||
hasFamilyExport: (family) => family !== "HAIR",
|
||||
initializeFamily: async (family) => {
|
||||
if (family === "PARTICLE") throw new Error("synthetic initialization failure");
|
||||
return initialization[family] as PhysicsSolverInitializationIR;
|
||||
},
|
||||
}, {
|
||||
threadMode: "SINGLE",
|
||||
memoryLimitBytes: 256 * mib,
|
||||
});
|
||||
const localGate = gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities);
|
||||
const fallbackGate = gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities);
|
||||
self.postMessage({
|
||||
familyOrder: [...PHYSICS_FAMILIES],
|
||||
defaultProbe: defaultCapabilities.map((entry) => entry.solverProbe),
|
||||
probes: Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])),
|
||||
routes: Object.fromEntries(capabilities.map((entry) => [entry.family, selectPhysicsExecutionRoute(entry.family, capabilities).mode])),
|
||||
localGate: { status: localGate.status, issues: localGate.issues.length },
|
||||
fallbackGate: {
|
||||
status: fallbackGate.status,
|
||||
code: fallbackGate.issues[0]?.code,
|
||||
message: fallbackGate.issues[0]?.message,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -42,7 +42,20 @@ self.onmessage = () => {
|
||||
parseSequencerTimeline({ ...base, strips: new Array(SEQUENCER_BUDGET.maxStrips + 1).fill(movie) });
|
||||
}
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
|
||||
const codecRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
stripType: "MOVIE" as const,
|
||||
mimeType: "video/mp4",
|
||||
byteLength: 1,
|
||||
sourceSha256: "0".repeat(64),
|
||||
};
|
||||
result.codec = gateSequencerCodec(codecRequest, {
|
||||
...codecRequest,
|
||||
status: "BLOCKED",
|
||||
backend: null,
|
||||
reason: "RUNTIME_UNAVAILABLE",
|
||||
decoded: null,
|
||||
}).issues[0]?.code;
|
||||
const transitionTimeline = { ...base, strips: [
|
||||
{ id: "strip:From", name: "From", type: "SCENE", channel: 1, frameStart: 10, frameEnd: 20, sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false },
|
||||
{ id: "strip:To", name: "To", type: "SCENE", channel: 2, frameStart: 10, frameEnd: 20, sourceStart: 200, sourceEnd: 210, speed: 1, muted: false, locked: false },
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheQuarantineIssue, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
|
||||
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { planSimulationCacheLRU, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCacheCancellable, verifySimulationCacheFrame, verifySimulationCacheRevisionBinding, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { deleteContentAsset, deleteLodCache, deleteProjectSnapshot, ensureProjectLayout, measureProjectVDBBytes, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, readProjectSnapshot, recoverProjectBlend, removeOrphanContentAssets, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, writeProjectSnapshot, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { classifyRecentProjectIdentity, createRecentProjectIndex, parseRecentProjectIndex, parseRecentProjectRecord, removeRecentProject, upsertRecentProject, type RecentProjectIdentity, type RecentProjectIndex, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects";
|
||||
import { createStorageBudget } from "../../../protocol/storage-budget";
|
||||
import { applyUdimTilePatch } from "../../../protocol/paint";
|
||||
import {
|
||||
texturePaintTileBindingKey,
|
||||
validateTexturePaintTileBindingRequest,
|
||||
validateTexturePaintTileCommit,
|
||||
type TexturePaintTileBindingIR,
|
||||
type TexturePaintTileBindingRequestIR,
|
||||
type TexturePaintTileBindingResultIR,
|
||||
type TexturePaintTileCommitIR,
|
||||
type TexturePaintTileCommitResultIR,
|
||||
} from "../../../protocol/texture-paint-asset";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
|
||||
@@ -11,7 +24,33 @@ const scope = self as unknown as {
|
||||
};
|
||||
|
||||
const projectTransactions = new Map<string, Promise<void>>();
|
||||
const simulationPlaybackReady = new Set<string>();
|
||||
const simulationPlaybackActive = new Set<string>();
|
||||
const cancelledRequests = new Set<string>();
|
||||
let recentProjectsTransaction: Promise<void> = Promise.resolve();
|
||||
let opfsUsable: boolean | undefined;
|
||||
const RECENT_PROJECTS_SETTING_ID = "recent-projects:v1";
|
||||
|
||||
function storageError(code: NonNullable<StorageResponse["errorCode"]>, message: string): Error & { code: NonNullable<StorageResponse["errorCode"]> } {
|
||||
const error = new Error(`${code}: ${message}`) as Error & { code: NonNullable<StorageResponse["errorCode"]> };
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function simulationSessionKey(projectId: string, cacheKey: string): string {
|
||||
return `${projectId}:${cacheKey}`;
|
||||
}
|
||||
|
||||
function throwIfCancelled(requestId: string): void {
|
||||
if (cancelledRequests.has(requestId)) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_CANCELLED", "Simulation cache operation was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRequest(requestId: string): void {
|
||||
cancelledRequests.add(requestId);
|
||||
setTimeout(() => cancelledRequests.delete(requestId), 30_000);
|
||||
}
|
||||
|
||||
interface WorkerLockManager {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
||||
@@ -51,6 +90,15 @@ async function withProjectTransaction<T>(projectId: string, operation: () => Pro
|
||||
}
|
||||
}
|
||||
|
||||
async function withRecentProjectsTransaction<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = recentProjectsTransaction.catch(() => undefined).then(() => {
|
||||
const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks;
|
||||
return locks ? locks.request("blender-web-recent-projects", operation) : operation();
|
||||
});
|
||||
recentProjectsTransaction = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION);
|
||||
@@ -101,6 +149,13 @@ interface SimulationManifestRow {
|
||||
manifest: SimulationCacheManifestIR;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
lastAccessAt: string;
|
||||
}
|
||||
|
||||
interface SimulationQuarantineRow extends StorageSimulationCacheQuarantineIssue {
|
||||
id: string;
|
||||
projectId: string;
|
||||
row: SimulationManifestRow;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
@@ -114,6 +169,100 @@ interface ProjectRow {
|
||||
buffer?: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface SettingRow {
|
||||
id: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
async function readRecentProjectIndex(): Promise<{ index: RecentProjectIndex; quarantined: number }> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readonly");
|
||||
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get(RECENT_PROJECTS_SETTING_ID);
|
||||
request.onsuccess = () => resolve(request.result as SettingRow | undefined);
|
||||
request.onerror = () => reject(request.error ?? new Error("Recent project index lookup failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const parsed = row ? parseRecentProjectIndex(row.value) : { index: createRecentProjectIndex(), quarantined: 0 };
|
||||
if (parsed.quarantined > 0) {
|
||||
const cleaned = await openDatabase();
|
||||
const write = cleaned.transaction("setting", "readwrite");
|
||||
write.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: parsed.index });
|
||||
await transactionComplete(write);
|
||||
cleaned.close();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function writeRecentProjectIndex(index: RecentProjectIndex): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: index });
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function readCommittedProjectIdentity(projectId: string, row: ProjectRow): Promise<RecentProjectIdentity | undefined> {
|
||||
if (row.backend === "indexeddb") {
|
||||
if (!row.buffer || row.buffer.byteLength !== row.bytes) return undefined;
|
||||
const sha256 = await sha256Hex(row.buffer);
|
||||
return { revision: row.revision, bytes: row.bytes, sha256 };
|
||||
}
|
||||
if (!await useOpfsForProject(projectId)) return { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
|
||||
const recovery = await recoverProjectBlend(projectId);
|
||||
if (!recovery.manifest) return undefined;
|
||||
return { revision: recovery.manifest.revision, bytes: recovery.manifest.bytes, sha256: recovery.manifest.sha256 };
|
||||
}
|
||||
|
||||
async function inspectRecentProject(project: RecentProjectRecord): Promise<RecentProjectIssue | undefined> {
|
||||
// Records created while storage was unavailable cannot be checked against a committed backend.
|
||||
if (project.backend === "unknown") return undefined;
|
||||
try {
|
||||
const row = await readProjectRow(project.projectId);
|
||||
if (!row) return { project, code: "MISSING" };
|
||||
const rowIdentity = { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
|
||||
const committed = await readCommittedProjectIdentity(project.projectId, row);
|
||||
const metadataIssue = classifyRecentProjectIdentity(rowIdentity, committed);
|
||||
if (metadataIssue) return { project, code: metadataIssue };
|
||||
const issue = classifyRecentProjectIdentity(project, committed);
|
||||
return issue ? { project, code: issue } : undefined;
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return { project, code: /integrity|hash|digest|mismatch/i.test(message) ? "HASH_MISMATCH" : "MISSING" };
|
||||
}
|
||||
}
|
||||
|
||||
async function listRecentProjects(): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
const parsed = await readRecentProjectIndex();
|
||||
const projects: RecentProjectRecord[] = [];
|
||||
const issues: RecentProjectIssue[] = [];
|
||||
for (const project of parsed.index.projects) {
|
||||
const issue = await inspectRecentProject(project);
|
||||
if (issue) issues.push(issue);
|
||||
else projects.push(project);
|
||||
}
|
||||
return { projects, quarantined: parsed.quarantined, issues };
|
||||
}
|
||||
|
||||
async function touchRecentProject(project: RecentProjectRecord): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
const parsed = parseRecentProjectRecord(project);
|
||||
if (!parsed) throw new Error("RECENT_PROJECT_INVALID");
|
||||
const current = await readRecentProjectIndex();
|
||||
const next = upsertRecentProject(current.index, parsed);
|
||||
await writeRecentProjectIndex(next);
|
||||
return listRecentProjects();
|
||||
}
|
||||
|
||||
async function removeRecentProjectEntry(projectId: string): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
|
||||
projectLayout(projectId);
|
||||
const current = await readRecentProjectIndex();
|
||||
const next = removeRecentProject(current.index, projectId);
|
||||
await writeRecentProjectIndex(next);
|
||||
return listRecentProjects();
|
||||
}
|
||||
|
||||
async function readProjectRow(projectId: string): Promise<ProjectRow | undefined> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
@@ -162,6 +311,58 @@ async function readSimulationRow(projectId: string, cacheKey: string): Promise<S
|
||||
return row;
|
||||
}
|
||||
|
||||
function simulationIssue(error: unknown): { code: NonNullable<StorageResponse["errorCode"]>; reason: string } {
|
||||
const code = error && typeof error === "object" && "code" in error ?
|
||||
(error as { code?: StorageResponse["errorCode"] }).code : undefined;
|
||||
const simulationCode = code === "SIMULATION_CACHE_INVALID" || code === "SIMULATION_CACHE_MISSING" ||
|
||||
code === "SIMULATION_CACHE_HASH_MISMATCH" ? code : "SIMULATION_CACHE_INVALID";
|
||||
return {
|
||||
code: simulationCode,
|
||||
reason: error instanceof Error ? error.message : "Simulation cache validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
async function quarantineSimulationCache(
|
||||
row: SimulationManifestRow,
|
||||
error: unknown,
|
||||
): Promise<StorageSimulationCacheQuarantineIssue> {
|
||||
const issue = simulationIssue(error);
|
||||
const quarantinedAt = new Date().toISOString();
|
||||
const quarantine: SimulationQuarantineRow = {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
cacheKey: row.cacheKey,
|
||||
code: issue.code,
|
||||
reason: issue.reason,
|
||||
quarantinedAt,
|
||||
row,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
|
||||
transaction.objectStore("simulation_manifest").delete(row.id);
|
||||
transaction.objectStore("simulation_quarantine").put(quarantine);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const sessionKey = simulationSessionKey(row.projectId, row.cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
return { cacheKey: row.cacheKey, code: issue.code, reason: issue.reason, quarantinedAt };
|
||||
}
|
||||
|
||||
async function listSimulationQuarantine(projectId: string): Promise<StorageSimulationCacheQuarantineIssue[]> {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_quarantine", "readonly");
|
||||
const rows = await new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
|
||||
const request = transaction.objectStore("simulation_quarantine").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine scan failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return rows.map(({ cacheKey, code, reason, quarantinedAt }) => ({ cacheKey, code, reason, quarantinedAt }))
|
||||
.sort((left, right) => left.quarantinedAt.localeCompare(right.quarantinedAt) || left.cacheKey.localeCompare(right.cacheKey));
|
||||
}
|
||||
|
||||
async function smoke() {
|
||||
const db = await openDatabase();
|
||||
const write = db.transaction("smoke", "readwrite");
|
||||
@@ -245,7 +446,7 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
|
||||
if (faultAt && existing && previousBuffer) {
|
||||
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
|
||||
if (rollback.manifest.sha256 !== existing.sha256) {
|
||||
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
|
||||
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored", { cause: error });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
@@ -414,7 +615,12 @@ async function pruneOperations(projectId: string, throughRevision: number): Prom
|
||||
return { projectId, throughRevision, removed: remove.length };
|
||||
}
|
||||
|
||||
interface SnapshotRow extends StorageSnapshotResult { id: string; buffer: ArrayBuffer }
|
||||
interface SnapshotRow extends StorageSnapshotResult {
|
||||
id: string;
|
||||
backend?: "indexeddb" | "opfs";
|
||||
path?: string;
|
||||
buffer?: ArrayBuffer;
|
||||
}
|
||||
|
||||
async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise<StorageSnapshotResult> {
|
||||
projectLayout(projectId);
|
||||
@@ -422,25 +628,59 @@ async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBu
|
||||
throw new Error("Invalid snapshot retention request");
|
||||
}
|
||||
const sha256 = await sha256Hex(buffer);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("snapshot", "readwrite");
|
||||
const store = transaction.objectStore("snapshot");
|
||||
const readDb = await openDatabase();
|
||||
const readTransaction = readDb.transaction("snapshot", "readonly");
|
||||
const rows = await new Promise<SnapshotRow[]>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
const request = readTransaction.objectStore("snapshot").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed"));
|
||||
});
|
||||
await transactionComplete(readTransaction);
|
||||
readDb.close();
|
||||
|
||||
const existing = rows.find((candidate) => candidate.revision === revision);
|
||||
if (existing?.bytes === buffer.byteLength && existing.sha256 === sha256) {
|
||||
const existingBuffer = existing.backend === "opfs"
|
||||
? await readProjectSnapshot(projectId, revision)
|
||||
: existing.buffer;
|
||||
if (existingBuffer?.byteLength === existing.bytes && await sha256Hex(existingBuffer) === sha256) {
|
||||
return { projectId, revision, bytes: existing.bytes, sha256, createdAt: existing.createdAt, persisted: true };
|
||||
}
|
||||
}
|
||||
|
||||
const useOpfs = await useOpfsForProject(projectId);
|
||||
const opfs = useOpfs ? await writeProjectSnapshot(projectId, revision, buffer) : undefined;
|
||||
const createdAt = new Date().toISOString();
|
||||
const row: SnapshotRow = { id: `${projectId}:${revision}`, projectId, revision, bytes: buffer.byteLength, sha256, createdAt, persisted: true, buffer: buffer.slice(0) };
|
||||
store.put(row);
|
||||
const row: SnapshotRow = {
|
||||
id: `${projectId}:${revision}`,
|
||||
projectId,
|
||||
revision,
|
||||
bytes: buffer.byteLength,
|
||||
sha256,
|
||||
createdAt,
|
||||
persisted: true,
|
||||
backend: useOpfs ? "opfs" : "indexeddb",
|
||||
path: opfs?.path,
|
||||
buffer: useOpfs ? undefined : buffer.slice(0),
|
||||
};
|
||||
const retained = [...rows.filter((candidate) => candidate.revision !== revision), row].sort((left, right) => right.revision - left.revision);
|
||||
let total = 0;
|
||||
const removed: SnapshotRow[] = [];
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("snapshot", "readwrite");
|
||||
const store = transaction.objectStore("snapshot");
|
||||
store.put(row);
|
||||
for (let index = 0; index < retained.length; index++) {
|
||||
total += retained[index].bytes;
|
||||
if (index >= maxCount || total > maxBytes) store.delete(retained[index].id);
|
||||
if (index >= maxCount || total > maxBytes) {
|
||||
store.delete(retained[index].id);
|
||||
removed.push(retained[index]);
|
||||
}
|
||||
}
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
await Promise.all(removed.filter((candidate) => candidate.backend === "opfs" && candidate.revision !== revision)
|
||||
.map((candidate) => deleteProjectSnapshot(projectId, candidate.revision)));
|
||||
return { projectId, revision, bytes: row.bytes, sha256, createdAt, persisted: true };
|
||||
}
|
||||
|
||||
@@ -469,8 +709,10 @@ async function readSnapshot(projectId: string, revision: number): Promise<Storag
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
if (!row || row.buffer.byteLength !== row.bytes || await sha256Hex(row.buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
|
||||
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: row.buffer.slice(0) };
|
||||
if (!row) throw new Error("Snapshot integrity check failed");
|
||||
const buffer = row.backend === "opfs" ? await readProjectSnapshot(projectId, revision) : row.buffer;
|
||||
if (!buffer || buffer.byteLength !== row.bytes || await sha256Hex(buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
|
||||
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: buffer.slice(0) };
|
||||
}
|
||||
|
||||
interface AssetRow extends StorageAssetRecord {
|
||||
@@ -553,10 +795,19 @@ async function readAsset(projectId: string, sha256: string): Promise<StorageAsse
|
||||
projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
const row = await readAssetRow(projectId, sha256);
|
||||
if (!row) throw new Error("Content-addressed asset metadata is missing");
|
||||
const data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
|
||||
if (!row) throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset metadata is missing");
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") {
|
||||
throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset bytes are missing");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (data.byteLength !== row.bytes || await sha256Hex(data) !== sha256) {
|
||||
throw new Error("Content-addressed asset integrity check failed");
|
||||
throw storageError("ASSET_SOURCE_HASH_MISMATCH", "content-addressed asset integrity check failed");
|
||||
}
|
||||
const lastAccessAt = new Date().toISOString();
|
||||
const db = await openDatabase();
|
||||
@@ -584,6 +835,115 @@ async function listAssets(projectId: string): Promise<StorageAssetListResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function readTexturePaintTileBindingRow(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingIR | undefined> {
|
||||
const request = validateTexturePaintTileBindingRequest(requestValue);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readonly");
|
||||
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
|
||||
const lookup = transaction.objectStore("setting").get(texturePaintTileBindingKey(request));
|
||||
lookup.onsuccess = () => resolve(lookup.result as SettingRow | undefined);
|
||||
lookup.onerror = () => reject(lookup.error ?? new Error("Texture paint tile binding lookup failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return row?.value as TexturePaintTileBindingIR | undefined;
|
||||
}
|
||||
|
||||
async function readTexturePaintTileBinding(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
|
||||
const request = validateTexturePaintTileBindingRequest(requestValue);
|
||||
return { projectId: request.projectId, binding: await readTexturePaintTileBindingRow(request) };
|
||||
}
|
||||
|
||||
async function decodePackedPng(data: ArrayBuffer, width: number, height: number): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
|
||||
try {
|
||||
if (bitmap.width !== width || bitmap.height !== height) throw new Error("PAINT_SCHEMA_INVALID: Packed texture dimensions do not match the dirty tile target");
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint decode canvas is unavailable");
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return new Uint8Array(context.getImageData(0, 0, width, height).data);
|
||||
}
|
||||
finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function encodePackedPng(pixels: Uint8Array, width: number, height: number): Promise<ArrayBuffer> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint encode canvas is unavailable");
|
||||
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0);
|
||||
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
|
||||
}
|
||||
|
||||
async function commitTexturePaintTile(commitValue: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
|
||||
const commit = validateTexturePaintTileCommit(commitValue);
|
||||
const target = commit.target;
|
||||
const bindingRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
projectId: target.projectId,
|
||||
textureAssetId: target.textureAssetId,
|
||||
tile: target.tile,
|
||||
};
|
||||
const previous = await readTexturePaintTileBindingRow(bindingRequest);
|
||||
if (previous && (previous.imageId !== target.imageId || previous.kind !== target.kind || previous.width !== target.width ||
|
||||
previous.height !== target.height || previous.mimeType !== target.mimeType || previous.colorSpace !== target.colorSpace)) {
|
||||
throw new Error("PAINT_SCHEMA_INVALID: Texture paint tile target does not match its published binding");
|
||||
}
|
||||
const expectedBase = previous?.assetSha256 ?? target.baseAssetSha256;
|
||||
if (target.baseAssetSha256 !== expectedBase) throw new Error("PAINT_TILE_HASH_MISMATCH: Texture paint base asset binding is stale");
|
||||
const base = await readAsset(target.projectId, expectedBase);
|
||||
if (base.asset.mimeType !== "image/png") throw new Error("PAINT_SCHEMA_INVALID: Texture paint base asset is not a packed PNG");
|
||||
const basePixels = await decodePackedPng(base.data, target.width, target.height);
|
||||
const nextPixels = await applyUdimTilePatch(basePixels, commit.patch, target.revision);
|
||||
const nextData = await encodePackedPng(nextPixels, target.width, target.height);
|
||||
if (commit.faultAt === "before-asset-write") throw new Error("STORAGE_TRANSACTION: injected before texture tile asset write");
|
||||
const stored = await putAsset(target.projectId, nextData, target.mimeType, target.sourcePath);
|
||||
if (commit.faultAt === "after-asset-write" || commit.faultAt === "before-binding-commit") {
|
||||
throw new Error("STORAGE_TRANSACTION: injected before texture tile binding commit");
|
||||
}
|
||||
const binding: TexturePaintTileBindingIR = {
|
||||
schemaVersion: 1,
|
||||
projectId: target.projectId,
|
||||
imageId: target.imageId,
|
||||
textureAssetId: target.textureAssetId,
|
||||
kind: target.kind,
|
||||
tile: target.tile,
|
||||
revision: target.revision,
|
||||
generation: (previous?.generation ?? 0) + 1,
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
mimeType: target.mimeType,
|
||||
colorSpace: target.colorSpace,
|
||||
sourcePath: target.sourcePath,
|
||||
assetId: stored.assetId,
|
||||
assetSha256: stored.sha256,
|
||||
pixelSha256: commit.patch.resultSha256,
|
||||
bytes: stored.bytes,
|
||||
path: stored.path,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: texturePaintTileBindingKey(bindingRequest), value: binding });
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const published = await readTexturePaintTileBindingRow(bindingRequest);
|
||||
if (!published || published.assetSha256 !== stored.sha256 || published.pixelSha256 !== commit.patch.resultSha256) {
|
||||
throw new Error("STORAGE_TRANSACTION: Texture paint tile binding verification failed");
|
||||
}
|
||||
await readAsset(target.projectId, published.assetSha256);
|
||||
return {
|
||||
projectId: target.projectId,
|
||||
persisted: true,
|
||||
binding: published,
|
||||
previousAssetSha256: expectedBase,
|
||||
orphanedAssetPossible: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
|
||||
const layout = await writeLodCache(projectId, cacheKey, data);
|
||||
const existing = await readLODRow(projectId, cacheKey);
|
||||
@@ -721,82 +1081,213 @@ async function putSimulationCache(
|
||||
projectId: string,
|
||||
manifestValue: SimulationCacheManifestIR,
|
||||
data: ArrayBuffer,
|
||||
requestId: string,
|
||||
): Promise<StorageSimulationCacheResult> {
|
||||
projectLayout(projectId);
|
||||
const manifest = await verifySimulationCache(manifestValue, data);
|
||||
throwIfCancelled(requestId);
|
||||
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
|
||||
throwIfCancelled(requestId);
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
|
||||
}
|
||||
if (project.revision !== manifest.sourceRevision) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache source revision does not match the committed project");
|
||||
}
|
||||
if (project.sha256 !== manifest.sourceBlendSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
|
||||
}
|
||||
await verifySimulationCacheCancellable(manifest, data, () => throwIfCancelled(requestId));
|
||||
const cacheKey = simulationCacheKey(manifest);
|
||||
const existing = await readSimulationRow(projectId, cacheKey);
|
||||
if (existing && existing.manifest.cacheSha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache key already references a different payload");
|
||||
}
|
||||
const asset = await putAsset(
|
||||
projectId,
|
||||
data,
|
||||
"application/x-blender-simulation-cache",
|
||||
`simulation/${cacheKey}.bin`,
|
||||
);
|
||||
if (asset.sha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
|
||||
let asset: StorageAssetPutResult | undefined;
|
||||
try {
|
||||
asset = await putAsset(
|
||||
projectId,
|
||||
data,
|
||||
"application/x-blender-simulation-cache",
|
||||
`simulation/${cacheKey}.bin`,
|
||||
);
|
||||
if (asset.sha256 !== manifest.cacheSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
|
||||
}
|
||||
throwIfCancelled(requestId);
|
||||
const now = new Date().toISOString();
|
||||
const row: SimulationManifestRow = {
|
||||
id: `${projectId}:${cacheKey}`,
|
||||
projectId,
|
||||
cacheKey,
|
||||
manifest,
|
||||
path: asset.path,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
|
||||
transaction.objectStore("simulation_manifest").put(row);
|
||||
transaction.objectStore("simulation_quarantine").delete(row.id);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
|
||||
}
|
||||
const row: SimulationManifestRow = {
|
||||
id: `${projectId}:${cacheKey}`,
|
||||
projectId,
|
||||
cacheKey,
|
||||
manifest,
|
||||
path: asset.path,
|
||||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
catch (error) {
|
||||
if (!existing && asset && error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") {
|
||||
await deleteUnreferencedSimulationAssets(projectId, new Set([asset.sha256]));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requireSimulationCacheProjectIdentity(
|
||||
projectId: string,
|
||||
manifest: SimulationCacheManifestIR,
|
||||
): Promise<ProjectRow> {
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
|
||||
}
|
||||
if (project.revision !== manifest.sourceRevision) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache is stale for the committed project revision");
|
||||
}
|
||||
if (project.sha256 !== manifest.sourceBlendSha256) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function requireSimulationCacheRow(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
): Promise<{ row: SimulationManifestRow; manifest: SimulationCacheManifestIR }> {
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
let manifest: SimulationCacheManifestIR;
|
||||
try {
|
||||
manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
throw error;
|
||||
}
|
||||
await requireSimulationCacheProjectIdentity(projectId, manifest);
|
||||
return { row, manifest };
|
||||
}
|
||||
|
||||
function simulationPayloadError(error: unknown): SimulationCacheValidationError {
|
||||
if (error instanceof SimulationCacheValidationError) return error;
|
||||
const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined;
|
||||
if (code === "NON_MESH_RESOURCE_MISSING") {
|
||||
return new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing");
|
||||
}
|
||||
return new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload failed integrity verification");
|
||||
}
|
||||
|
||||
async function readVerifiedSimulationPayload(
|
||||
row: SimulationManifestRow,
|
||||
manifest: SimulationCacheManifestIR,
|
||||
requestId: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
try {
|
||||
throwIfCancelled(requestId);
|
||||
const asset = await readAsset(row.projectId, manifest.cacheSha256);
|
||||
await verifySimulationCacheCancellable(manifest, asset.data, () => throwIfCancelled(requestId));
|
||||
return asset.data;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
|
||||
const normalized = simulationPayloadError(error);
|
||||
await quarantineSimulationCache(row, normalized);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async function touchSimulationCache(row: SimulationManifestRow): Promise<SimulationManifestRow> {
|
||||
const updated = { ...row, lastAccessAt: new Date().toISOString() };
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readwrite");
|
||||
transaction.objectStore("simulation_manifest").put(row);
|
||||
transaction.objectStore("simulation_manifest").put(updated);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
|
||||
async function readSimulationCache(projectId: string, cacheKey: string, requestId: string): Promise<StorageSimulationCacheReadResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
const asset = await readAsset(projectId, manifest.cacheSha256);
|
||||
await verifySimulationCache(manifest, asset.data);
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
const data = await readVerifiedSimulationPayload(row, manifest, requestId);
|
||||
const touched = await touchSimulationCache(row);
|
||||
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, data };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
async function prepareSimulationCachePlayback(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
requestId: string,
|
||||
): Promise<StorageSimulationCachePlaybackReadyResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
await readVerifiedSimulationPayload(row, manifest, requestId);
|
||||
throwIfCancelled(requestId);
|
||||
const touched = await touchSimulationCache(row);
|
||||
const sessionKey = simulationSessionKey(projectId, cacheKey);
|
||||
simulationPlaybackReady.add(sessionKey);
|
||||
simulationPlaybackActive.add(sessionKey);
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, verifiedAt: touched.lastAccessAt };
|
||||
}
|
||||
|
||||
async function releaseSimulationCachePlayback(
|
||||
projectId: string,
|
||||
cacheKey: string,
|
||||
): Promise<StorageSimulationCachePlaybackReleaseResult> {
|
||||
projectLayout(projectId);
|
||||
const sessionKey = simulationSessionKey(projectId, cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
return { projectId, cacheKey, released: true };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, requestId: string): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
projectLayout(projectId);
|
||||
throwIfCancelled(requestId);
|
||||
if (!simulationPlaybackReady.has(simulationSessionKey(projectId, cacheKey))) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_NOT_READY", "Simulation cache playback requires full verification in the current Worker");
|
||||
}
|
||||
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
|
||||
const selected = selectSimulationCacheFrame(manifest, frame);
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
}
|
||||
throwIfCancelled(requestId);
|
||||
data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
throwIfCancelled(requestId);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
}
|
||||
const data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
catch (error) {
|
||||
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
|
||||
const normalized = simulationPayloadError(error);
|
||||
await quarantineSimulationCache(row, normalized);
|
||||
throw normalized;
|
||||
}
|
||||
const touched = await touchSimulationCache(row);
|
||||
return {
|
||||
projectId,
|
||||
cacheKey,
|
||||
persisted: true,
|
||||
manifest,
|
||||
path: row.path,
|
||||
path: touched.path,
|
||||
frame,
|
||||
byteOffset: selected.byteOffset,
|
||||
byteLength: selected.byteLength,
|
||||
@@ -806,6 +1297,10 @@ async function readSimulationCacheFrame(projectId: string, cacheKey: string, fra
|
||||
|
||||
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
projectLayout(projectId);
|
||||
const project = await readProjectRow(projectId);
|
||||
if (!project) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache listing requires a committed source blend");
|
||||
}
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readonly");
|
||||
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
@@ -815,22 +1310,184 @@ async function listSimulationCaches(projectId: string): Promise<StorageSimulatio
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const caches = rows.map((row) => {
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
const caches: StorageSimulationCacheListResult["caches"] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
}
|
||||
if (manifest.sourceRevision === project.revision && manifest.sourceBlendSha256 === project.sha256) {
|
||||
caches.push({
|
||||
cacheKey: row.cacheKey,
|
||||
manifest,
|
||||
path: row.path,
|
||||
createdAt: row.createdAt,
|
||||
lastAccessAt: row.lastAccessAt ?? row.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { cacheKey: row.cacheKey, manifest, path: row.path, createdAt: row.createdAt };
|
||||
}).sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
|
||||
return { projectId, caches };
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
}
|
||||
}
|
||||
caches.sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
|
||||
const issues = await listSimulationQuarantine(projectId);
|
||||
return { projectId, caches, quarantined: issues.length, issues };
|
||||
}
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
async function deleteUnreferencedSimulationAssets(projectId: string, sha256Values: ReadonlySet<string>): Promise<void> {
|
||||
if (sha256Values.size === 0) return;
|
||||
const db = await openDatabase();
|
||||
const readTransaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readonly");
|
||||
const rowsPromise = new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
const request = readTransaction.objectStore("simulation_manifest").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation manifest reference scan failed"));
|
||||
});
|
||||
const quarantinePromise = new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
|
||||
const request = readTransaction.objectStore("simulation_quarantine").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine reference scan failed"));
|
||||
});
|
||||
const [rows, quarantine] = await Promise.all([rowsPromise, quarantinePromise]);
|
||||
await transactionComplete(readTransaction);
|
||||
const referenced = new Set([
|
||||
...rows.map((row) => row.manifest.cacheSha256),
|
||||
...quarantine.map((row) => row.row.manifest.cacheSha256),
|
||||
]);
|
||||
const removable: string[] = [];
|
||||
for (const sha256 of sha256Values) {
|
||||
if (referenced.has(sha256)) continue;
|
||||
const asset = await readAssetRow(projectId, sha256);
|
||||
if (asset?.mimeType === "application/x-blender-simulation-cache") removable.push(sha256);
|
||||
}
|
||||
if (removable.length > 0) {
|
||||
const transaction = db.transaction("asset", "readwrite");
|
||||
const store = transaction.objectStore("asset");
|
||||
removable.forEach((sha256) => store.delete(`${projectId}:${sha256}`));
|
||||
await transactionComplete(transaction);
|
||||
}
|
||||
db.close();
|
||||
await Promise.all(removable.map((sha256) => deleteContentAsset(projectId, sha256)));
|
||||
}
|
||||
|
||||
async function pruneSimulationCaches(
|
||||
projectId: string,
|
||||
maxBytes: number,
|
||||
protectedCacheKeys: readonly string[] = [],
|
||||
): Promise<StorageSimulationCachePruneResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction("simulation_manifest", "readonly");
|
||||
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
|
||||
const request = transaction.objectStore("simulation_manifest").getAll();
|
||||
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
|
||||
request.onerror = () => reject(request.error ?? new Error("Simulation manifest LRU scan failed"));
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const validRows: SimulationManifestRow[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== row.cacheKey) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
|
||||
validRows.push({ ...row, manifest, lastAccessAt: row.lastAccessAt ?? row.createdAt });
|
||||
}
|
||||
catch (error) {
|
||||
await quarantineSimulationCache(row, error);
|
||||
}
|
||||
}
|
||||
const activeKeys = [...simulationPlaybackActive].filter((key) => key.startsWith(`${projectId}:`)).map((key) => key.slice(projectId.length + 1));
|
||||
const plan = planSimulationCacheLRU(
|
||||
validRows.map((row) => ({ cacheKey: row.cacheKey, byteLength: row.manifest.byteLength, createdAt: row.createdAt, lastAccessAt: row.lastAccessAt })),
|
||||
maxBytes,
|
||||
[...protectedCacheKeys, ...activeKeys],
|
||||
);
|
||||
const removedRows = validRows.filter((row) => plan.cacheKeys.includes(row.cacheKey));
|
||||
if (removedRows.length > 0) {
|
||||
const writeDb = await openDatabase();
|
||||
const write = writeDb.transaction("simulation_manifest", "readwrite");
|
||||
const store = write.objectStore("simulation_manifest");
|
||||
removedRows.forEach((row) => store.delete(row.id));
|
||||
await transactionComplete(write);
|
||||
writeDb.close();
|
||||
for (const row of removedRows) {
|
||||
const sessionKey = simulationSessionKey(projectId, row.cacheKey);
|
||||
simulationPlaybackReady.delete(sessionKey);
|
||||
simulationPlaybackActive.delete(sessionKey);
|
||||
}
|
||||
await deleteUnreferencedSimulationAssets(projectId, new Set(removedRows.map((row) => row.manifest.cacheSha256)));
|
||||
}
|
||||
return {
|
||||
projectId,
|
||||
maxBytes: plan.maxBytes,
|
||||
beforeBytes: plan.beforeBytes,
|
||||
remainingBytes: plan.remainingBytes,
|
||||
removedBytes: plan.removedBytes,
|
||||
removed: plan.cacheKeys.length,
|
||||
cacheKeys: plan.cacheKeys,
|
||||
protectedCacheKeys: plan.protectedCacheKeys,
|
||||
budgetSatisfied: plan.budgetSatisfied,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStorageBudget(projectId: string): Promise<StorageBudgetResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(["project", "snapshot", "lod_manifest", "asset"], "readonly");
|
||||
const getAll = <T>(storeName: "snapshot" | "lod_manifest" | "asset"): Promise<T[]> => new Promise((resolve, reject) => {
|
||||
const request = transaction.objectStore(storeName).getAll();
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
request.onerror = () => reject(request.error ?? new Error(`Storage budget ${storeName} scan failed`));
|
||||
});
|
||||
const projectRequest = transaction.objectStore("project").get(projectId);
|
||||
const projectPromise = new Promise<ProjectRow | undefined>((resolve, reject) => {
|
||||
projectRequest.onsuccess = () => resolve(projectRequest.result as ProjectRow | undefined);
|
||||
projectRequest.onerror = () => reject(projectRequest.error ?? new Error("Storage budget project lookup failed"));
|
||||
});
|
||||
const [project, snapshots, lodRows, assets] = await Promise.all([
|
||||
projectPromise,
|
||||
getAll<SnapshotRow>("snapshot"),
|
||||
getAll<LODManifestRow>("lod_manifest"),
|
||||
getAll<AssetRow>("asset"),
|
||||
]);
|
||||
await transactionComplete(transaction);
|
||||
db.close();
|
||||
const projectSnapshots = snapshots.filter((row) => row.projectId === projectId);
|
||||
const projectLod = lodRows.filter((row) => row.projectId === projectId);
|
||||
const projectAssets = assets.filter((row) => row.projectId === projectId);
|
||||
const simulationAssets = projectAssets.filter((row) => row.mimeType === "application/x-blender-simulation-cache");
|
||||
const sum = (values: readonly number[]): number => values.reduce((total, value) => Number.isSafeInteger(value) && value >= 0 ? total + value : total, 0);
|
||||
return createStorageBudget(projectId, {
|
||||
projectBytes: project?.bytes ?? 0,
|
||||
snapshotBytes: sum(projectSnapshots.map((row) => row.bytes)),
|
||||
lodBytes: sum(projectLod.map((row) => row.bytes ?? 0)),
|
||||
mediaBytes: sum(projectAssets.filter((row) => row.mimeType !== "application/x-blender-simulation-cache").map((row) => row.bytes)),
|
||||
vdbBytes: sum(simulationAssets.map((row) => row.bytes)) + await measureProjectVDBBytes(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
|
||||
projectLayout(projectId);
|
||||
const listed = await listAssets(projectId);
|
||||
const referenced = new Set(listed.assets.map((asset) => asset.sha256));
|
||||
const cleanup = await removeOrphanContentAssets(projectId, referenced);
|
||||
return { projectId, ...cleanup };
|
||||
}
|
||||
|
||||
async function handleRequest(event: MessageEvent<StorageRequest>): Promise<void> {
|
||||
try {
|
||||
let result;
|
||||
const command = event.data.command;
|
||||
if (command.type === "smoke") result = await smoke();
|
||||
if (command.type === "crashForTest") throw new Error("Worker crash injection escaped the synchronous boundary");
|
||||
else if (command.type === "smoke") result = await smoke();
|
||||
else if (command.type === "info") result = await info();
|
||||
else if (command.type === "getBudget") result = await withProjectTransaction(command.projectId, () => getStorageBudget(command.projectId));
|
||||
else if (command.type === "cleanupProject") result = await withProjectTransaction(command.projectId, () => cleanupProject(command.projectId));
|
||||
else if (command.type === "listRecentProjects") result = await withRecentProjectsTransaction(listRecentProjects);
|
||||
else if (command.type === "touchRecentProject") result = await withRecentProjectsTransaction(() => touchRecentProject(command.project));
|
||||
else if (command.type === "removeRecentProject") result = await withRecentProjectsTransaction(() => removeRecentProjectEntry(command.projectId));
|
||||
else if (command.type === "ensureProject") result = await withProjectTransaction(command.projectId, () => ensureProject(command.projectId));
|
||||
else if (command.type === "saveProject") result = await withProjectTransaction(command.projectId, () => saveProject(command.projectId, command.revision, command.buffer, command.faultAt));
|
||||
else if (command.type === "recoverProject") result = await withProjectTransaction(command.projectId, () => recoverProject(command.projectId));
|
||||
@@ -844,6 +1501,8 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath);
|
||||
else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256);
|
||||
else if (command.type === "listAssets") result = await listAssets(command.projectId);
|
||||
else if (command.type === "commitTexturePaintTile") result = await withProjectTransaction(command.commit.target.projectId, () => commitTexturePaintTile(command.commit));
|
||||
else if (command.type === "readTexturePaintTileBinding") result = await withProjectTransaction(command.request.projectId, () => readTexturePaintTileBinding(command.request));
|
||||
else if (command.type === "saveLOD") result = await saveLOD(command.projectId, command.cacheKey, command.data);
|
||||
else if (command.type === "putLODManifest") result = await putLODManifest(command.projectId, command.manifest);
|
||||
else if (command.type === "getLODManifest") result = await getLODManifest(command.projectId, command.cacheKey);
|
||||
@@ -851,10 +1510,14 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "readLOD") result = await readLOD(command.projectId, command.cacheKey);
|
||||
else if (command.type === "deleteLOD") result = await deleteLOD(command.projectId, command.cacheKey);
|
||||
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
|
||||
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
|
||||
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
|
||||
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data, event.data.requestId));
|
||||
else if (command.type === "prepareSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => prepareSimulationCachePlayback(command.projectId, command.cacheKey, event.data.requestId));
|
||||
else if (command.type === "releaseSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => releaseSimulationCachePlayback(command.projectId, command.cacheKey));
|
||||
else if (command.type === "readSimulationCache") result = await withProjectTransaction(command.projectId, () => readSimulationCache(command.projectId, command.cacheKey, event.data.requestId));
|
||||
else if (command.type === "readSimulationCacheFrame") result = await withProjectTransaction(command.projectId, () => readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame, event.data.requestId));
|
||||
else if (command.type === "listSimulationCaches") result = await withProjectTransaction(command.projectId, () => listSimulationCaches(command.projectId));
|
||||
else if (command.type === "pruneSimulationCaches") result = await withProjectTransaction(command.projectId, () => pruneSimulationCaches(command.projectId, command.maxBytes, command.protectedCacheKeys));
|
||||
else if (command.type === "cancelRequest") return;
|
||||
else throw new Error("Unknown storage command");
|
||||
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);
|
||||
else if (result && "buffer" in result && result.buffer instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.buffer]);
|
||||
@@ -867,4 +1530,16 @@ scope.onmessage = async (event) => {
|
||||
errorCode: error && typeof error === "object" && "code" in error ? (error as { code: StorageResponse["errorCode"] }).code : undefined,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
cancelledRequests.delete(event.data.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
|
||||
if (event.data.command.type === "cancelRequest") {
|
||||
cancelRequest(event.data.command.targetRequestId);
|
||||
return;
|
||||
}
|
||||
void handleRequest(event);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,22 @@ import {
|
||||
uploadNanoVDBFloat32GridPaged,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport";
|
||||
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency, rebuildNanoVDBGridAfterDeviceLoss } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
|
||||
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||||
const encoder = device.createCommandEncoder();
|
||||
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
|
||||
device.queue.submit([encoder.finish()]);
|
||||
await readback.mapAsync(GPUMapMode.READ);
|
||||
const words = new Uint32Array(readback.getMappedRange().slice(0));
|
||||
readback.unmap();
|
||||
readback.destroy();
|
||||
return words;
|
||||
}
|
||||
|
||||
scope.onmessage = (): void => {
|
||||
void (async () => {
|
||||
const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR;
|
||||
@@ -199,10 +211,19 @@ scope.onmessage = (): void => {
|
||||
device.destroy();
|
||||
const loss = await session.waitForLoss();
|
||||
const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024);
|
||||
const recoveredDemand = createNanoVDBFloat32GridPaged(recoveredDevice, payload.byteLength, pageByteLength, 2 * pageByteLength);
|
||||
recoveredDemand.uploadPage(0, await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal));
|
||||
recoveredDemand.uploadPage(lastPageIndex, await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal));
|
||||
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, wordOffsets.slice(0, 2));
|
||||
const visiblePageIds = [2, 1, 0, 2];
|
||||
const replay = await rebuildNanoVDBGridAfterDeviceLoss(
|
||||
recoveredDevice,
|
||||
pagingManifest,
|
||||
density.name,
|
||||
pageSource,
|
||||
visiblePageIds,
|
||||
new AbortController().signal,
|
||||
);
|
||||
const recoveredDemand = replay.grid;
|
||||
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, [0, pageByteLength]);
|
||||
const recoveredPageTable = [...await readWords(recoveredDevice, recoveredDemand.pageTable, recoveredDemand.pageCount * 4)];
|
||||
const recoveredResidentPages = [...recoveredDemand.residentVirtualPages];
|
||||
recoveredDemand.dispose();
|
||||
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
|
||||
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
|
||||
@@ -227,7 +248,20 @@ scope.onmessage = (): void => {
|
||||
demandPaging,
|
||||
globalResidency,
|
||||
paging,
|
||||
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords },
|
||||
deviceLoss: {
|
||||
reason: loss.reason,
|
||||
firstGeneration,
|
||||
recoveredGeneration,
|
||||
residentBeforeReplay: replay.residentBeforeReplay,
|
||||
visiblePageIds: replay.plan.visiblePageIds,
|
||||
replayedPageIds: replay.plan.replayedPageIds,
|
||||
skippedPageIds: replay.plan.skippedPageIds,
|
||||
recoveredResidentPages,
|
||||
recoveredPageTable,
|
||||
oldNonVisiblePage: lastPageIndex,
|
||||
recoveredDemandWords,
|
||||
expectedDemandWords: [new Uint32Array(page0)[0], new Uint32Array(page1)[0]],
|
||||
},
|
||||
samplesStable: JSON.stringify(before) === JSON.stringify(after),
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
|
||||
@@ -34,21 +34,29 @@ import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNano
|
||||
import { createNanoVDBViewportObject } from "../three-adapter/volume";
|
||||
import {
|
||||
configurePBRLight,
|
||||
configurePBRCamera,
|
||||
configurePBRRenderer,
|
||||
createPBRLight,
|
||||
createPBRMaterial,
|
||||
PBR_SHADOW_MAP_DIMENSION,
|
||||
setPBRMaterialSelected,
|
||||
} from "../three-adapter/pbr";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import { planPBRLightingBudget } from "../../../protocol/render-budget";
|
||||
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import {
|
||||
applyGreasePencilPointSelection,
|
||||
applyGreasePencilPointPreview,
|
||||
applyGreasePencilTransform,
|
||||
createGreasePencilObject,
|
||||
greasePencilMarqueeCandidates,
|
||||
greasePencilPointRef,
|
||||
type GreasePencilPointRef,
|
||||
} from "../three-adapter/grease-pencil";
|
||||
import { selectGreasePencilMarquee, type GreasePencilDrawingScopeIR, type GreasePencilMarqueeBoxIR } from "../../../protocol/grease-pencil-marquee";
|
||||
import { applyOrbitDelta, cameraState, VIEWPORT_DEFAULT_ORBIT, orbitPosition, type ViewportOrbitState } from "../../../protocol/viewport-camera";
|
||||
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR } from "../../../protocol/paint-depth-visibility";
|
||||
import { samplePaintDepthVisibilityGPU } from "../three-adapter/paint-depth-visibility";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
|
||||
@@ -62,13 +70,11 @@ let importedLights: Group | null = null;
|
||||
let contextLost = false;
|
||||
let width = 1;
|
||||
let height = 1;
|
||||
let yaw = -Math.PI / 4;
|
||||
let pitch = 0.55;
|
||||
let distance = 7;
|
||||
let orbit: ViewportOrbitState = { ...VIEWPORT_DEFAULT_ORBIT, target: [...VIEWPORT_DEFAULT_ORBIT.target] as [number, number, number] };
|
||||
let editMode = false;
|
||||
let selectionMode: MeshElementMode = "FACE";
|
||||
let currentSnapshot: SceneSnapshotIR | null = null;
|
||||
const textureStore = new GPUTextureStore();
|
||||
const textureStore = new GPUTextureStore("THREE_WEBGL2");
|
||||
const raycaster = new Raycaster();
|
||||
raycaster.params.Points.threshold = 0.14;
|
||||
const objectById = new Map<string, Object3D>();
|
||||
@@ -88,8 +94,8 @@ function post(message: OffscreenViewportResponse): void {
|
||||
|
||||
function render(): void {
|
||||
if (contextLost || !renderer || !scene || !camera) return;
|
||||
camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.position.set(...orbitPosition(orbit));
|
||||
camera.lookAt(...orbit.target);
|
||||
renderer.render(scene, camera);
|
||||
publishCurveGizmoFrame();
|
||||
const gl = renderer.getContext();
|
||||
@@ -109,7 +115,7 @@ function render(): void {
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) visiblePixels += 1;
|
||||
}
|
||||
post({ type: "frame", visiblePixels });
|
||||
post({ type: "frame", visiblePixels, camera: cameraState(orbit) });
|
||||
}
|
||||
|
||||
function publishCurveGizmoFrame(): void {
|
||||
@@ -155,6 +161,15 @@ function matrixFor(node: SceneSnapshotIR["nodes"][number]): Matrix4 {
|
||||
);
|
||||
}
|
||||
|
||||
function applyCamera(snapshot: SceneSnapshotIR): void {
|
||||
if (!camera) return;
|
||||
const cameraObjectId = snapshot.scenes[0]?.cameraObjectId;
|
||||
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
|
||||
const definition = snapshot.cameras.find((candidate) => candidate.id === cameraNode?.dataId);
|
||||
if (!cameraNode || !definition) return;
|
||||
configurePBRCamera(camera, definition);
|
||||
}
|
||||
|
||||
function geometryFrom(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
|
||||
const sourcePositions = new Float32Array(payload.positions);
|
||||
const sourceIndices = new Uint32Array(payload.indices);
|
||||
@@ -283,7 +298,7 @@ function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
|
||||
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === currentSnapshot?.scenes[0]?.worldId) ?? currentSnapshot?.worlds[0];
|
||||
if (scene && renderer) void textureStore.applyEnvironment(scene, renderer, world, true);
|
||||
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes });
|
||||
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes, budget: status.budget });
|
||||
render();
|
||||
}).catch((error) => post({ type: "error", message: error instanceof Error ? error.message : "Texture upload failed" }));
|
||||
}
|
||||
@@ -393,19 +408,26 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
greasePencilCount++;
|
||||
}
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
|
||||
const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2");
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount, renderBudget: lightingBudget });
|
||||
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
|
||||
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
|
||||
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
|
||||
if (renderer) configurePBRRenderer(renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
|
||||
applyCamera(snapshot);
|
||||
const rendered = new Set(lightingBudget.renderedLightNodeIds);
|
||||
const shadowed = new Set(lightingBudget.shadowLightNodeIds);
|
||||
if (importedLights) {
|
||||
const lights = new Map(snapshot.lights.map((definition) => [definition.id, definition]));
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
|
||||
if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue;
|
||||
const definition = lights.get(node.dataId);
|
||||
if (!definition) continue;
|
||||
const light = createPBRLight(definition);
|
||||
configurePBRLight(light, node, importedLights);
|
||||
configurePBRLight(light, node, importedLights, {
|
||||
shadowEnabled: shadowed.has(node.id),
|
||||
shadowMapDimension: lightingBudget.budget.shadowMapDimension,
|
||||
});
|
||||
light.name = node.name;
|
||||
importedLights.add(light);
|
||||
objectById.set(node.id, light);
|
||||
@@ -415,7 +437,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void {
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[], greasePencilSelectionRevision: number): void {
|
||||
const selected = new Set(ids);
|
||||
const visited = new Set<Object3D>();
|
||||
for (const [id, object] of objectById) {
|
||||
@@ -444,9 +466,10 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non
|
||||
if (root) applyNonMeshElementSelection(root, selection);
|
||||
if (root) applyGreasePencilPointSelection(root, greasePencilPoints);
|
||||
render();
|
||||
post({ type: "greasePencilSelectionStatus", selectionRevision: greasePencilSelectionRevision, pointIds: greasePencilPoints.map((point) => point.pointId) });
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, additive: boolean): void {
|
||||
function pick(x: number, y: number, additive: boolean, baseSelectionRevision: number): void {
|
||||
if (!root || !camera) return;
|
||||
raycaster.setFromCamera(new Vector2(x, y), camera);
|
||||
const hits = raycaster.intersectObjects(root.children, true);
|
||||
@@ -455,7 +478,7 @@ function pick(x: number, y: number, additive: boolean): void {
|
||||
: undefined;
|
||||
if (greasePencilHit?.index !== undefined) {
|
||||
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
|
||||
if (point) post({ type: "greasePencilPointSelected", point, additive });
|
||||
if (point) post({ type: "greasePencilPointSelected", point, additive, baseSelectionRevision });
|
||||
return;
|
||||
}
|
||||
const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
|
||||
@@ -511,6 +534,41 @@ function pick(x: number, y: number, additive: boolean): void {
|
||||
else if (typeof objectId === "string") post({ type: "selected", objectId, additive });
|
||||
}
|
||||
|
||||
function greasePencilMarquee(
|
||||
drawing: GreasePencilDrawingScopeIR,
|
||||
box: GreasePencilMarqueeBoxIR,
|
||||
baseRevision: number,
|
||||
baseSelectionRevision: number,
|
||||
additive: boolean,
|
||||
): void {
|
||||
if (!root || !camera || !currentSnapshot) return;
|
||||
const result = selectGreasePencilMarquee({
|
||||
schemaVersion: 1,
|
||||
baseRevision,
|
||||
baseSelectionRevision,
|
||||
drawing,
|
||||
box,
|
||||
candidates: greasePencilMarqueeCandidates(root, drawing, camera),
|
||||
}, currentSnapshot.revision);
|
||||
post({ type: "greasePencilMarqueeSelected", result, additive });
|
||||
}
|
||||
|
||||
function paintDepthVisibility(requestId: string, requestValue: PaintDepthVisibilityRequestIR): void {
|
||||
try {
|
||||
if (!renderer || !scene || !camera || !currentSnapshot) throw new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport is not ready");
|
||||
const request = validatePaintDepthVisibilityRequest(requestValue, currentSnapshot.revision);
|
||||
const node = currentSnapshot.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible);
|
||||
const object = node ? objectById.get(node.id) : undefined;
|
||||
if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport");
|
||||
const result = samplePaintDepthVisibilityGPU({ renderer, scene, camera, object, request, backend: "OFFSCREEN_WEBGL2" });
|
||||
post({ type: "paintDepthVisibilityResult", requestId, result });
|
||||
render();
|
||||
}
|
||||
catch (error) {
|
||||
post({ type: "paintDepthVisibilityError", requestId, message: error instanceof Error ? error.message : "PAINT_DEPTH_UNAVAILABLE: GPU depth readback failed" });
|
||||
}
|
||||
}
|
||||
|
||||
workerScope.onmessage = (event): void => {
|
||||
try {
|
||||
const message = event.data;
|
||||
@@ -537,7 +595,7 @@ workerScope.onmessage = (event): void => {
|
||||
const light = new DirectionalLight(0xffffff, 2.5);
|
||||
light.position.set(4, -5, 8);
|
||||
light.castShadow = true;
|
||||
light.shadow.mapSize.set(1024, 1024);
|
||||
light.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION);
|
||||
light.shadow.bias = -0.0005;
|
||||
light.shadow.normalBias = 0.03;
|
||||
scene.add(light, light.target, new GridHelper(20, 20, 0x60656e, 0x383b42), root, importedLights);
|
||||
@@ -552,7 +610,7 @@ workerScope.onmessage = (event): void => {
|
||||
void refreshVolumes();
|
||||
}
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints, message.greasePencilSelectionRevision);
|
||||
else if (message.type === "interaction") {
|
||||
editMode = message.editMode;
|
||||
selectionMode = message.selectionMode;
|
||||
@@ -574,12 +632,14 @@ workerScope.onmessage = (event): void => {
|
||||
render();
|
||||
}
|
||||
else if (message.type === "orbit") {
|
||||
yaw -= message.deltaX * 0.008;
|
||||
pitch = Math.max(-1.45, Math.min(1.45, pitch + message.deltaY * 0.008));
|
||||
distance = Math.max(0.2, Math.min(500, distance * Math.exp(message.zoom * 0.001)));
|
||||
orbit = applyOrbitDelta(orbit, message.deltaX, message.deltaY, message.zoom);
|
||||
render();
|
||||
}
|
||||
else if (message.type === "pick") pick(message.x, message.y, message.additive);
|
||||
else if (message.type === "pick") pick(message.x, message.y, message.additive, message.baseSelectionRevision);
|
||||
else if (message.type === "greasePencilMarquee") {
|
||||
greasePencilMarquee(message.drawing, message.box, message.baseRevision, message.baseSelectionRevision, message.additive);
|
||||
}
|
||||
else if (message.type === "paintDepthVisibility") paintDepthVisibility(message.requestId, message.request);
|
||||
else if (message.type === "dispose") {
|
||||
clearRoot();
|
||||
volumeRenderGeneration++;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -12,11 +12,23 @@ import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../prot
|
||||
import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt";
|
||||
import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes";
|
||||
import { gateShaderGraph } from "../../../protocol/shader-graph";
|
||||
import { gateNlaTracks } from "../../../protocol/nla";
|
||||
import { compileShaderGraph, type ShaderCompileReport } from "../../../protocol/shader-compiler";
|
||||
import { gateNlaTracks, moveNlaStrip } from "../../../protocol/nla";
|
||||
import { blockedGate, capabilityIssue, type CapabilityGateResult } from "../../../protocol/capability-gates";
|
||||
import { gateRenderCapability } from "../../../protocol/render-capabilities";
|
||||
import { gateNonMeshData } from "../../../protocol/non-mesh";
|
||||
import { chunkNonMeshGeometry, nonMeshChunkTransferables, type NonMeshGeometryChunk, type NonMeshAttributeArray } from "../../../protocol/nonmesh-binary";
|
||||
import {
|
||||
EXTERNAL_VFONT_MAX_BYTES,
|
||||
validateExternalVFontImport,
|
||||
validateExternalVFontMainImportProof,
|
||||
} from "../../../protocol/external-vfont";
|
||||
import { validateGreasePencilReorderCommand } from "../../../protocol/grease-pencil-reorder";
|
||||
import { PaintStrokeSessionError, PaintStrokeSessionStore } from "../../../protocol/paint-stroke-session";
|
||||
import {
|
||||
gatePaintPBVHCapability,
|
||||
PAINT_PBVH_WASM_ENTRYPOINT,
|
||||
} from "../../../protocol/paint-pbvh-capability";
|
||||
|
||||
type WasmModule = {
|
||||
_malloc: (size: number) => number;
|
||||
@@ -97,6 +109,7 @@ let sourceBlendBuffer: ArrayBuffer | null = null;
|
||||
let sourceBlendRevision = -1;
|
||||
const cancelledOpenRequests = new Set<string>();
|
||||
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
|
||||
const paintStrokeSessions = new PaintStrokeSessionStore();
|
||||
|
||||
function openResourceStatus(): WebEngineOpenResourceStatus {
|
||||
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
|
||||
@@ -140,11 +153,78 @@ function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]):
|
||||
throw report(fallbackCode, error instanceof Error ? error.message : "Capability payload is invalid");
|
||||
}
|
||||
|
||||
async function assertExternalVFontImport(payload: Extract<WebEngineEditCommand, { type: "importVFont" }>): Promise<void> {
|
||||
try {
|
||||
validateExternalVFontMainImportProof(payload);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
if (typeof payload.base64 !== "string" || payload.base64.length === 0 ||
|
||||
payload.base64.length > Math.ceil(EXTERNAL_VFONT_MAX_BYTES / 3) * 4 + 4) {
|
||||
throw report("NON_MESH_DATA_BUDGET_EXCEEDED", "External VFont command exceeds the 32 MiB payload budget");
|
||||
}
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const binary = atob(payload.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
||||
data = bytes.buffer;
|
||||
}
|
||||
catch {
|
||||
throw report("NON_MESH_BINARY_INVALID", "External VFont command base64 is invalid");
|
||||
}
|
||||
try {
|
||||
await validateExternalVFontImport({
|
||||
sourcePath: payload.sourcePath,
|
||||
mimeType: payload.mimeType,
|
||||
byteLength: payload.byteLength,
|
||||
sha256: payload.sha256,
|
||||
data,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function rejectCapabilityGate(gate: CapabilityGateResult): never {
|
||||
const issue = gate.issues[0];
|
||||
throw report(issue?.code ?? "CAPABILITY_MISSING", `N-${gate.taskId.slice(2)} ${gate.capability} blocked${issue ? `: ${issue.message}` : ""}`);
|
||||
}
|
||||
|
||||
function prepareNlaMoveCommand(
|
||||
payload: Extract<WebEngineEditCommand, { type: "moveNLAStrip" }>,
|
||||
): Extract<WebEngineEditCommand, { type: "setNLAStack" }> {
|
||||
const snapshot = currentSnapshot;
|
||||
if (!snapshot?.nodes.some((node) => node.id === payload.objectId)) {
|
||||
throw report("NLA_PATH_INCOMPATIBLE", `NLA owner does not exist: ${payload.objectId}`);
|
||||
}
|
||||
if (payload.baseRevision !== snapshot.revision) {
|
||||
throw report("REVISION_CONFLICT", "NLA operator base revision does not match the current SceneIR");
|
||||
}
|
||||
const actionIds = new Set(snapshot.animations.map((animation) => animation.id));
|
||||
const actionChannelPaths = new Map(snapshot.animations.map((animation) => [
|
||||
animation.id,
|
||||
new Set(animation.channels.map((channel) => channel.path)),
|
||||
]));
|
||||
try {
|
||||
return {
|
||||
type: "setNLAStack",
|
||||
objectId: payload.objectId,
|
||||
baseRevision: payload.baseRevision,
|
||||
tracks: moveNlaStrip(
|
||||
(snapshot.nlaTracks ?? []).filter((track) => track.ownerId === payload.objectId),
|
||||
payload,
|
||||
{ actionIds, actionChannelPaths, ownerId: payload.objectId },
|
||||
),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NLA_INVALID_STACK");
|
||||
}
|
||||
}
|
||||
|
||||
function sculptMeshGate(meshId: string): CapabilityGateResult {
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === meshId);
|
||||
const objectCount = currentSnapshot?.nodes.filter((node) => node.type === "MESH" && node.dataId === meshId).length ?? 0;
|
||||
@@ -309,6 +389,16 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (!Number.isSafeInteger(payload.activeTextBox) || payload.activeTextBox < 0 || payload.activeTextBox >= payload.textBoxes.length) throw report("NON_MESH_PROPERTY_INVALID", "Font active text box is outside the text box array");
|
||||
return;
|
||||
}
|
||||
case "importVFont": {
|
||||
try {
|
||||
validateExternalVFontMainImportProof(payload);
|
||||
return;
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setFontLinks": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "FONT") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 font data block is unavailable: ${payload.dataId}`);
|
||||
@@ -345,7 +435,21 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
return;
|
||||
}
|
||||
case "moveGreasePencilLayer": {
|
||||
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !["UP", "DOWN", "TOP", "BOTTOM"].includes(payload.direction)) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer move is invalid");
|
||||
try {
|
||||
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "moveGreasePencilFrame": {
|
||||
try {
|
||||
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "insertGreasePencilFrame":
|
||||
@@ -379,8 +483,13 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
const object = currentSnapshot?.nodes.find((candidate) => candidate.id === payload.objectId && candidate.type === "MESH");
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === object?.dataId);
|
||||
if (!object || !mesh || typeof payload.vertexGroup !== "string" || payload.vertexGroup.length === 0 || new TextEncoder().encode(payload.vertexGroup).byteLength > 63 || !Array.isArray(payload.indices) || !Array.isArray(payload.values) || payload.indices.length === 0 || payload.indices.length !== payload.values.length || payload.indices.length > 1_000_000) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch is invalid");
|
||||
if (payload.indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.mirror !== undefined && typeof payload.mirror !== "boolean")) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
|
||||
if (payload.mirror) throw report("CAPABILITY_MISSING", "N-017 topology mirror requires a verified mesh symmetry map");
|
||||
const uniqueIndices = new Set<number>();
|
||||
if (payload.indices.some((index) => {
|
||||
if (!Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount || uniqueIndices.has(index)) return true;
|
||||
uniqueIndices.add(index);
|
||||
return false;
|
||||
}) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.limit !== undefined && (!Number.isSafeInteger(payload.limit) || payload.limit < 1 || payload.limit > 32)) || (payload.mirror !== undefined && typeof payload.mirror !== "boolean") || (payload.mirrorAxis !== undefined && (!Number.isSafeInteger(payload.mirrorAxis) || payload.mirrorAxis < 0 || payload.mirrorAxis > 2)) || (payload.mirrorTolerance !== undefined && (typeof payload.mirrorTolerance !== "number" || !Number.isFinite(payload.mirrorTolerance) || payload.mirrorTolerance <= 0 || payload.mirrorTolerance > 1))) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
|
||||
if (!payload.mirror && (payload.mirrorAxis !== undefined || payload.mirrorTolerance !== undefined)) throw report("PAINT_SCHEMA_INVALID", "Vertex weight mirror axis/tolerance require mirror=true");
|
||||
return;
|
||||
}
|
||||
case "setLightProperties": {
|
||||
@@ -949,11 +1058,17 @@ function progress(requestId: string, phase: "started" | "progress" | "completed"
|
||||
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
|
||||
}
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
function openCancellationWindow(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
|
||||
async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<void> {
|
||||
const request = event.data;
|
||||
try {
|
||||
let result: WebEngineResult;
|
||||
switch (request.command.type) {
|
||||
case "crashForTest":
|
||||
throw report("WORKER_TERMINATED", "Worker crash injection escaped the synchronous boundary");
|
||||
case "init":
|
||||
result = baseResult();
|
||||
await initialize();
|
||||
@@ -966,6 +1081,7 @@ scope.onmessage = async (event) => {
|
||||
let candidateHandle = 0;
|
||||
try {
|
||||
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
|
||||
await openCancellationWindow();
|
||||
await initialize();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
|
||||
@@ -987,7 +1103,7 @@ scope.onmessage = async (event) => {
|
||||
resource.liveInputBytes = 0;
|
||||
}
|
||||
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
await openCancellationWindow();
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
|
||||
const previousModule = module;
|
||||
@@ -1000,6 +1116,7 @@ scope.onmessage = async (event) => {
|
||||
throwIfOpenCancelled(request.requestId);
|
||||
const sourceCopy = request.command.buffer.slice(0);
|
||||
currentSnapshot = scene.snapshot;
|
||||
paintStrokeSessions.clear();
|
||||
sourceBlendBuffer = sourceCopy;
|
||||
sourceBlendRevision = scene.snapshot.revision;
|
||||
result = { status: status(), ...publishFullScene(scene) };
|
||||
@@ -1040,11 +1157,87 @@ scope.onmessage = async (event) => {
|
||||
await initialize();
|
||||
result = { status: status(), delta: readDelta() };
|
||||
break;
|
||||
case "beginPaintStroke":
|
||||
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.begin(request.command.session, currentSnapshot.revision) };
|
||||
break;
|
||||
case "appendPaintStrokeChunk":
|
||||
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.append(request.command.chunk, currentSnapshot.revision) };
|
||||
break;
|
||||
case "cancelPaintStroke":
|
||||
result = { status: status(), paintStrokeSession: paintStrokeSessions.cancel(request.command.session) };
|
||||
break;
|
||||
case "queryPaintPBVHCapability": {
|
||||
await initialize();
|
||||
const activeObject = currentSnapshot?.nodes.find((node) => node.id === currentSnapshot?.activeObjectId && node.type === "MESH");
|
||||
const nativeEntrypointPresent = typeof (module as unknown as Record<string, unknown> | null)?.[PAINT_PBVH_WASM_ENTRYPOINT] === "function";
|
||||
try {
|
||||
const gate = gatePaintPBVHCapability(request.command.request, {
|
||||
nativeEntrypointPresent,
|
||||
sessionContextReady: false,
|
||||
verifiedBrushes: new Set(),
|
||||
currentRevision: currentSnapshot?.revision,
|
||||
currentObjectId: activeObject?.id,
|
||||
currentMeshId: activeObject?.dataId ?? undefined,
|
||||
});
|
||||
result = { status: status(), capabilityGate: gate };
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "PAINT_SCHEMA_INVALID");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "commitPaintStroke": {
|
||||
await initialize();
|
||||
const beforeSnapshot = currentSnapshot;
|
||||
if (!beforeSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
|
||||
const staged = paintStrokeSessions.commit(request.command.session, beforeSnapshot.revision);
|
||||
assertFutureCapability(staged.command);
|
||||
const input = copyCommand(staged.command);
|
||||
try {
|
||||
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) throw nativeError("INVALID_ARGUMENT");
|
||||
}
|
||||
finally {
|
||||
module?._free(input.pointer);
|
||||
}
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
result = {
|
||||
status: status(),
|
||||
...publishIncrementalScene(scene),
|
||||
delta: readDelta(),
|
||||
paintStrokeSession: { ...staged.receipt, state: "COMMITTED", committedRevision: scene.snapshot.revision },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "applyCommand": {
|
||||
await initialize();
|
||||
const beforeSnapshot = currentSnapshot;
|
||||
const payload = request.command.payload;
|
||||
assertFutureCapability(payload);
|
||||
const nativePayload = payload.type === "moveNLAStrip" ? prepareNlaMoveCommand(payload) : payload;
|
||||
if (payload.type === "importVFont") await assertExternalVFontImport(payload);
|
||||
assertFutureCapability(nativePayload);
|
||||
let shaderCompile: ShaderCompileReport | undefined;
|
||||
if (payload.type === "setShaderGraph") {
|
||||
shaderCompile = compileShaderGraph(
|
||||
payload.graph,
|
||||
beforeSnapshot?.materials.find((material) => material.id === payload.materialId),
|
||||
{
|
||||
imageIds: new Set(beforeSnapshot?.images.map((image) => image.id) ?? []),
|
||||
blockedImageIds: new Set(beforeSnapshot?.images
|
||||
.filter((image) => image.libraryLinked || image.assetStatus === "LINKED_LIBRARY_REQUIRED" || image.assetStatus === "MISSING" || image.assetStatus === "CORRUPT")
|
||||
.map((image) => image.id) ?? []),
|
||||
textureIdentities: new Map(beforeSnapshot?.images.map((image) => [image.id, {
|
||||
assetId: image.assetId,
|
||||
sha256: image.sha256,
|
||||
colorSpace: image.colorSpace,
|
||||
}] as const) ?? []),
|
||||
},
|
||||
);
|
||||
const compileIssue = shaderCompile.issues[0];
|
||||
if (shaderCompile.status === "BLOCKED" && compileIssue) throw report(compileIssue.code, compileIssue.message, true);
|
||||
}
|
||||
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertModifierStackEvaluated(beforeSnapshot, payload.meshId);
|
||||
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertSkinSimplifyEvaluable(beforeSnapshot, payload.meshId, payload.profile);
|
||||
if (payload.type === "previewDecimateMesh") {
|
||||
@@ -1102,7 +1295,7 @@ scope.onmessage = async (event) => {
|
||||
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta() };
|
||||
break;
|
||||
}
|
||||
const input = copyCommand(payload);
|
||||
const input = copyCommand(nativePayload);
|
||||
try {
|
||||
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) {
|
||||
throw nativeError("INVALID_ARGUMENT");
|
||||
@@ -1114,7 +1307,13 @@ scope.onmessage = async (event) => {
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
const simplify = simplifyReport(beforeSnapshot, scene, payload);
|
||||
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta(), simplify };
|
||||
result = {
|
||||
status: status(),
|
||||
...publishIncrementalScene(scene),
|
||||
delta: readDelta(),
|
||||
simplify,
|
||||
shaderCompile,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "generateLOD": {
|
||||
@@ -1287,6 +1486,7 @@ scope.onmessage = async (event) => {
|
||||
handle = 0;
|
||||
currentSnapshot = null;
|
||||
currentGeometryBuffers = [];
|
||||
paintStrokeSessions.clear();
|
||||
result = { status: { ready: false, liveHandles: 0, allocatedBytes: 0 } };
|
||||
break;
|
||||
}
|
||||
@@ -1314,8 +1514,15 @@ scope.onmessage = async (event) => {
|
||||
scope.postMessage({ kind: "result", requestId: request.requestId, ok: true, result }, transfer);
|
||||
}
|
||||
catch (error) {
|
||||
const native = typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
|
||||
const native = error instanceof PaintStrokeSessionError
|
||||
? report(error.code, error.message)
|
||||
: typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
|
||||
const failure = native ?? report("WASM_INIT_FAILED", error instanceof Error ? error.message : "WebEngine request failed");
|
||||
scope.postMessage({ kind: "result", requestId: request.requestId, ok: false, error: failure });
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
|
||||
void handleRequest(event);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user