Advance N-015 through N-018 bounded workflows

This commit is contained in:
mes123456
2026-08-12 17:14:27 -04:00
parent 86136139e2
commit d54d5dd913
30 changed files with 662 additions and 59 deletions

View File

@@ -13,7 +13,7 @@
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3",
"sha256": "c726ef40a81b39b479a955a1fb1932ceaef4a87cefc6443f9e4c0c96de1b814c",
"required": true
}
]

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -20,6 +20,7 @@ import { modifierStackHash } from "../../../protocol/modifier";
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 { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import "./app-shell.css";
@@ -179,9 +180,10 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
);
}
function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, onCommand, onImportImage, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
snapshot: SceneSnapshotIR | null;
selectedFaceIndices: number[];
selectedVertexIndices: number[];
onCommand: (command: WebEngineEditCommand) => void;
onImportImage: (file: File) => void;
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
@@ -211,8 +213,14 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
const [materialCoatRoughness, setMaterialCoatRoughness] = useState(0.03);
const [materialEmissionStrength, setMaterialEmissionStrength] = useState(1);
const [renameValue, setRenameValue] = useState("");
const [paintColor, setPaintColor] = useState("#cc6633");
const [paintWeight, setPaintWeight] = useState(1);
const [paintGroup, setPaintGroup] = useState("WebPaint");
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
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;
const activeMaterial = snapshot?.materials.find((material) => material.id === activeMesh?.materialSlotIds?.[0]);
useEffect(() => {
if (!activeMaterial) return;
@@ -227,6 +235,10 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1);
}, [activeMaterial]);
useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]);
useEffect(() => {
if (!activeGreasePencil) setGreasePencilLayerId("");
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
}, [activeGreasePencil, greasePencilLayerId]);
const toggleDelimit = (value: SimplifyDelimit): void => {
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
};
@@ -268,6 +280,8 @@ function Properties({ snapshot, selectedFaceIndices, onCommand, onImportImage, o
{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}
{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>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
@@ -556,22 +570,40 @@ export function App() {
};
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
if (!activeNode) return;
if (!snapshot || !activeNode) return;
if (uiState.context.mode === "Edit" && activeNode.dataId) {
const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId);
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
const handlePoints = nonMesh.handlePoints.slice();
const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: handlePoints.length / 6 }, (_, index) => index);
const handles: Array<{ pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }> = [];
for (const [kind, selected] of meshSelection.nonMeshSelections) {
if (kind === "CONTROL_POINT") continue;
const sideOffset = kind === "HANDLE_RIGHT" ? 3 : 0;
for (const pointIndex of selected) {
const packedPointIndex = pointIndices.indexOf(pointIndex);
if (packedPointIndex < 0) continue;
handlePoints[packedPointIndex * 6 + sideOffset + axis] += amount;
handles.push({ pointIndex, side: kind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position: handlePoints.slice(packedPointIndex * 6 + sideOffset, packedPointIndex * 6 + sideOffset + 3) as [number, number, number] });
}
}
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
if (handles.length === 0) return;
let applied;
try {
const delta: [number, number, number] = [0, 0, 0];
delta[axis] = amount;
applied = applyCurveGizmoDelta({ schemaVersion: 1, dataId: nonMesh.id, baseRevision: snapshot.revision, phase: "COMMIT", axis, delta, handles }, snapshot.revision);
}
catch (error) {
setEngineStatus(`Curve gizmo rejected${error instanceof Error ? ` (${error.message})` : ""}`);
return;
}
for (const handle of applied.handles) {
const packedPointIndex = pointIndices.indexOf(handle.pointIndex);
if (packedPointIndex < 0) continue;
const sideOffset = handle.side === "RIGHT" ? 3 : 0;
handlePoints.splice(packedPointIndex * 6 + sideOffset, 3, ...handle.position);
}
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, baseRevision: snapshot.revision, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
return;
}
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);
@@ -970,7 +1002,7 @@ export function App() {
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,15 @@
import { applyGreasePencilEditorEdit, parseGreasePencilEditor } from "../../../protocol/grease-pencil-editor";
self.onmessage = () => {
const result: Record<string, unknown> = {};
const base = { schemaVersion: 1, revision: 0, dataId: "grease-pencil:Data", layerId: "layer:Lines", frame: 1, onionSkinning: true, selectedStrokeIndices: [], selectedPoints: [] };
try {
const frame = applyGreasePencilEditorEdit(base, { type: "SET_FRAME", revision: 0, frame: 5 });
const selection = applyGreasePencilEditorEdit(frame, { type: "SET_SELECTION", revision: 1, strokeIndices: [2, 0], points: [{ strokeIndex: 2, pointIndex: 1 }] });
result.edit = [selection.revision, selection.frame, selection.selectedStrokeIndices, selection.selectedPoints];
} catch (error) { result.edit = error instanceof Error ? error.message : String(error); }
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); }
self.postMessage(result);
};

