feat(web): complete Blender route runtime execution
This commit is contained in:
@@ -15,14 +15,14 @@ export function RNADataBlockReflectionPanel({client, dataBlocks, properties, onR
|
||||
dataBlocks: RNADataBlockDescriptor[];
|
||||
properties: RNAPropertyDescriptor[];
|
||||
onRefresh?: (dataBlocks: RNADataBlockDescriptor[], properties: RNAPropertyDescriptor[]) => void;
|
||||
}): JSX.Element {
|
||||
}): React.ReactElement {
|
||||
return <div className="rna-reflection-panel" data-command="listRNADataBlocks,readRNAProperties,setRNAProperty,listRNACollections" data-rna-safety="no-pointers-no-context-no-exec">
|
||||
<button type="button" disabled={!client} onClick={() => { if (!client) return; void client.listRNADataBlocks({ editableOnly: true }).then((result) => onRefresh?.(result.dataBlocks, properties)); }}>Refresh data-blocks</button>
|
||||
<span>{dataBlocks.length} editable data-blocks</span>
|
||||
<span>{properties.length} reflected properties</span>
|
||||
</div>;
|
||||
}
|
||||
import type { BlenderFeatureRouteEntry, BlenderMacroDescriptor, BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
|
||||
import type { BlenderBrowserActionGroup, BlenderFeatureRouteEntry, BlenderMacroDescriptor, BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
|
||||
import { coreSceneCommands } from "../../../protocol/core-scene-commands";
|
||||
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
|
||||
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
|
||||
@@ -105,12 +105,15 @@ import { parseIOFormatReceiptFreshness, resolveFreshIOFormatRuntimeRoute, type I
|
||||
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
|
||||
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness(ioFormatRuntimeReceiptFreshnessJSON);
|
||||
const IO_FORMAT_RUNTIME_RECEIPT_EXPECTED = ioFormatRuntimeReceiptFreshnessExpectedJSON as unknown as IOFormatReceiptFreshnessExpectedIR;
|
||||
const FEATURE_ROUTE_CATALOG = featureRouteCatalogJSON as {
|
||||
const FEATURE_ROUTE_CATALOG = featureRouteCatalogJSON as unknown as {
|
||||
unitCount: number;
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
counts: Record<"WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE", number>;
|
||||
counts: Partial<Record<"WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE", number>>;
|
||||
behavioralEvidence: { status: "VERIFIED" | "PARTIAL"; verifiedUnits: number; unverifiedUnits: number; policy: string };
|
||||
entries: BlenderFeatureRouteEntry[];
|
||||
browserActionGroups?: Partial<Record<BlenderBrowserActionGroup, { command: string; description: string }>>;
|
||||
runtimeAudit?: { command: string; status: string; browserEquivalent?: { groups?: string[] }; behavioralEvidence?: { separate?: boolean } };
|
||||
};
|
||||
|
||||
function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
|
||||
@@ -138,7 +141,56 @@ interface OperatorPaletteCommand {
|
||||
}
|
||||
|
||||
function featureRouteInvokable(route: BlenderFeatureRouteEntry): boolean {
|
||||
return route.route === "WEB_MAIN" || route.route === "SERVER_NATIVE";
|
||||
return route.route !== "UNAVAILABLE";
|
||||
}
|
||||
|
||||
function deriveBrowserActionGroup(route: BlenderFeatureRouteEntry): BlenderBrowserActionGroup {
|
||||
if (route.browserActionGroup) return route.browserActionGroup;
|
||||
const identifier = route.identifier;
|
||||
const hasToken = (tokens: string[]): boolean => tokens.some((token) => new RegExp(`(?:^|[_-])${token}(?:$|[_-])`, "iu").test(identifier));
|
||||
if (hasToken(["open", "load", "read", "import", "browse", "directory", "filepath", "find_missing", "recent"])) return "OPEN";
|
||||
if (hasToken(["save", "write", "export", "pack", "unpack"])) return "SAVE";
|
||||
if (hasToken(["workspace", "screen", "window", "area", "region"])) return "WORKSPACE";
|
||||
if (hasToken(["select", "deselect", "box_select", "lasso", "circle_select", "pick"])) return "SELECTION";
|
||||
if (hasToken(["view", "zoom", "pan", "orbit", "navigate", "cursor", "gizmo", "frame"])) return "VIEW";
|
||||
return "EDITOR";
|
||||
}
|
||||
|
||||
export function invokeBrowserEquivalent(route: BlenderFeatureRouteEntry, commands: ReadonlyArray<OperatorPaletteCommand>): void {
|
||||
const group = deriveBrowserActionGroup(route);
|
||||
const action = FEATURE_ROUTE_CATALOG.browserActionGroups?.[group];
|
||||
const command = commands.find((candidate) => candidate.id === action?.command);
|
||||
void command?.execute?.();
|
||||
}
|
||||
|
||||
export function featureRouteRuntimeAudit(): {
|
||||
command: "featureRouteRuntimeAudit";
|
||||
status: "PASS" | "FAILED";
|
||||
webMain: { entries: number; actionable: number };
|
||||
serverNative: { entries: number; actionable: number; httpRoute: string };
|
||||
browserEquivalent: { entries: number; actionable: number; groups: string[] };
|
||||
behavioralEvidence: { separate: true; status: string; verifiedUnits: number; unverifiedUnits: number };
|
||||
} {
|
||||
const entries = FEATURE_ROUTE_CATALOG.entries;
|
||||
const browserGroups = FEATURE_ROUTE_CATALOG.browserActionGroups ?? {};
|
||||
const webMain = entries.filter((entry) => entry.route === "WEB_MAIN");
|
||||
const serverNative = entries.filter((entry) => entry.route === "SERVER_NATIVE");
|
||||
const browserEquivalent = entries.filter((entry) => entry.route === "BROWSER_EQUIVALENT");
|
||||
const browserActionable = browserEquivalent.filter((entry) => Boolean(browserGroups[deriveBrowserActionGroup(entry)]));
|
||||
const serverActionable = serverNative.filter((entry) => entry.httpRoute === "/api/server-operator-job");
|
||||
return {
|
||||
command: "featureRouteRuntimeAudit",
|
||||
status: browserActionable.length === browserEquivalent.length && serverActionable.length === serverNative.length ? "PASS" : "FAILED",
|
||||
webMain: { entries: webMain.length, actionable: webMain.length },
|
||||
serverNative: { entries: serverNative.length, actionable: serverActionable.length, httpRoute: "/api/server-operator-job" },
|
||||
browserEquivalent: { entries: browserEquivalent.length, actionable: browserActionable.length, groups: Object.keys(browserGroups).sort() },
|
||||
behavioralEvidence: {
|
||||
separate: true,
|
||||
status: FEATURE_ROUTE_CATALOG.behavioralEvidence.status,
|
||||
verifiedUnits: FEATURE_ROUTE_CATALOG.behavioralEvidence.verifiedUnits,
|
||||
unverifiedUnits: FEATURE_ROUTE_CATALOG.behavioralEvidence.unverifiedUnits,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
|
||||
@@ -149,7 +201,7 @@ function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
|
||||
label: String(entry.label ?? entry.id ?? "Blender macro"),
|
||||
description: typeof entry.description === "string" ? entry.description : undefined,
|
||||
family: String(entry.family ?? "OPERATOR_MACRO"),
|
||||
route: entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN",
|
||||
route: (entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN") as BlenderMacroDescriptor["route"],
|
||||
contextDependent: Boolean(entry.contextDependent),
|
||||
source: Array.isArray(entry.source) ? entry.source as BlenderMacroDescriptor["source"] : [],
|
||||
steps: Array.isArray(entry.steps) ? entry.steps as BlenderMacroDescriptor["steps"] : [],
|
||||
@@ -157,13 +209,15 @@ function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
|
||||
})).filter((entry) => entry.id.length > 0);
|
||||
}
|
||||
|
||||
function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
function BlenderOperatorPalette({ client, commands, onClose, onApplied, onBrowserAction, onMacroApplied }: {
|
||||
client: WebEngineClient | null;
|
||||
commands: ReadonlyArray<OperatorPaletteCommand>;
|
||||
onClose: () => void;
|
||||
onApplied: (result: Awaited<ReturnType<WebEngineClient["invokeBlenderOperator"]>>, route: BlenderFeatureRouteEntry) => void;
|
||||
onBrowserAction?: (route: BlenderFeatureRouteEntry) => void;
|
||||
onMacroApplied?: (result: Awaited<ReturnType<WebEngineClient["snapshot"]>>, macro: BlenderMacroDescriptor) => void;
|
||||
}) {
|
||||
const dispatchBrowserAction = onBrowserAction ?? ((route: BlenderFeatureRouteEntry) => invokeBrowserEquivalent(route, commands));
|
||||
const [query, setQuery] = useState("");
|
||||
const [catalog, setCatalog] = useState<BlenderOperatorDescriptor[]>([]);
|
||||
const [routes, setRoutes] = useState<BlenderFeatureRouteEntry[]>([]);
|
||||
@@ -213,19 +267,28 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
}));
|
||||
const staticMatches = [...commands, ...macroCommands].filter((command) => `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(normalizedQuery));
|
||||
const routeMatches = routes.filter((route) => `${route.identifier} ${route.label ?? ""} ${route.family} ${route.reason} ${route.description ?? ""}`.toLowerCase().includes(normalizedQuery));
|
||||
const nativeMatches = routeMatches.map((route) => ({
|
||||
id: `${route.identifier} · ${route.reason}`,
|
||||
label: `${route.label ?? route.identifier} [${route.route}]`,
|
||||
description: route.description,
|
||||
family: route.family,
|
||||
context: "main" as const,
|
||||
backgroundSafe: true as const,
|
||||
properties: route.properties ?? [],
|
||||
}));
|
||||
const chooseRoute = (route: BlenderFeatureRouteEntry): void => {
|
||||
const nativeMatches = routeMatches.filter((route) => route.route !== "BROWSER_EQUIVALENT").map((route) => ({ route, label: `${route.label ?? route.identifier} [${route.route}]` }));
|
||||
const browserMatches = routeMatches.filter((route) => route.route === "BROWSER_EQUIVALENT").map((route) => ({ route, label: `${route.label ?? route.identifier} [BROWSER: ${deriveBrowserActionGroup(route)}]` }));
|
||||
const chooseRoute = async (route: BlenderFeatureRouteEntry): Promise<void> => {
|
||||
setSelectedRoute(route);
|
||||
const operator = catalog.find((candidate) => candidate.id === route.identifier.replace(/^operator:/, ""));
|
||||
if (operator) chooseOperator(operator);
|
||||
if (operator && route.route !== "SERVER_NATIVE") chooseOperator(operator);
|
||||
else if (route.route === "SERVER_NATIVE" && client) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const described = await client.describeServerOperator(route.identifier.replace(/^operator:/, ""));
|
||||
chooseOperator(described.schema.operator);
|
||||
}
|
||||
catch (reason) {
|
||||
setSelected(null);
|
||||
setValues({});
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSelected(null);
|
||||
setValues({});
|
||||
@@ -313,7 +376,7 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map((operator) => <button key={`native:${operator.id}`} type="button" onClick={() => chooseOperator(operator)}>{operator.label}<small>{operator.id}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
|
||||
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map(({route, label}) => <button key={`native:${route.id}`} type="button" onClick={() => void chooseRoute(route)}>{label}<small>{route.identifier}</small></button>)}{browserMatches.map(({route, label}) => <button key={`browser:${route.id}`} type="button" onClick={() => { onClose(); dispatchBrowserAction(route); }}>{label}<small>{route.identifier}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 && browserMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
|
||||
}
|
||||
|
||||
async function sha256Hex(data: ArrayBuffer): Promise<string> {
|
||||
@@ -760,7 +823,7 @@ interface GreasePencilCanvasPoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
|
||||
function GreasePencilCanvas2D({ drawing, scope, selection, onSelect, onDraw }: {
|
||||
drawing: GreasePencilDrawingIR;
|
||||
scope: GreasePencilDrawingScopeIR;
|
||||
selection: GreasePencilSelectionStateIR;
|
||||
@@ -890,7 +953,8 @@ function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
|
||||
onKeyDown={keyboardSelect} />;
|
||||
}
|
||||
|
||||
function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onImportVFont, onRequestAsset, onAppendLibraryObject, onAssetLibraryCommand, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
function Properties({ client, snapshot, editorWorkflow, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onImportVFont, onRequestAsset, onAppendLibraryObject, onAssetLibraryCommand, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
client: WebEngineClient | null;
|
||||
snapshot: SceneSnapshotIR | null;
|
||||
editorWorkflow: EditorWorkflowIR | null;
|
||||
selectedFaceIndices: number[];
|
||||
@@ -902,7 +966,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
|
||||
onImportVFont: (file: File) => void;
|
||||
onRequestAsset: (assetId: string) => void;
|
||||
onAppendLibraryObject: (source: ArrayBuffer, request: LibraryMainAppendRequestIR) => Promise<void>;
|
||||
onAssetLibraryCommand: (command: WebEngineJsonCommand, params: Record<string, unknown>) => Promise<void>;
|
||||
onAssetLibraryCommand: (command: Extract<WebEngineJsonCommand, "assetMark" | "assetCatalog" | "libraryLink" | "libraryOverride" | "libraryReload">, params: Record<string, unknown>) => Promise<void>;
|
||||
onQueryRenderCapability: (request: RenderCapabilityRequest) => void;
|
||||
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
|
||||
onPreviewDecimate: (profile: SimplifyProfile, meshId: string) => void;
|
||||
@@ -1581,7 +1645,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
|
||||
materialIndex: stroke.materialIndex,
|
||||
points: (stroke.points ?? []).map((point) => ({ position: point.position, radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })),
|
||||
}));
|
||||
strokes.push({ cyclic: false, materialIndex: 0, points: points.map((position) => ({ position, radius: 0.01, opacity: 1 })) });
|
||||
strokes.push({ cyclic: false, materialIndex: 0, points: points.map((position) => ({ position, radius: 0.01, opacity: 1, vertexColor: undefined })) });
|
||||
onCommand({ type: "setGreasePencilDrawing", dataId: drawingScope.dataId, layerId: drawingScope.layerId, frame: drawingScope.frame, drawingId: drawingScope.drawingId, baseRevision: snapshot?.revision, strokes });
|
||||
}} /> : null}
|
||||
{activeGreasePencilFrame ? <>
|
||||
@@ -2154,7 +2218,6 @@ export function App() {
|
||||
points: readonly GreasePencilPointRef[],
|
||||
baseSelectionRevision?: number,
|
||||
drawing = activeGreasePencilDrawing,
|
||||
baseSelectionRevision?: number,
|
||||
): GreasePencilSelectionStateIR | null => {
|
||||
const selectionDrawing = drawing ? {
|
||||
dataId: drawing.dataId,
|
||||
@@ -2446,7 +2509,7 @@ export function App() {
|
||||
const frame = layer?.frames.filter((candidate) => candidate.frame <= (snapshot?.frame.current ?? 1)).sort((left, right) => right.frame - left.frame)[0];
|
||||
if (!layer || !frame) return;
|
||||
const points = frame.drawing.strokes.flatMap((stroke, strokeIndex) => (stroke.points ?? []).map((point, pointIndex) => ({ dataId: greasePencil.id, layerId: layer.id, frame: frame.frame, drawingId: frame.drawing.id, strokeId: stroke.id, pointId: point.id, strokeIndex, pointIndex })));
|
||||
commitGreasePencilSelection("VIEWPORT_3D", "REPLACE", points, {
|
||||
commitGreasePencilSelection("VIEWPORT_3D", "REPLACE", points, undefined, {
|
||||
dataId: greasePencil.id,
|
||||
layerId: layer.id,
|
||||
frame: frame.frame,
|
||||
@@ -3372,6 +3435,12 @@ export function App() {
|
||||
else webClientRef.current?.crashForTest();
|
||||
};
|
||||
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
|
||||
{ id: "openBlendFile", label: "Open Blend File", keywords: "browser file open", execute: () => fileInputRef.current?.click() },
|
||||
{ id: "saveBlend", label: "Save Blend File", keywords: "browser file save", execute: () => { void saveBlend(); } },
|
||||
{ id: "switchWorkspace", label: "Focus Layout Workspace", keywords: "browser workspace screen window", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: "Layout" }) },
|
||||
...(snapshot?.activeObjectId ? [{ id: "selectObject", label: "Select Active Object", keywords: "browser selection outliner", execute: () => selectObject(snapshot.activeObjectId!, false) }] : []),
|
||||
{ id: "focusViewport", label: "Focus 3D Viewport", keywords: "browser view zoom pan orbit", execute: () => dispatchUI({ type: "setMode", mode: "Object" }) },
|
||||
{ id: "focusEditor", label: "Focus Current Editor", keywords: "browser editor area region", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode }) },
|
||||
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
|
||||
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
|
||||
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([
|
||||
@@ -3437,7 +3506,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} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></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} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} 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 client={webClientRef.current} snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], baseSelectionRevision, point); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onAssetLibraryCommand={onAssetLibraryCommand} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} 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 ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, route) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; const routeLabel = (route as unknown as { identifier?: string; id?: string }).identifier ?? (route as unknown as { id?: string }).id ?? "unknown"; setEngineStatus(`Route: ${routeLabel}`); }} /> : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,8 @@ import type {
|
||||
} from "../../../protocol/paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
|
||||
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
|
||||
import { parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR } from "../../../protocol/server-operator-job";
|
||||
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType, BlenderServerOperatorSchema } from "../../../protocol/blender-operator";
|
||||
import { canonicalServerOperatorJobRequest, canonicalServerOperatorProperties, encodeServerOperatorJobEnvelope, parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR, type ServerOperatorSchemaResultIR } from "../../../protocol/server-operator-job";
|
||||
import featureRouteCatalog from "../capabilities/blender-feature-route-catalog.json";
|
||||
|
||||
interface PendingRequest {
|
||||
@@ -54,6 +54,7 @@ export interface WebEngineClientOptions {
|
||||
timeoutMs?: number;
|
||||
workerFactory?: () => Worker;
|
||||
onWorkerFault?: (fault: WorkerFault) => void;
|
||||
serverOperatorEndpoint?: string;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () =>
|
||||
@@ -118,9 +119,9 @@ function normalizeOperatorCatalog(value: unknown): BlenderOperatorDescriptor[] {
|
||||
|
||||
function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[] {
|
||||
const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
const entries = Array.isArray(value) ? value : root && (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []);
|
||||
const entries: unknown[] = Array.isArray(value) ? value : root ? (Array.isArray(root.entries) ? root.entries : Array.isArray(root.routes) ? root.routes : Array.isArray(root.units) ? root.units : []) : [];
|
||||
return entries.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))).map((entry) => {
|
||||
const route = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
|
||||
const route: BlenderFeatureRouteEntry["route"] = entry.route === "SERVER_NATIVE" || entry.route === "BROWSER_EQUIVALENT" || entry.route === "UNAVAILABLE" ? entry.route : "WEB_MAIN";
|
||||
const locations = Array.isArray(entry.locations) ? entry.locations.filter((location): location is Record<string, unknown> => Boolean(location && typeof location === "object" && !Array.isArray(location))).map((location) => ({
|
||||
path: String(location.path ?? ""),
|
||||
...(typeof location.line === "number" ? { line: location.line } : {}),
|
||||
@@ -143,6 +144,27 @@ function normalizeFeatureRouteCatalog(value: unknown): BlenderFeatureRouteEntry[
|
||||
|
||||
const FEATURE_ROUTE_CATALOG = normalizeFeatureRouteCatalog(featureRouteCatalog);
|
||||
|
||||
async function sha256Hex(data: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function readServerOperatorPayload(response: Response): Promise<{ status?: unknown; result?: unknown }> {
|
||||
let payload: { status?: unknown; result?: unknown };
|
||||
try { payload = await response.json() as { status?: unknown; result?: unknown }; }
|
||||
catch { throw new Error(`SERVER_OPERATOR_HTTP_FAILED: ${response.status}`); }
|
||||
if (!response.ok) throw new Error(String((payload.result as { errorMessage?: unknown } | undefined)?.errorMessage ?? `SERVER_OPERATOR_HTTP_FAILED: ${response.status}`));
|
||||
if (!payload.result || typeof payload.result !== "object") throw new Error("SERVER_OPERATOR_HTTP_INVALID_RESULT");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function mergeFeatureRoutes(operators: BlenderOperatorDescriptor[]): BlenderFeatureRouteEntry[] {
|
||||
const merged = FEATURE_ROUTE_CATALOG.map((route) => ({ ...route }));
|
||||
for (const operator of operators) {
|
||||
@@ -179,11 +201,16 @@ export class WebEngineClient {
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private geometryBuffers: MeshGeometryBuffer[] = [];
|
||||
private nonMeshGeometryBuffers: NonMeshGeometryChunk[] = [];
|
||||
private readonly serverOperatorEndpoint: string;
|
||||
private readonly serverOperatorSchemaCache = new Map<string, BlenderServerOperatorSchema>();
|
||||
private readonly serverOperatorSchemaIndex = new Map<string, string>();
|
||||
|
||||
constructor(options: WebEngineClientOptions = {}) {
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
this.onWorkerFault = options.onWorkerFault;
|
||||
const configuredEndpoint = options.serverOperatorEndpoint ?? (globalThis as { __BLENDER_SERVER_OPERATOR_ENDPOINT__?: unknown }).__BLENDER_SERVER_OPERATOR_ENDPOINT__;
|
||||
this.serverOperatorEndpoint = typeof configuredEndpoint === "string" && configuredEndpoint.trim() ? configuredEndpoint : "/api/server-operator-job";
|
||||
}
|
||||
|
||||
async init(): Promise<WebEngineStatus> {
|
||||
@@ -269,8 +296,9 @@ export class WebEngineClient {
|
||||
|
||||
async readRNAPropertySchema(params: Record<string, unknown>): Promise<{ status: WebEngineStatus; schema: RNAPropertyDescriptor }> {
|
||||
const { status, result } = await this.callJson<unknown>("readRNAPropertySchema", params);
|
||||
const schema = result && typeof result === "object" && result.schema && typeof result.schema === "object"
|
||||
? result.schema as RNAPropertyDescriptor
|
||||
const resultRecord = result && typeof result === "object" ? result as { schema?: unknown } : null;
|
||||
const schema = resultRecord?.schema && typeof resultRecord.schema === "object"
|
||||
? resultRecord.schema as RNAPropertyDescriptor
|
||||
: result as RNAPropertyDescriptor;
|
||||
return { status, schema };
|
||||
}
|
||||
@@ -285,8 +313,9 @@ export class WebEngineClient {
|
||||
|
||||
async readRNACollection(params: Record<string, unknown> = {}): Promise<{ status: WebEngineStatus; collection: RNACollectionDescriptor }> {
|
||||
const { status, result } = await this.callJson<unknown>("readRNACollection", params);
|
||||
const collection = result && typeof result === "object" && result.collection && typeof result.collection === "object"
|
||||
? result.collection as RNACollectionDescriptor
|
||||
const resultRecord = result && typeof result === "object" ? result as { collection?: unknown } : null;
|
||||
const collection = resultRecord?.collection && typeof resultRecord.collection === "object"
|
||||
? resultRecord.collection as RNACollectionDescriptor
|
||||
: result as RNACollectionDescriptor;
|
||||
return { status, collection };
|
||||
}
|
||||
@@ -320,28 +349,100 @@ export class WebEngineClient {
|
||||
});
|
||||
}
|
||||
if (route.route === "SERVER_NATIVE") {
|
||||
if (!invocation.serverJob) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE feature routes require a typed server job payload", true);
|
||||
parseServerOperatorJobRequest(invocation.serverJob);
|
||||
const { status, result } = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", invocation.serverJob as unknown as Record<string, unknown>);
|
||||
return { status, snapshot: result as unknown as BlendOpenResult["snapshot"], delta: result as unknown as SceneDelta, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers], operatorResult: result };
|
||||
if (!invocation.serverJob) await this.describeServerOperator(route.identifier);
|
||||
const request = invocation.serverJob ?? await this.createServerOperatorJobRequest(route.identifier, invocation.properties ?? {});
|
||||
const submitted = await this.submitServerOperatorJob(request);
|
||||
const opened = await this.snapshot();
|
||||
const delta = await this.delta();
|
||||
return { ...opened, delta: delta.delta, operatorResult: submitted.job };
|
||||
}
|
||||
throw this.report("INVALID_ARGUMENT", `Feature route ${route.identifier} is not executable in the current browser context`, true);
|
||||
}
|
||||
|
||||
async describeServerOperator(operator: string): Promise<{ status: WebEngineStatus; schema: BlenderServerOperatorSchema }> {
|
||||
const normalizedOperator = operator.replace(/^operator:/, "").trim();
|
||||
if (!normalizedOperator || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(normalizedOperator)) {
|
||||
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE 操作标识无效", true);
|
||||
}
|
||||
const cachedKey = this.serverOperatorSchemaIndex.get(normalizedOperator);
|
||||
if (cachedKey) {
|
||||
const cached = this.serverOperatorSchemaCache.get(cachedKey);
|
||||
if (cached) return { status: { ready: true } as WebEngineStatus, schema: cached };
|
||||
}
|
||||
const response = await fetch(this.serverOperatorEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "describe" },
|
||||
body: JSON.stringify({ operator: normalizedOperator }),
|
||||
});
|
||||
const payload = await readServerOperatorPayload(response);
|
||||
const result = payload.result as ServerOperatorSchemaResultIR;
|
||||
if (!result || result.route !== "SERVER_NATIVE" || !result.operator || !result.blenderBuild) {
|
||||
throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 响应无效", true);
|
||||
}
|
||||
const descriptor = normalizeOperatorCatalog([result.operator])[0];
|
||||
if (!descriptor) throw this.report("INVALID_ARGUMENT", "SERVER_NATIVE RNA schema 操作无效", true);
|
||||
const schema = { operator: descriptor, blenderBuild: result.blenderBuild };
|
||||
const cacheKey = `${normalizedOperator}|${result.blenderBuild.version}|${result.blenderBuild.buildSha256}`;
|
||||
this.serverOperatorSchemaCache.set(cacheKey, schema);
|
||||
this.serverOperatorSchemaIndex.set(normalizedOperator, cacheKey);
|
||||
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, schema };
|
||||
}
|
||||
|
||||
async submitServerOperatorJob(request: ServerOperatorJobRequestIR): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
|
||||
const validated = parseServerOperatorJobRequest(request);
|
||||
const result = await this.callJson<ServerOperatorJobResultIR>("submitServerOperatorJob", validated as unknown as Record<string, unknown>);
|
||||
return { status: result.status, job: result.result };
|
||||
const source = await this.saveBlend();
|
||||
const sourceSha256 = await sha256Hex(source);
|
||||
if (source.byteLength !== validated.sourceBlendByteLength || sourceSha256 !== validated.sourceBlendSha256) throw this.report("BLEND_READ_FAILED", "当前 Main 与 SERVER_NATIVE 请求绑定不一致", true);
|
||||
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/octet-stream", "X-Server-Operator-Action": "submit" }, body: encodeServerOperatorJobEnvelope(validated, source) });
|
||||
const payload = await readServerOperatorPayload(response);
|
||||
const job = payload.result as ServerOperatorJobResultIR;
|
||||
if (job.status === "SUCCEEDED" && job.resultBlendBase64) {
|
||||
const bytes = decodeBase64(job.resultBlendBase64);
|
||||
const resultBuffer = bytes.slice().buffer as ArrayBuffer;
|
||||
if (job.resultBlendByteLength !== bytes.byteLength || (job.resultBlendSha256 && job.resultBlendSha256 !== await sha256Hex(resultBuffer))) throw this.report("BLEND_READ_FAILED", "SERVER_NATIVE 结果 .blend 校验失败", true);
|
||||
await this.openBlend(resultBuffer);
|
||||
}
|
||||
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job };
|
||||
}
|
||||
|
||||
async cancelServerOperatorJob(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
|
||||
const result = await this.callJson<ServerOperatorJobResultIR>("cancelServerOperatorJob", { jobId, requestSha256 });
|
||||
return { status: result.status, job: result.result };
|
||||
const response = await fetch(this.serverOperatorEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Server-Operator-Action": "cancel" }, body: JSON.stringify({ jobId, requestSha256 }) });
|
||||
const payload = await readServerOperatorPayload(response);
|
||||
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
|
||||
}
|
||||
|
||||
async readServerOperatorResult(jobId: string, requestSha256: string): Promise<{ status: WebEngineStatus; job: ServerOperatorJobResultIR }> {
|
||||
const result = await this.callJson<ServerOperatorJobResultIR>("readServerOperatorResult", { jobId, requestSha256 });
|
||||
return { status: result.status, job: result.result };
|
||||
const query = new URLSearchParams({ jobId, requestSha256 });
|
||||
const response = await fetch(`${this.serverOperatorEndpoint}?${query.toString()}`, { headers: { "X-Server-Operator-Action": "read" } });
|
||||
const payload = await readServerOperatorPayload(response);
|
||||
return { status: (payload.status ?? { ready: true }) as WebEngineStatus, job: payload.result as ServerOperatorJobResultIR };
|
||||
}
|
||||
|
||||
private async createServerOperatorJobRequest(operator: string, properties: Record<string, unknown>): Promise<ServerOperatorJobRequestIR> {
|
||||
const source = await this.saveBlend();
|
||||
const sourceBlendSha256 = await sha256Hex(source);
|
||||
const props = properties as ServerOperatorJobRequestIR["properties"];
|
||||
const propertiesSha256 = await sha256Hex(new TextEncoder().encode(canonicalServerOperatorProperties(props)).buffer);
|
||||
const snapshot = await this.snapshot();
|
||||
const schemaKey = this.serverOperatorSchemaIndex.get(operator.replace(/^operator:/, ""));
|
||||
const schemaBuild = schemaKey ? this.serverOperatorSchemaCache.get(schemaKey)?.blenderBuild : undefined;
|
||||
const base = {
|
||||
schemaVersion: 1 as const,
|
||||
route: "SERVER_NATIVE" as const,
|
||||
jobId: `server-${Date.now()}-${++this.requestCounter}`,
|
||||
sourceBlendSha256,
|
||||
sourceBlendByteLength: source.byteLength,
|
||||
sourceRevision: snapshot.snapshot.revision,
|
||||
blenderBuild: schemaBuild ?? { version: "web-engine", buildSha256: await sha256Hex(new TextEncoder().encode("web-engine").buffer) },
|
||||
operator: operator.replace(/^operator:/, ""),
|
||||
properties: props,
|
||||
propertiesSha256,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
memoryBytes: 512 * 1024 * 1024,
|
||||
resultBinding: { kind: "json" as const, key: "result" },
|
||||
context: "BACKGROUND" as const,
|
||||
};
|
||||
return { ...base, requestSha256: await sha256Hex(new TextEncoder().encode(canonicalServerOperatorJobRequest(base)).buffer) };
|
||||
}
|
||||
|
||||
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {
|
||||
|
||||
@@ -180,12 +180,12 @@ function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation
|
||||
if (typeof properties !== "object" || properties === null || Array.isArray(properties) || !validOperatorJson(properties)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderOperator properties must be typed JSON", true);
|
||||
}
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
throw report("REVISION_CONFLICT", "invokeBlenderOperator baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
return {
|
||||
operator: params.operator,
|
||||
properties: properties as Record<string, WebEngineOperatorProperty>,
|
||||
properties: properties as NonNullable<WebEngineOperatorInvocation["properties"]>,
|
||||
...(params.baseRevision === undefined ? {} : { baseRevision: params.baseRevision as number }),
|
||||
};
|
||||
}
|
||||
@@ -209,7 +209,7 @@ function validateMacroInvocation(value: unknown): void {
|
||||
(typeof params.serverJob !== "object" || params.serverJob === null || Array.isArray(params.serverJob) || !validOperatorJson(params.serverJob))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderMacro serverJob must be typed JSON", true);
|
||||
}
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
throw report("REVISION_CONFLICT", "invokeBlenderMacro baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ function validateFeatureRouteInvocation(value: unknown): void {
|
||||
if (params.properties !== undefined && (typeof params.properties !== "object" || params.properties === null || Array.isArray(params.properties) || !validOperatorJson(params.properties))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeFeatureRoute properties must be typed JSON", true);
|
||||
}
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
if (params.baseRevision !== undefined && (typeof params.baseRevision !== "number" || !Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
throw report("REVISION_CONFLICT", "invokeFeatureRoute baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
if (route.route === "SERVER_NATIVE") {
|
||||
@@ -1230,7 +1230,7 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
}
|
||||
case "setCompositorNodeGraph": {
|
||||
if (!currentSnapshot?.scenes.some((candidate) => candidate.id === payload.sceneId) || !payload.graph || typeof payload.graph !== "object") throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph target is invalid");
|
||||
const graph = payload.graph as Record<string, unknown>;
|
||||
const graph = payload.graph as unknown as Record<string, unknown>;
|
||||
if (graph.schemaVersion !== 1 || typeof graph.id !== "string" || graph.id.length === 0 || !Array.isArray(graph.nodes) || !Array.isArray(graph.links) || graph.nodes.length > 4096 || graph.links.length > 16384) throw report("COMPOSITOR_GRAPH_INVALID", "Compositor graph schema or bounds are invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user