Advance bounded editor and cache workflows

This commit is contained in:
mes123456
2026-08-12 19:31:41 -04:00
parent d54d5dd913
commit 3da1dfc804
19 changed files with 563 additions and 50 deletions

View File

@@ -21,9 +21,19 @@ import { exportGLB } from "../../../protocol/glb-export";
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
import { applyGreasePencilPointTranslation } from "../../../protocol/grease-pencil-editor";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import "./app-shell.css";
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
return `${code}${error.message}`;
}
return String(error);
}
interface AreaProps {
className?: string;
editor: EditorType;
@@ -218,6 +228,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
const [paintGroup, setPaintGroup] = useState("WebPaint");
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0);
const [greasePencilPointIndex, setGreasePencilPointIndex] = useState(0);
const [greasePencilPointDeltaX, setGreasePencilPointDeltaX] = useState(0.1);
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
@@ -239,6 +252,34 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
if (!activeGreasePencil) setGreasePencilLayerId("");
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
}, [activeGreasePencil, greasePencilLayerId]);
const activeGreasePencilFrame = activeGreasePencil?.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.find((entry) => entry.frame === (snapshot?.frame.current ?? 1));
const activeGreasePencilStroke = activeGreasePencilFrame?.drawing.strokes[greasePencilStrokeIndex];
const activeGreasePencilPoint = activeGreasePencilStroke?.points?.[greasePencilPointIndex];
const translateGreasePencilPoint = (): void => {
if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return;
const result = applyGreasePencilPointTranslation({
schemaVersion: 1,
revision: snapshot.revision,
dataId: activeGreasePencil.id,
layerId: greasePencilLayerId,
frame: activeGreasePencilFrame.frame,
onionSkinning: activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.onionSkinning ?? false,
selectedStrokeIndices: [greasePencilStrokeIndex],
selectedPoints: [{ strokeIndex: greasePencilStrokeIndex, pointIndex: greasePencilPointIndex }],
}, activeGreasePencilFrame.drawing.strokes, {
type: "TRANSLATE_POINTS",
revision: snapshot.revision,
translation: [greasePencilPointDeltaX, 0, 0],
});
onCommand({
type: "setGreasePencilStrokes",
dataId: activeGreasePencil.id,
layerId: greasePencilLayerId,
frame: activeGreasePencilFrame.frame,
baseRevision: snapshot.revision,
strokes: result.strokes,
});
};
const toggleDelimit = (value: SimplifyDelimit): void => {
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
};
@@ -280,7 +321,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCo
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label> <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}></button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}></button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}></button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input type="checkbox" defaultChecked /></label></div>
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label> UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}> UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => setGreasePencilLayerId(event.target.value)}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencil.layers.find((layer) => layer.id === greasePencilLayerId)?.frames.some((entry) => entry.frame === (snapshot?.frame.current ?? 1))} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, strokes: [] })}>Clear Drawing</button></div><output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeGreasePencil ? <div className="property-section" data-testid="grease-pencil-editor"><h3>Grease Pencil</h3><label>Layer <select aria-label="Grease Pencil layer" value={greasePencilLayerId} onChange={(event) => { setGreasePencilLayerId(event.target.value); setGreasePencilStrokeIndex(0); setGreasePencilPointIndex(0); }}>{activeGreasePencil.layers.map((layer) => <option key={layer.id} value={layer.id}>{layer.name}</option>)}</select></label><label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button><button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button><button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button><button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, baseRevision: snapshot?.revision, strokes: [] })}>Clear Drawing</button></div>{activeGreasePencilFrame ? <><label>Stroke <select aria-label="Grease Pencil stroke" value={Math.min(greasePencilStrokeIndex, Math.max(0, activeGreasePencilFrame.drawing.strokes.length - 1))} onChange={(event) => { setGreasePencilStrokeIndex(Number(event.target.value)); setGreasePencilPointIndex(0); }}>{activeGreasePencilFrame.drawing.strokes.map((stroke, index) => <option key={stroke.id ?? index} value={index}>{index + 1}</option>)}</select></label><label>Point <select aria-label="Grease Pencil point" value={Math.min(greasePencilPointIndex, Math.max(0, (activeGreasePencilStroke?.points?.length ?? 1) - 1))} onChange={(event) => setGreasePencilPointIndex(Number(event.target.value))}>{activeGreasePencilStroke?.points?.map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><label>X delta <input aria-label="Grease Pencil point X delta" type="number" min="-1000000" max="1000000" step="0.1" value={greasePencilPointDeltaX} onChange={(event) => setGreasePencilPointDeltaX(Number(event.target.value))} /></label><div className="property-actions"><button type="button" disabled={!activeGreasePencilPoint} onClick={translateGreasePencilPoint}>Move Point</button></div>{activeGreasePencilPoint ? <output data-testid="grease-pencil-point-position">{activeGreasePencilPoint.position.join(", ")}</output> : null}</> : null}<output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output></div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: true })}>Apply Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output></div> : null}
{activeMesh ? <div className="property-section">
<h3>Material Slots</h3>
@@ -523,7 +564,7 @@ export function App() {
}
}
} catch (error) {
setEngineStatus(`Engine: command failed${error instanceof Error ? ` (${error.message})` : ""}`);
setEngineStatus(`Engine: command failed (${errorMessage(error)})`);
}
};