View File

@@ -0,0 +1,11 @@
import { applyCurveGizmoDelta } from "../../../protocol/nonmesh-interaction";
self.onmessage = () => {
const result: Record<string, unknown> = {};
const base = { schemaVersion: 1, dataId: "curve:1", baseRevision: 3, phase: "COMMIT", axis: 0, delta: [0.25, 0, 0], handles: [{ pointIndex: 2, side: "LEFT", position: [1, 2, 3] }] };
try { const applied = applyCurveGizmoDelta(base, 3); result.commit = [applied.revision, applied.handles[0].position]; } catch (error) { result.commit = error instanceof Error ? error.message : String(error); }
try { applyCurveGizmoDelta(base, 4); } catch (error) { result.stale = error instanceof Error ? error.message : String(error); }
try { applyCurveGizmoDelta({ ...base, handles: [{ ...base.handles[0] }, { ...base.handles[0] }] }, 3); } catch (error) { result.duplicate = error instanceof Error ? error.message : String(error); }
try { applyCurveGizmoDelta({ ...base, delta: [0.25, 0.25, 0] }, 3); } catch (error) { result.axis = error instanceof Error ? error.message : String(error); }
self.postMessage(result);
};

View File

@@ -223,6 +223,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
case "setCurveTopology": {
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
if (!data || (data.type !== "CURVE" && data.type !== "SURFACE")) throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 curve data block is unavailable: ${payload.dataId}`);
if (payload.baseRevision !== undefined && payload.baseRevision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Curve topology base revision does not match the current SceneIR");
const splineCount = data.splineCount;
if (payload.splineTypes !== undefined && (payload.splineTypes.length !== splineCount || payload.splineTypes.some((value) => !["POLY", "BEZIER", "NURBS"].includes(value)))) throw report("NON_MESH_PROPERTY_INVALID", "Curve spline types must match the existing topology");
for (const values of [payload.cyclicU, payload.cyclicV]) if (values !== undefined && values.length !== splineCount) throw report("NON_MESH_PROPERTY_INVALID", "Curve cyclic flags must match the spline count");

View File

@@ -13,6 +13,7 @@
"test:e2e": "playwright test --config playwright.config.ts",
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
"test:browser": "playwright test --config playwright.release.config.ts",
"test:cross-browser": "npm run test:browser",
"test:golden": "node ../tools/web/run-blender-golden.mjs",
@@ -27,7 +28,10 @@
"test:nonmesh-roundtrip": "node ../tools/web/check-nonmesh-roundtrip.mjs",
"test:nonmesh-desktop-golden": "node ../tools/web/check-nonmesh-desktop-golden.mjs",
"test:selection-history": "playwright test --config playwright.config.ts -g \"N-015 selection history\"",
"test:nonmesh-interaction": "playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"",
"test:vdb-availability": "node ../tools/web/check-vdb-availability.mjs",
"test:grease-pencil": "node ../tools/web/check-grease-pencil-roundtrip.mjs",
"test:grease-pencil-editor": "playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"",
"test:paint-roundtrip": "node ../tools/web/check-paint-roundtrip.mjs",
"test:lighting-roundtrip": "node ../tools/web/check-lighting-roundtrip.mjs",
"test:compositor-main-reader": "node ../tools/web/check-compositor-main-reader.mjs",

View File

@@ -0,0 +1,92 @@
export const GREASE_PENCIL_EDITOR_SCHEMA = 1 as const;
export const GREASE_PENCIL_EDITOR_BUDGET = { maxSelection: 1_000_000, maxIdLength: 256 } as const;
export interface GreasePencilPointSelectionIR { strokeIndex: number; pointIndex: number }
export interface GreasePencilEditorIR {
schemaVersion: typeof GREASE_PENCIL_EDITOR_SCHEMA;
revision: number;
dataId: string;
layerId: string;
frame: number;
onionSkinning: boolean;
selectedStrokeIndices: number[];
selectedPoints: GreasePencilPointSelectionIR[];
}
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[] };
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> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
return value as Record<string, unknown>;
}
function id(value: unknown, path: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxIdLength) fail(path, "is invalid");
return value;
}
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 nonnegativeIndex(value: unknown, path: string): number {
const result = integer(value, path);
if (result < 0) fail(path, "must be non-negative");
return result;
}
function uniqueIndices(value: unknown, path: string): number[] {
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
const result = value.map((item, itemIndex) => nonnegativeIndex(item, `${path}[${itemIndex}]`));
if (new Set(result).size !== result.length) fail(path, "contains duplicates");
return result.sort((left, right) => left - right);
}
function points(value: unknown, path: string): GreasePencilPointSelectionIR[] {
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
const seen = new Set<string>();
const result = value.map((item, index) => {
const point = record(item, `${path}[${index}]`);
const parsed = { strokeIndex: nonnegativeIndex(point.strokeIndex, `${path}[${index}].strokeIndex`), pointIndex: nonnegativeIndex(point.pointIndex, `${path}[${index}].pointIndex`) };
const key = `${parsed.strokeIndex}:${parsed.pointIndex}`;
if (seen.has(key)) fail(`${path}[${index}]`, "contains duplicates");
seen.add(key);
return parsed;
});
return result.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex);
}
export function parseGreasePencilEditor(value: unknown): GreasePencilEditorIR {
const editor = record(value, "editor");
if (editor.schemaVersion !== GREASE_PENCIL_EDITOR_SCHEMA) fail("schemaVersion", "is unsupported");
if (typeof editor.revision !== "number" || !Number.isSafeInteger(editor.revision) || editor.revision < 0) fail("revision", "is invalid");
if (typeof editor.frame !== "number" || !Number.isSafeInteger(editor.frame) || editor.frame < -1_000_000 || editor.frame > 1_000_000) fail("frame", "is invalid");
if (typeof editor.onionSkinning !== "boolean") fail("onionSkinning", "must be boolean");
return {
schemaVersion: GREASE_PENCIL_EDITOR_SCHEMA,
revision: editor.revision,
dataId: id(editor.dataId, "dataId"),
layerId: id(editor.layerId, "layerId"),
frame: editor.frame,
onionSkinning: editor.onionSkinning,
selectedStrokeIndices: uniqueIndices(editor.selectedStrokeIndices, "selectedStrokeIndices"),
selectedPoints: points(editor.selectedPoints, "selectedPoints"),
};
}
export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown): GreasePencilEditorIR {
const editor = parseGreasePencilEditor(value);
const edit = record(editValue, "edit");
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
const revision = editor.revision + 1;
switch (edit.type) {
case "SET_FRAME": return parseGreasePencilEditor({ ...editor, revision, frame: integer(edit.frame, "edit.frame") });
case "SET_LAYER": return parseGreasePencilEditor({ ...editor, revision, layerId: id(edit.layerId, "edit.layerId") });
case "SET_ONION":
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") });
default: fail("edit.type", "is unsupported");
}
}

View File

@@ -0,0 +1,93 @@
export const CURVE_GIZMO_SCHEMA = 1 as const;
export const CURVE_GIZMO_BUDGET = {
maxHandles: 256,
maxIdLength: 256,
maxCoordinate: 1_000_000,
} as const;
export type CurveGizmoHandleSide = "LEFT" | "RIGHT" | "CONTROL";
export type CurveGizmoPhase = "PREVIEW" | "COMMIT";
export interface CurveGizmoHandleIR {
pointIndex: number;
side: CurveGizmoHandleSide;
position: [number, number, number];
}
export interface CurveGizmoDragIR {
schemaVersion: typeof CURVE_GIZMO_SCHEMA;
dataId: string;
baseRevision: number;
phase: CurveGizmoPhase;
axis: 0 | 1 | 2;
delta: [number, number, number];
handles: CurveGizmoHandleIR[];
}
export interface AppliedCurveGizmoDragIR {
dataId: string;
phase: CurveGizmoPhase;
axis: 0 | 1 | 2;
revision: number;
handles: CurveGizmoHandleIR[];
}
function fail(path: string, message: string): never {
throw new Error(`CURVE_GIZMO_INVALID: ${path} ${message}`);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
return value as Record<string, unknown>;
}
function integer(value: unknown, path: string, minimum = 0): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(path, "must be a bounded integer");
return value;
}
function finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > CURVE_GIZMO_BUDGET.maxCoordinate) fail(path, "is outside the finite coordinate budget");
return value;
}
function vector(value: unknown, path: string): [number, number, number] {
if (!Array.isArray(value) || value.length !== 3) fail(path, "must contain three coordinates");
return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )];
}
export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR {
const drag = record(value, "drag");
if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported");
if (typeof drag.dataId !== "string" || drag.dataId.length === 0 || drag.dataId.length > CURVE_GIZMO_BUDGET.maxIdLength) fail("dataId", "is invalid");
const baseRevision = integer(drag.baseRevision, "baseRevision");
if (expectedRevision !== undefined && baseRevision !== expectedRevision) throw new Error("REVISION_CONFLICT: Curve gizmo request is stale");
if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid");
if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z");
const delta = vector(drag.delta, "delta");
if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
const seen = new Set<string>();
const handles = drag.handles.map((item, index) => {
const handle = record(item, `handles[${index}]`);
const pointIndex = integer(handle.pointIndex, `handles[${index}].pointIndex`);
if (handle.side !== "LEFT" && handle.side !== "RIGHT" && handle.side !== "CONTROL") fail(`handles[${index}].side`, "is invalid");
const side = handle.side as CurveGizmoHandleSide;
const key = `${pointIndex}:${side}`;
if (seen.has(key)) fail(`handles[${index}]`, "duplicates a selected handle");
seen.add(key);
return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) };
});
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles };
}
export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR {
const drag = parseCurveGizmoDrag(value, expectedRevision);
const handles = drag.handles.map((handle) => ({
...handle,
position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number],
}));
handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`));
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
}

