Advance bounded editor and cache workflows
This commit is contained in:
@@ -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)})`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user