View File

@@ -1,4 +1,4 @@
import { applyGreasePencilEditorEdit, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
import { applyGreasePencilEditorEdit, applyGreasePencilPointTranslation, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
self.onmessage = () => {
const result: Record<string, unknown> = {};
@@ -11,5 +11,16 @@ self.onmessage = () => {
try { applyGreasePencilEditorEdit(base, { type: "SET_LAYER", revision: 7, layerId: "layer:Other" }); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
try { parseGreasePencilEditor({ ...base, selectedPoints: [{ strokeIndex: 1, pointIndex: 1 }, { strokeIndex: 1, pointIndex: 1 }] }); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
try { parseGreasePencilEditor({ ...base, selectedStrokeIndices: [-1] }); } catch (error) { result.negative = error instanceof Error ? error.message : String(error); }
try {
const editor = { ...base, selectedPoints: [{ strokeIndex: 0, pointIndex: 1 }] };
const translated = applyGreasePencilPointTranslation(editor, [{ cyclic: false, materialIndex: 0, points: [
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
{ position: [1, 2, 3], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] },
] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [0.5, -1, 2] });
result.translation = [translated.editor.revision, translated.strokes[0].points[0].position, translated.strokes[0].points[1]];
} catch (error) { result.translation = error instanceof Error ? error.message : String(error); }
try {
applyGreasePencilPointTranslation({ ...base, selectedPoints: [{ strokeIndex: 9, pointIndex: 1 }] }, [{ points: [{ position: [0, 0, 0] }] }], { type: "TRANSLATE_POINTS", revision: 0, translation: [1, 0, 0] });
} catch (error) { result.missingPoint = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -1,4 +1,4 @@
import { PAINT_BUDGET, parsePaintStroke, parseWeightPatch } from "../../../protocol/paint";
import { PAINT_BUDGET, applyUdimTilePatch, computePaintBrushWeights, parsePaintStroke, parseUdimTilePatch, parseWeightPatch } from "../../../protocol/paint";
const base = {
schemaVersion: 1,
@@ -11,12 +11,29 @@ const base = {
color: [1, 0.25, 0, 1],
};
self.onmessage = () => {
const result: Record<string, string> = {};
self.onmessage = async () => {
const result: Record<string, unknown> = {};
try { result.valid = parsePaintStroke(base).mode; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, samples: [{ position: [0, 0, 0], barycentric: [0.1, 0.1, 0.1] }] }); } catch (error) { result.hit = error instanceof Error ? error.message : String(error); }
try { parsePaintStroke({ ...base, mode: "WEIGHT", vertexGroup: "Group", samples: new Array(PAINT_BUDGET.maxSamples + 1).fill(base.samples[0]) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { result.weight = parseWeightPatch({ schemaVersion: 1, objectId: "object:Paint", revision: 3, vertexGroup: "Group", indices: [0, 1], values: [0.25, 0.75], normalize: true }).vertexGroup; } catch (error) { result.weight = error instanceof Error ? error.message : String(error); }
try {
result.brush = computePaintBrushWeights([
{ index: 2, position: [0, 0, 0], normal: [0, 0, 1] },
{ index: 1, position: [1, 0, 0], normal: [0, 0, 1] },
{ index: 3, position: [0.5, 0, 0], normal: [0, 0, 1], occluded: true },
{ index: 4, position: [3, 0, 0], normal: [0, 0, 1] },
], [0, 0, 0], 2, 0.8, { frontFaceOnly: true, viewDirection: [0, 0, -1] });
} catch (error) { result.brush = error instanceof Error ? error.message : String(error); }
try {
const baseTile = new Uint8Array(16);
const hash = async (bytes: Uint8Array) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)), (value) => value.toString(16).padStart(2, "0")).join("");
const expected = baseTile.slice(); expected.set([10, 20, 30, 255], 4);
const patch = { schemaVersion: 1, textureAssetId: "image:Paint", tile: 1001, revision: 2, width: 2, height: 2, format: "RGBA8", colorSpace: "SRGB", baseSha256: await hash(baseTile), resultSha256: await hash(expected), byteOffset: 4, bytes: new Uint8Array([10, 20, 30, 255]) };
parseUdimTilePatch(patch);
result.udim = Array.from(await applyUdimTilePatch(baseTile, patch, 2));
try { await applyUdimTilePatch(baseTile, patch, 3); } catch (error) { result.udimRevision = error instanceof Error ? error.message : String(error); }
try { await applyUdimTilePatch(new Uint8Array(16).fill(1), patch, 2); } catch (error) { result.udimStale = error instanceof Error ? error.message : String(error); }
} catch (error) { result.udim = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -1,4 +1,5 @@
import {
decodeBrowserTransformCacheFrame,
PHYSICS_FAMILIES,
PHYSICS_SIMULATION_BUDGET,
gatePhysicsExecution,
@@ -61,5 +62,17 @@ self.onmessage = () => {
result.solver = gatePhysicsExecution("FLUID", "LOCAL_SOLVER").issues[0]?.code;
result.manifest = gatePhysicsExecution("RIGID_BODY", "CACHE_MANIFEST").status;
result.familyCount = PHYSICS_FAMILIES.length;
try {
const bytes = new ArrayBuffer(16 + 72);
const view = new DataView(bytes);
view.setUint32(0, 0x31465442, true); view.setUint16(4, 1, true); view.setUint16(6, 16, true); view.setInt32(8, 7, true); view.setUint32(12, 1, true);
const id = new TextEncoder().encode("object:Cloth"); view.setUint8(16, id.length); new Uint8Array(bytes, 17, id.length).set(id);
[1, 2, 3, 0, 0, 0, 1, 1, 1, 1].forEach((value, index) => view.setFloat32(48 + index * 4, value, true));
const decoded = decodeBrowserTransformCacheFrame(bytes);
result.browserPlayback = [decoded.frame, decoded.objects[0].objectId, decoded.objects[0].translation];
view.setFloat32(48 + 3 * 4, 2, true);
try { decodeBrowserTransformCacheFrame(bytes); } catch (error) { result.browserRotation = error instanceof Error ? error.message : String(error); }
}
catch (error) { result.browserPlayback = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -13,6 +13,10 @@ const scope = self as unknown as {
const projectTransactions = new Map<string, Promise<void>>();
let opfsUsable: boolean | undefined;
interface WorkerLockManager {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
}
async function useOpfsForProject(projectId: string): Promise<boolean> {
if (opfsUsable !== undefined) return opfsUsable;
const workerNavigator = (self as unknown as { navigator?: Navigator }).navigator;
@@ -33,7 +37,10 @@ async function useOpfsForProject(projectId: string): Promise<boolean> {
async function withProjectTransaction<T>(projectId: string, operation: () => Promise<T>): Promise<T> {
projectLayout(projectId);
const previous = projectTransactions.get(projectId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const result = previous.catch(() => undefined).then(() => {
const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks;
return locks ? locks.request(`blender-web-project:${projectId}`, operation) : operation();
});
const tail = result.then(() => undefined, () => undefined);
projectTransactions.set(projectId, tail);
try {
@@ -197,6 +204,33 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
const sha256 = await sha256Hex(buffer);
const existing = faultAt ? undefined : await readProjectRow(projectId);
if (existing && existing.revision > revision) {
throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`);
}
if (existing?.revision === revision) {
if (existing.bytes !== buffer.byteLength || existing.sha256 !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} already has different content`);
}
if (existing.backend === "opfs" && useOpfs) {
const recovered = await recoverProjectBlend(projectId);
if (!recovered.manifest || recovered.manifest.revision !== revision || recovered.manifest.bytes !== existing.bytes || recovered.manifest.sha256 !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} metadata does not match the OPFS commit`);
}
}
else if (existing.backend !== "indexeddb" || useOpfs || !existing.buffer || await sha256Hex(existing.buffer) !== sha256) {
throw new Error(`PROJECT_REVISION_CONFLICT: revision ${revision} backend does not match the committed project`);
}
return {
projectId,
bytes: existing.bytes,
revision,
persisted: true,
backend: existing.backend,
scenePath: existing.scenePath,
sha256,
};
}
if (useOpfs) {
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");

View File

@@ -329,6 +329,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
}
case "setGreasePencilStrokes": {
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !Number.isSafeInteger(payload.frame) || payload.frame < -1_000_000 || payload.frame > 1_000_000 || !Array.isArray(payload.strokes) || payload.strokes.length > 1_000_000) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil stroke transaction is invalid");
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Grease Pencil stroke base revision does not match the current SceneIR");
let points = 0;
for (const [strokeIndex, stroke] of payload.strokes.entries()) {
if (!stroke || typeof stroke !== "object" || !Array.isArray(stroke.points) || stroke.points.length === 0 || stroke.points.length > 1_000_000 || (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") || (stroke.materialIndex !== undefined && (!Number.isSafeInteger(stroke.materialIndex) || stroke.materialIndex < 0))) throw report("GREASE_PENCIL_SCHEMA_INVALID", `Grease Pencil stroke ${strokeIndex} is invalid`);

View File

@@ -45,6 +45,7 @@
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
"test:cross-browser-smoke": "npm run test:browser-smoke",

View File

@@ -17,7 +17,26 @@ export type GreasePencilEditorEditIR =
| { type: "SET_FRAME"; revision: number; frame: number }
| { type: "SET_LAYER"; revision: number; layerId: string }
| { type: "SET_ONION"; revision: number; enabled: boolean }
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] };
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] }
| { type: "TRANSLATE_POINTS"; revision: number; translation: [number, number, number] };
export interface GreasePencilEditablePointIR {
position: [number, number, number];
radius?: number;
opacity?: number;
vertexColor?: [number, number, number, number];
}
export interface GreasePencilEditableStrokeIR {
cyclic?: boolean;
materialIndex?: number;
points: GreasePencilEditablePointIR[];
}
export interface GreasePencilPointEditResultIR {
editor: GreasePencilEditorIR;
strokes: GreasePencilEditableStrokeIR[];
}
function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); }
function record(value: unknown, path: string): Record<string, unknown> {
@@ -32,6 +51,14 @@ function integer(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) fail(path, "is outside the supported frame/index range");
return value;
}
function finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 1_000_000) fail(path, "must be finite and bounded");
return value;
}
function tuple(value: unknown, length: number, path: string): number[] {
if (!Array.isArray(value) || value.length !== length) fail(path, `must contain ${length} numbers`);
return value.map((item, index) => finite(item, `${path}[${index}]`));
}
function nonnegativeIndex(value: unknown, path: string): number {
const result = integer(value, path);
if (result < 0) fail(path, "must be non-negative");
@@ -87,6 +114,75 @@ export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown):
if (typeof edit.enabled !== "boolean") fail("edit.enabled", "must be boolean");
return parseGreasePencilEditor({ ...editor, revision, onionSkinning: edit.enabled });
case "SET_SELECTION": return parseGreasePencilEditor({ ...editor, revision, selectedStrokeIndices: uniqueIndices(edit.strokeIndices, "edit.strokeIndices"), selectedPoints: points(edit.points, "edit.points") });
case "TRANSLATE_POINTS":
tuple(edit.translation, 3, "edit.translation");
if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation");
return parseGreasePencilEditor({ ...editor, revision });
default: fail("edit.type", "is unsupported");
}
}
function parseEditablePoint(value: unknown, path: string): GreasePencilEditablePointIR {
const point = record(value, path);
const result: GreasePencilEditablePointIR = { position: tuple(point.position, 3, `${path}.position`) as [number, number, number] };
if (point.radius !== undefined) {
result.radius = finite(point.radius, `${path}.radius`);
if (result.radius < 0) fail(`${path}.radius`, "must be non-negative");
}
if (point.opacity !== undefined) {
result.opacity = finite(point.opacity, `${path}.opacity`);
if (result.opacity < 0 || result.opacity > 1) fail(`${path}.opacity`, "must be in [0,1]");
}
if (point.vertexColor !== undefined) {
result.vertexColor = tuple(point.vertexColor, 4, `${path}.vertexColor`) as [number, number, number, number];
if (result.vertexColor.some((component) => component < 0 || component > 1)) fail(`${path}.vertexColor`, "must be in [0,1]");
}
return result;
}
function parseEditableStrokes(value: unknown): GreasePencilEditableStrokeIR[] {
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the stroke budget");
let pointCount = 0;
return value.map((item, strokeIndex) => {
const stroke = record(item, `strokes[${strokeIndex}]`);
if (!Array.isArray(stroke.points) || stroke.points.length === 0) fail(`strokes[${strokeIndex}].points`, "must be a non-empty array");
pointCount += stroke.points.length;
if (pointCount > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail("strokes", "exceeds the point budget");
if (stroke.cyclic !== undefined && typeof stroke.cyclic !== "boolean") fail(`strokes[${strokeIndex}].cyclic`, "must be boolean");
if (stroke.materialIndex !== undefined) nonnegativeIndex(stroke.materialIndex, `strokes[${strokeIndex}].materialIndex`);
return {
...(stroke.cyclic !== undefined ? { cyclic: stroke.cyclic } : {}),
...(stroke.materialIndex !== undefined ? { materialIndex: stroke.materialIndex as number } : {}),
points: stroke.points.map((point, pointIndex) => parseEditablePoint(point, `strokes[${strokeIndex}].points[${pointIndex}]`)),
};
});
}
export function applyGreasePencilPointTranslation(
editorValue: unknown,
strokesValue: unknown,
editValue: unknown,
): GreasePencilPointEditResultIR {
const editor = parseGreasePencilEditor(editorValue);
const strokes = parseEditableStrokes(strokesValue);
const edit = record(editValue, "edit");
if (edit.type !== "TRANSLATE_POINTS") fail("edit.type", "must be TRANSLATE_POINTS");
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
const translation = tuple(edit.translation, 3, "edit.translation") as [number, number, number];
if (editor.selectedPoints.length === 0) fail("editor.selectedPoints", "must contain at least one point for translation");
const selected = new Set(editor.selectedPoints.map((point) => `${point.strokeIndex}:${point.pointIndex}`));
for (const point of editor.selectedPoints) {
if (!strokes[point.strokeIndex]?.points[point.pointIndex]) fail("editor.selectedPoints", `references missing point ${point.strokeIndex}:${point.pointIndex}`);
}
const translated = strokes.map((stroke, strokeIndex) => ({
...stroke,
points: stroke.points.map((point, pointIndex) => selected.has(`${strokeIndex}:${pointIndex}`) ? {
...point,
position: point.position.map((component, axis) => finite(component + translation[axis], `translated[${strokeIndex}][${pointIndex}][${axis}]`)) as [number, number, number],
} : { ...point, position: [...point.position] as [number, number, number] }),
}));
return {
editor: parseGreasePencilEditor({ ...editor, revision: editor.revision + 1 }),
strokes: translated,
};
}

View File

@@ -42,6 +42,30 @@ export interface WeightPatchIR {
mirror?: boolean;
}
export interface PaintBrushVertexIR {
index: number;
position: [number, number, number];
normal?: [number, number, number];
occluded?: boolean;
}
export interface PaintBrushWeightIR { index: number; weight: number }
export interface UdimTilePatchIR {
schemaVersion: 1;
textureAssetId: string;
tile: number;
revision: number;
width: number;
height: number;
format: "RGBA8";
colorSpace: "SRGB" | "LINEAR";
baseSha256: string;
resultSha256: string;
byteOffset: number;
bytes: Uint8Array;
}
function fail(path: string, message: string, budget = false): never {
throw new Error(`${budget ? "PAINT_BUDGET_EXCEEDED" : "PAINT_SCHEMA_INVALID"}: ${path} ${message}`);
}
@@ -73,6 +97,14 @@ function string(value: unknown, path: string, allowEmpty = false): string {
return value;
}
const SHA256 = /^[a-f0-9]{64}$/;
function digest(value: unknown, path: string): string {
const result = string(value, path);
if (!SHA256.test(result)) fail(path, "must be a lowercase SHA-256 digest");
return result;
}
function parseHit(value: unknown, path: string): PaintHitIR {
const hit = record(value, path);
const position = tuple(hit.position, 3, `${path}.position`) as [number, number, number];
@@ -146,3 +178,87 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
return result;
}
export function computePaintBrushWeights(
verticesValue: unknown,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {},
): PaintBrushWeightIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
const strength = finite(strengthValue, "strength");
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]");
const viewDirection = options.viewDirection ?? [0, 0, -1];
tuple(viewDirection, 3, "viewDirection");
const result: PaintBrushWeightIR[] = [];
const seen = new Set<number>();
for (const [vertexIndex, item] of verticesValue.entries()) {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
if (vertex.normal !== undefined) {
const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue;
}
const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]);
if (distance > radius) continue;
const normalized = distance / radius;
const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized);
const weight = Math.max(0, Math.min(1, strength * smoothstep));
if (weight > 0) result.push({ index, weight });
}
return result.sort((left, right) => left.index - right.index);
}
export function parseUdimTilePatch(value: unknown): UdimTilePatchIR {
const patch = record(value, "udimPatch");
if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format");
const tile = integer(patch.tile, "udimPatch.tile");
if (tile < 1001 || tile > 1999) fail("udimPatch.tile", "must be in the supported UDIM range [1001,1999]");
const width = integer(patch.width, "udimPatch.width");
const height = integer(patch.height, "udimPatch.height");
if (width < 1 || height < 1 || width > 16_384 || height > 16_384) fail("udimPatch", "dimensions are outside the bounded range");
const byteOffset = integer(patch.byteOffset, "udimPatch.byteOffset");
if (!(patch.bytes instanceof Uint8Array) || patch.bytes.byteLength === 0 || patch.bytes.byteLength > PAINT_BUDGET.maxTextureTileBytes) fail("udimPatch.bytes", "exceeds the tile patch budget", true);
const tileBytes = width * height * 4;
if (tileBytes > PAINT_BUDGET.maxTextureTileBytes || byteOffset > tileBytes - patch.bytes.byteLength) fail("udimPatch.bytes", "is outside the RGBA8 tile range", tileBytes > PAINT_BUDGET.maxTextureTileBytes);
return {
schemaVersion: 1,
textureAssetId: string(patch.textureAssetId, "udimPatch.textureAssetId"),
tile,
revision: integer(patch.revision, "udimPatch.revision"),
width,
height,
format: "RGBA8",
colorSpace: patch.colorSpace,
baseSha256: digest(patch.baseSha256, "udimPatch.baseSha256"),
resultSha256: digest(patch.resultSha256, "udimPatch.resultSha256"),
byteOffset,
bytes: patch.bytes.slice(),
};
}
async function sha256(bytes: Uint8Array): Promise<string> {
const digestBytes = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
return Array.from(new Uint8Array(digestBytes), (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function applyUdimTilePatch(tileValue: unknown, patchValue: unknown, currentRevision: number): Promise<Uint8Array> {
if (!(tileValue instanceof Uint8Array)) fail("tile", "must be Uint8Array");
const patch = parseUdimTilePatch(patchValue);
if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("currentRevision", "must be a non-negative safe integer");
if (patch.revision !== currentRevision) throw new Error("REVISION_CONFLICT: UDIM tile patch is stale");
if (tileValue.byteLength !== patch.width * patch.height * 4) fail("tile", "length does not match the declared dimensions");
if (await sha256(tileValue) !== patch.baseSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile base digest is stale");
const result = tileValue.slice();
result.set(patch.bytes, patch.byteOffset);
if (await sha256(result) !== patch.resultSha256) throw new Error("PAINT_TILE_HASH_MISMATCH: UDIM tile result digest does not match the patch");
return result;
}

View File

@@ -61,6 +61,19 @@ export interface PhysicsFamilyCapabilityIR {
serverJob: "BLOCKED";
}
export interface BrowserTransformCacheObjectIR {
objectId: string;
translation: [number, number, number];
rotationQuaternion: [number, number, number, number];
scale: [number, number, number];
}
export interface BrowserTransformCacheFrameIR {
schemaVersion: 1;
frame: number;
objects: BrowserTransformCacheObjectIR[];
}
export class PhysicsSimulationValidationError extends Error {
readonly code: ErrorCode;
@@ -237,3 +250,46 @@ export function selectPhysicsCacheFrame(system: PhysicsSystemIR, requestedFrame:
}
return { cacheKey: cache.cacheKey, frame: requestedFrame };
}
const BROWSER_TRANSFORM_CACHE_MAGIC = 0x31465442; // BTF1
const BROWSER_TRANSFORM_CACHE_HEADER_BYTES = 16;
const BROWSER_TRANSFORM_CACHE_OBJECT_BYTES = 72;
export function decodeBrowserTransformCacheFrame(value: ArrayBuffer): BrowserTransformCacheFrameIR {
if (!(value instanceof ArrayBuffer) || value.byteLength < BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame is truncated");
}
const view = new DataView(value);
if (view.getUint32(0, true) !== BROWSER_TRANSFORM_CACHE_MAGIC || view.getUint16(4, true) !== 1 || view.getUint16(6, true) !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES) {
throw new PhysicsSimulationValidationError("PROTOCOL_MISMATCH", "Unsupported browser transform cache frame schema");
}
const frameNumber = view.getInt32(8, true);
const count = view.getUint32(12, true);
if (frameNumber < -1_000_000 || frameNumber > 1_000_000 || count > PHYSICS_SIMULATION_BUDGET.maxSystems || value.byteLength !== BROWSER_TRANSFORM_CACHE_HEADER_BYTES + count * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES) {
throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", "Browser transform cache frame length or count is invalid");
}
const decoder = new TextDecoder("utf-8", { fatal: true });
const objects: BrowserTransformCacheObjectIR[] = [];
const ids = new Set<string>();
for (let index = 0; index < count; index += 1) {
const offset = BROWSER_TRANSFORM_CACHE_HEADER_BYTES + index * BROWSER_TRANSFORM_CACHE_OBJECT_BYTES;
const idLength = view.getUint8(offset);
if (idLength === 0 || idLength > 31) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid ID length`);
let objectId: string;
try { objectId = decoder.decode(new Uint8Array(value, offset + 1, idLength)); }
catch { throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has invalid UTF-8`); }
if (!objectId.startsWith("object:") || ids.has(objectId)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid or duplicate ID`);
ids.add(objectId);
const numbers = Array.from({ length: 10 }, (_, component) => view.getFloat32(offset + 32 + component * 4, true));
if (numbers.some((component) => !Number.isFinite(component))) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has non-finite transforms`);
const quaternionLength = Math.hypot(numbers[3], numbers[4], numbers[5], numbers[6]);
if (Math.abs(quaternionLength - 1) > 1e-3 || numbers.slice(7).some((component) => component === 0)) throw new PhysicsSimulationValidationError("PHYSICS_CACHE_FRAME_MISMATCH", `Browser transform cache object ${index} has an invalid rotation or scale`);
objects.push({
objectId,
translation: numbers.slice(0, 3) as [number, number, number],
rotationQuaternion: numbers.slice(3, 7) as [number, number, number, number],
scale: numbers.slice(7, 10) as [number, number, number],
});
}
return { schemaVersion: 1, frame: frameNumber, objects };
}

View File

@@ -118,7 +118,7 @@ export type WebEngineEditCommand =
| { type: "moveGreasePencilLayer"; dataId: string; layerId: string; direction: "UP" | "DOWN" | "TOP" | "BOTTOM" }
| { type: "insertGreasePencilFrame"; dataId: string; layerId: string; frame: number; duration?: number }
| { type: "removeGreasePencilFrame"; dataId: string; layerId: string; frame: number }
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
| { type: "setGreasePencilStrokes"; dataId: string; layerId: string; frame: number; baseRevision?: number; strokes: Array<{ cyclic?: boolean; materialIndex?: number; points: Array<{ position: [number, number, number]; radius?: number; opacity?: number; vertexColor?: [number, number, number, number] }> }> }
| { type: "setVertexColors"; meshId: string; attributeName: string; domain: "POINT" | "CORNER"; indices: number[]; colors: number[] }
| { type: "setVertexWeights"; objectId: string; vertexGroup: string; indices: number[]; values: number[]; normalize?: boolean; mirror?: boolean }
| { type: "setLightProperties"; dataId: string; properties: { color?: [number, number, number]; energy?: number; exposure?: number; temperature?: number; useTemperature?: boolean; castsShadow?: boolean; radius?: number; spotAngle?: number; spotBlend?: number; areaSize?: number; areaSizeY?: number; areaSpread?: number; sunAngle?: number } }

View File

@@ -183,6 +183,26 @@ test("serializes concurrent saves for one project without journal races", async
expect(result).toEqual({ revision: 9, bytes: [9, 9, 9, 9], recovered: false });
});
test("deduplicates identical project revisions across independent StorageWorkers", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const first = new StorageClient();
const second = new StorageClient();
const projectId = `save-cross-worker-e2e-${Date.now()}`;
const bytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7]).buffer;
const saves = await Promise.all([
first.saveProject(projectId, 7, bytes.slice(0)),
second.saveProject(projectId, 7, bytes.slice(0)),
]);
const restored = await second.readProject(projectId);
first.terminate();
second.terminate();
return { revisions: saves.map((save) => save.revision), bytes: Array.from(new Uint8Array(restored.buffer)), recovered: restored.recovered };
});
expect(result).toEqual({ revisions: [7, 7], bytes: [0x42, 0x4c, 0x45, 0x4e, 0x44, 7], recovered: false });
});
test("content-addresses assets, deduplicates them, and rediscovers them after worker restart", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
@@ -494,6 +514,8 @@ test("validates the N-016 Grease Pencil editor context transaction boundary", as
expect(result.stale).toContain("REVISION_CONFLICT");
expect(result.duplicate).toContain("GREASE_PENCIL_EDITOR_INVALID");
expect(result.negative).toContain("GREASE_PENCIL_EDITOR_INVALID");
expect(result.translation).toEqual([1, [0, 0, 0], { position: [1.5, 1, 5], radius: 0.2, opacity: 0.8, vertexColor: [1, 0, 0, 1] }]);
expect(result.missingPoint).toContain("GREASE_PENCIL_EDITOR_INVALID");
});
test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => {
@@ -511,11 +533,22 @@ test("edits N-016 Grease Pencil layers and frames from the bounded editor panel"
await expect(editor).toContainText("2 layers / 2 frames / 1 strokes");
});
test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ page }) => {
test("moves an N-016 Grease Pencil point through one revision-bound Main transaction", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, string>>((resolve, reject) => {
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
await page.getByText("GreasePencilObject", { exact: true }).click();
const editor = page.getByTestId("grease-pencil-editor");
await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1.5, 0, 0");
await editor.getByLabel("Grease Pencil point X delta").fill("0.5");
await editor.getByRole("button", { name: "Move Point" }).click();
await expect(editor.getByTestId("grease-pencil-point-position")).toContainText("-1, 0, 0", { timeout: 20_000 });
});
test("enforces the N-017 paint stroke, bounded brush and UDIM patch budgets", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/paint-schema-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, string>>) => { worker.terminate(); resolve(event.data); };
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
@@ -523,6 +556,10 @@ test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({
expect(result.weight).toBe("Group");
expect(result.hit).toContain("PAINT_SCHEMA_INVALID");
expect(result.budget).toContain("PAINT_BUDGET_EXCEEDED");
expect(result.brush).toEqual([{ index: 1, weight: 0.4 }, { index: 2, weight: 0.8 }]);
expect((result.udim as number[]).slice(4, 8)).toEqual([10, 20, 30, 255]);
expect(result.udimRevision).toContain("REVISION_CONFLICT");
expect(result.udimStale).toContain("PAINT_TILE_HASH_MISMATCH");
});
test("derives an N-017 source-face, barycentric and UV paint hit from a real raycast", async ({ page }) => {
@@ -587,6 +624,8 @@ test("validates the N-018 physics capability and cache manifests without claimin
expect(result.missingFrame).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
expect(result.solver).toBe("PHYSICS_SOLVER_UNAVAILABLE");
expect(result.manifest).toBe("READY");
expect(result.browserPlayback).toEqual([7, "object:Cloth", [1, 2, 3]]);
expect(result.browserRotation).toContain("PHYSICS_CACHE_FRAME_MISMATCH");
});
test("maps N-019 Scene exposure and light shadow metadata without using legacy World exposure", async ({ page }) => {
@@ -866,7 +905,8 @@ test("autosaves a dirty project without starting a download", async ({ page }) =
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "已保存" }).click();
await expect(page.getByRole("button", { name: "保存" })).toBeVisible({ timeout: 5_000 });
await expect(page.getByRole("button", { name: "保存" })).toBeVisible();
await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 15_000 });
});
test("persists an operation log entry with an inverse payload", async ({ page }) => {