View File

@@ -6,6 +6,7 @@ import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking
import { normalizeProjectAssetPath } from "./asset-path";
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation";
export type SceneNodeType =
| "EMPTY"
@@ -482,6 +483,7 @@ export interface SceneSnapshotIR {
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
scriptSources?: ScriptSourceInventoryIR;
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
physicsSimulation?: PhysicsSimulationManifestIR;
libraries?: Array<{
id: string;
name: string;
@@ -1132,5 +1134,6 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
parseScriptSourceInventory(value.scriptSources);
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
}
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
return value as unknown as SceneSnapshotIR;
}

View File

@@ -106,7 +106,7 @@ export type WebEngineEditCommand =
| { type: "deleteNonMeshData"; dataId: string }
| { type: "setCurveControlPoints"; dataId: string; controlPoints: number[]; splineOffsets?: number[]; resolution?: number }
| { type: "setCurveHandle"; dataId: string; pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }
| { type: "setCurveTopology"; dataId: string; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
| { type: "setCurveTopology"; dataId: string; baseRevision?: number; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
| { type: "setCurveSplines"; dataId: string; splineTypes: NonMeshCurveSplineType[]; splineOffsets: number[]; ordersU: number[]; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; handleTypes: number[]; handlePoints: number[] }
| { type: "setSurfaceTopology"; dataId: string; splineDimensions: Array<{ u: number; v: number; orderU: number; orderV: number }>; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; cyclicV: boolean[] }
| { type: "setFontBody"; dataId: string; body: string }

View File

@@ -482,6 +482,35 @@ for (const offscreen of [false, true]) {
});
}
test("validates the N-016 Grease Pencil editor context transaction boundary", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/grease-pencil-editor-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.edit).toEqual([2, 5, [0, 2], [{ strokeIndex: 2, pointIndex: 1 }]]);
expect(result.stale).toContain("REVISION_CONFLICT");
expect(result.duplicate).toContain("GREASE_PENCIL_EDITOR_INVALID");
expect(result.negative).toContain("GREASE_PENCIL_EDITOR_INVALID");
});
test("edits N-016 Grease Pencil layers and frames from the bounded editor panel", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", greasePencilBlend);
await expect(page.getByText("GreasePencilObject", { exact: true })).toBeVisible({ timeout: 20_000 });
await page.getByText("GreasePencilObject", { exact: true }).click();
const editor = page.getByTestId("grease-pencil-editor");
await expect(editor).toContainText("1 layers / 1 frames / 1 strokes");
await editor.getByLabel("Grease Pencil new layer name").fill("Browser Draft");
await editor.getByRole("button", { name: "Add Layer" }).click();
await expect(editor).toContainText("2 layers / 1 frames / 1 strokes");
await editor.getByLabel("Grease Pencil layer").selectOption({ label: "Browser Draft" });
await editor.getByRole("button", { name: "Add Frame" }).click();
await expect(editor).toContainText("2 layers / 2 frames / 1 strokes");
});
test("enforces the N-017 paint stroke, PBVH/UV hit and brush budgets", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, string>>((resolve, reject) => {
@@ -523,6 +552,24 @@ test("derives an N-017 source-face, barycentric and UV paint hit from a real ray
expect(result?.normal).toEqual([0, 0, 1]);
});
test("commits N-017 vertex color and weight patches from the bounded paint panel", async ({ page }) => {
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible({ timeout: 20_000 });
await page.getByText("AttributeMeshObject", { exact: true }).click();
await page.getByRole("button", { name: /Object Mode/ }).click();
await page.getByRole("button", { name: "1 Vertex" }).click();
await page.getByRole("button", { name: "Select All" }).click();
const editor = page.getByTestId("paint-editor");
await expect(editor).toContainText("5 selected vertices");
await editor.getByLabel("Paint vertex color").fill("#336699");
await editor.getByRole("button", { name: "Apply Color" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
await editor.getByLabel("Paint vertex group").fill("BrowserPaint");
await editor.getByRole("button", { name: "Apply Weight" }).click();
await expect(page.getByTestId("engine-status")).toContainText("SceneIR r", { timeout: 20_000 });
});
test("validates the N-018 physics capability and cache manifests without claiming solvers", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -714,6 +761,20 @@ test("keeps N-015 selection history bounded and rejects stale raycast hits", asy
expect(result.gates).toEqual(["READY", "READY", "BLOCKED"]);
});
test("validates the N-015 curve gizmo interaction transaction boundary", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
const worker = new Worker("/src/workers/nonmesh-interaction-test.worker.ts", { type: "module" });
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.postMessage({});
}));
expect(result.commit).toEqual([4, [1.25, 2, 3]]);
expect(result.stale).toContain("REVISION_CONFLICT");
expect(result.duplicate).toContain("CURVE_GIZMO_INVALID");
expect(result.axis).toContain("CURVE_GIZMO_INVALID");
});
test("validates bounded OpenVDB metadata, SHA and cancellation", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(() => new Promise<{ decodedByteLength: number; outsideProject: string; cancelled: boolean }>((resolve, reject) => {