feat(web): classify and route complete Blender inventory
This commit is contained in:
@@ -22,7 +22,7 @@ export function RNADataBlockReflectionPanel({client, dataBlocks, properties, onR
|
||||
<span>{properties.length} reflected properties</span>
|
||||
</div>;
|
||||
}
|
||||
import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
|
||||
import type { 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";
|
||||
@@ -99,11 +99,19 @@ import ioFormatUIRegistryJSON from "../capabilities/io-format-ui-registry.json";
|
||||
import { filterIOFormatOperatorCommands, gateIOFormatFileSelection, ioFormatUIAccept, parseIOFormatUIRegistry, type IOFormatUICommandRef } from "../../../protocol/io-format-ui-gate";
|
||||
import ioFormatRuntimeReceiptFreshnessJSON from "../capabilities/io-format-runtime-receipts-freshness.json";
|
||||
import ioFormatRuntimeReceiptFreshnessExpectedJSON from "../capabilities/io-format-runtime-receipts-freshness-expected.json";
|
||||
import featureRouteCatalogJSON from "../capabilities/blender-feature-route-catalog.json";
|
||||
import { parseIOFormatReceiptFreshness, resolveFreshIOFormatRuntimeRoute, type IOFormatReceiptFreshnessExpectedIR } from "../../../protocol/io-format-receipt-freshness";
|
||||
|
||||
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 {
|
||||
unitCount: number;
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
counts: Record<"WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE", number>;
|
||||
behavioralEvidence: { status: "VERIFIED" | "PARTIAL"; verifiedUnits: number; unverifiedUnits: number; policy: string };
|
||||
};
|
||||
|
||||
function recentProjectIssueMessage(code: RecentProjectIssue["code"]): string {
|
||||
if (code === "MISSING") return "项目内容缺失";
|
||||
@@ -129,15 +137,39 @@ interface OperatorPaletteCommand {
|
||||
execute?: () => unknown;
|
||||
}
|
||||
|
||||
function featureRouteInvokable(route: BlenderFeatureRouteEntry): boolean {
|
||||
return route.route === "WEB_MAIN" || route.route === "SERVER_NATIVE";
|
||||
}
|
||||
|
||||
function normalizeMacroCatalog(value: unknown): BlenderMacroDescriptor[] {
|
||||
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.macros) ? root.macros : [];
|
||||
return entries.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === "object" && !Array.isArray(entry))).map((entry) => ({
|
||||
id: String(entry.id ?? "").trim(),
|
||||
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",
|
||||
contextDependent: Boolean(entry.contextDependent),
|
||||
source: Array.isArray(entry.source) ? entry.source as BlenderMacroDescriptor["source"] : [],
|
||||
steps: Array.isArray(entry.steps) ? entry.steps as BlenderMacroDescriptor["steps"] : [],
|
||||
...(Array.isArray(entry.properties) ? { properties: entry.properties as BlenderMacroDescriptor["properties"] } : {}),
|
||||
})).filter((entry) => entry.id.length > 0);
|
||||
}
|
||||
|
||||
function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
client: WebEngineClient | null;
|
||||
commands: ReadonlyArray<OperatorPaletteCommand>;
|
||||
onClose: () => void;
|
||||
onApplied: (result: Awaited<ReturnType<WebEngineClient["invokeBlenderOperator"]>>, descriptor: BlenderOperatorDescriptor) => void;
|
||||
onApplied: (result: Awaited<ReturnType<WebEngineClient["invokeBlenderOperator"]>>, route: BlenderFeatureRouteEntry) => void;
|
||||
onMacroApplied?: (result: Awaited<ReturnType<WebEngineClient["snapshot"]>>, macro: BlenderMacroDescriptor) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [catalog, setCatalog] = useState<BlenderOperatorDescriptor[]>([]);
|
||||
const [routes, setRoutes] = useState<BlenderFeatureRouteEntry[]>([]);
|
||||
const [macros, setMacros] = useState<BlenderMacroDescriptor[]>([]);
|
||||
const [selected, setSelected] = useState<BlenderOperatorDescriptor | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<BlenderFeatureRouteEntry | null>(null);
|
||||
const [values, setValues] = useState<Record<string, BlenderOperatorPropertyValue>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -150,8 +182,20 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
return () => { active = false; };
|
||||
}
|
||||
setLoading(true);
|
||||
void client.listBlenderOperators().then(({ operators }) => {
|
||||
if (active) setCatalog(operators);
|
||||
void Promise.all([client.listFeatureRoutes(), client.callJson<unknown>("listBlenderMacros")]).then(([featureResult, macroResult]) => {
|
||||
if (!active) return;
|
||||
const featureRoutes = featureResult.routes;
|
||||
setRoutes(featureRoutes);
|
||||
setMacros(normalizeMacroCatalog(macroResult.result));
|
||||
setCatalog(featureRoutes.filter((route) => route.route === "WEB_MAIN" && route.kind.toLowerCase().includes("operator")).map((route) => ({
|
||||
id: route.identifier.replace(/^operator:/, ""),
|
||||
label: route.label ?? route.identifier,
|
||||
description: route.description,
|
||||
family: route.family,
|
||||
context: "main",
|
||||
backgroundSafe: true,
|
||||
properties: route.properties ?? [],
|
||||
})));
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => {
|
||||
@@ -161,8 +205,32 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
}, [client]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const staticMatches = commands.filter((command) => `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(normalizedQuery));
|
||||
const nativeMatches = catalog.filter((operator) => `${operator.id} ${operator.label} ${operator.family} ${operator.description ?? ""}`.toLowerCase().includes(normalizedQuery));
|
||||
const macroCommands: OperatorPaletteCommand[] = macros.map((macro) => ({
|
||||
id: "macro:" + macro.id,
|
||||
label: macro.label + " [MACRO]",
|
||||
keywords: macro.id + " " + macro.family + " " + macro.route,
|
||||
execute: () => void executeMacro(macro),
|
||||
}));
|
||||
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 => {
|
||||
setSelectedRoute(route);
|
||||
const operator = catalog.find((candidate) => candidate.id === route.identifier.replace(/^operator:/, ""));
|
||||
if (operator) chooseOperator(operator);
|
||||
else {
|
||||
setSelected(null);
|
||||
setValues({});
|
||||
}
|
||||
};
|
||||
|
||||
const chooseOperator = (operator: BlenderOperatorDescriptor): void => {
|
||||
const next: Record<string, BlenderOperatorPropertyValue> = {};
|
||||
@@ -177,6 +245,24 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
async function executeMacro(macro: BlenderMacroDescriptor): Promise<void> {
|
||||
if (!client) return;
|
||||
setExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.callJson("invokeBlenderMacro", { macro: macro.id, properties: {} });
|
||||
const result = await client.snapshot();
|
||||
onMacroApplied?.(result, macro);
|
||||
onClose();
|
||||
}
|
||||
catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const renderProperty = (property: BlenderOperatorPropertySpec): React.ReactNode => {
|
||||
const value = values[property.name];
|
||||
const label = property.label ?? property.name;
|
||||
@@ -213,8 +299,10 @@ function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
|
||||
setExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await client.invokeBlenderOperator({ operator: selected.id, properties });
|
||||
onApplied(result, selected);
|
||||
const route = selectedRoute ?? routes.find((candidate) => candidate.identifier === selected.id || `operator:${candidate.identifier}` === selected.id);
|
||||
if (!route || !featureRouteInvokable(route)) return;
|
||||
const result = await client.invokeFeatureRoute({ route, properties });
|
||||
onApplied(result, route);
|
||||
onClose();
|
||||
}
|
||||
catch (reason) {
|
||||
@@ -1310,6 +1398,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
|
||||
{snapshot ? <div className="property-section" data-testid="scene-context-workbench"><h3>Scene Context</h3><output data-testid="scene-context-counts">Scenes {snapshot.scenes.length} · Collections {snapshot.collections.length} · Armatures {snapshot.armatures?.length ?? 0} · Libraries {snapshot.libraries?.length ?? 0}</output>{snapshot.scenes.map((scene) => <output key={scene.id} data-resource-id={scene.id}>Scene: {scene.name}{scene.renderEngine ? ` · ${scene.renderEngine}` : ""}</output>)}{snapshot.collections.slice(0, 32).map((collection) => <output key={collection.id} data-resource-id={collection.id}>Collection: {collection.name} · {collection.objectIds.length} objects · {collection.childCollectionIds.length} children</output>)}{snapshot.armatures?.slice(0, 16).map((armature) => <output key={armature.id} data-resource-id={armature.id}>Armature: {armature.name} · {armature.bones.length} bones</output>)}{snapshot.libraries?.slice(0, 16).map((library) => <output key={library.id} data-resource-id={library.id}>Library: {library.name} · {library.status}</output>)}</div> : null}
|
||||
{snapshot ? <div className="property-section" data-testid="editor-workflow-workbench" data-status={snapshot.editorWorkflowStatus ?? "BLOCKED"} data-revision={editorWorkflow?.context.revision ?? snapshot.revision}><h3>Editor Workflow</h3><output data-testid="editor-workflow-status">{snapshot.editorWorkflowStatus ?? "BLOCKED"}</output>{editorWorkflow ? <><output data-testid="editor-workflow-context">{editorWorkflow.context.workspaceId} · {editorWorkflow.context.activeAreaId} · {editorWorkflow.context.activeEditor}</output><output data-testid="editor-workflow-workspace-count">{editorWorkflow.workspaces.length} workspaces</output>{editorWorkflow.workspaces.slice(0, 16).map((item) => <output key={item.id} data-workspace-id={item.id}>{item.name} · {item.areas.length} areas · active {item.activeAreaId}</output>)}</> : <output data-testid="editor-workflow-unavailable">Editor workflow unavailable</output>}</div> : null}
|
||||
{snapshot ? <div className="property-section" data-testid="capability-family-workbench"><h3>Capability Families</h3><output data-testid="capability-family-command-count">{Object.keys(WEB_ENGINE_COMMAND_CAPABILITIES).length} typed Main commands</output>{[...new Set(Object.values(WEB_ENGINE_COMMAND_CAPABILITIES).map((capability) => capability.family))].sort().map((family) => <output key={family} data-capability-family={family}>{family}</output>)}</div> : null}
|
||||
<div className="property-section" data-testid="feature-route-coverage" data-command="featureRouteCoverage,featureRouteGaps"><h3>Feature Routes</h3><output data-testid="feature-route-total">{FEATURE_ROUTE_CATALOG.classifiedCount}/{FEATURE_ROUTE_CATALOG.unitCount} source units classified</output><output data-testid="feature-route-counts">Main {FEATURE_ROUTE_CATALOG.counts.WEB_MAIN} · Server {FEATURE_ROUTE_CATALOG.counts.SERVER_NATIVE} · Browser {FEATURE_ROUTE_CATALOG.counts.BROWSER_EQUIVALENT} · Unavailable {FEATURE_ROUTE_CATALOG.counts.UNAVAILABLE ?? 0}</output><output data-testid="feature-route-gaps">Route gaps {FEATURE_ROUTE_CATALOG.unclassifiedCount} · Evidence {FEATURE_ROUTE_CATALOG.behavioralEvidence.verifiedUnits} verified / {FEATURE_ROUTE_CATALOG.behavioralEvidence.unverifiedUnits} unverified</output></div>
|
||||
{snapshot && (snapshot.releaseCandidateStatus || snapshot.scriptingPolicyStatus || snapshot.renderRoutingStatus || snapshot.serverJobBindingStatus || snapshot.performanceFaultMatrixStatus || snapshot.editorWorkflowStatus) ? <div className="property-section" data-testid="release-status-workbench"><h3>Runtime Status</h3><output data-testid="runtime-status-summary">Scripting {snapshot.scriptingPolicyStatus ?? "UNKNOWN"} · Render {snapshot.renderRoutingStatus ?? "UNKNOWN"} · Jobs {snapshot.serverJobBindingStatus ?? "UNKNOWN"}</output><output>Editor workflow {snapshot.editorWorkflowStatus ?? "UNKNOWN"} · Performance faults {snapshot.performanceFaultMatrixStatus ?? "UNKNOWN"}</output><output>Release candidate {snapshot.releaseCandidateStatus ?? "UNKNOWN"}</output></div> : null}
|
||||
{activeNode?.dataId?.startsWith("mesh:") ? <div className="property-section" data-testid="geometry-node-workbench"><h3>Geometry Nodes</h3><output data-testid="geometry-node-graph-count">{snapshot?.geometryNodeGraphs?.length ?? 0} graphs</output>{activeGeometryGraph ? <><output data-testid="geometry-node-active-graph">{activeGeometryGraph.name}</output><output>{activeGeometryGraph.nodes.length} nodes / {activeGeometryGraph.links.length} links</output><button type="button" onClick={() => onCommand(geometryNodeCommands.setGraph(activeNode.dataId!, activeGeometryGraph))}>提交当前节点图</button></> : <output>当前文件没有 Geometry Node 图</output>}</div> : null}
|
||||
{snapshot?.physicsSimulation ? <div className="property-section" data-testid="physics-workbench"><h3>Physics</h3><output data-testid="physics-system-count">{snapshot.physicsSimulation.systems.length} systems</output><output>{[...new Set(snapshot.physicsSimulation.systems.map((system) => system.family))].join(", ") || "No systems"}</output><button type="button" disabled={!activeNode} onClick={() => onCommand(physicsCommands.setManifest(snapshot.physicsSimulation!))}>提交物理清单</button></div> : null}
|
||||
@@ -3351,7 +3440,7 @@ export function App() {
|
||||
<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="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, descriptor) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Operator: ${descriptor.id}`); }} /> : null}
|
||||
{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}
|
||||
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
|
||||
</main>
|
||||
);
|
||||
|
||||
60492
web/app/src/capabilities/blender-feature-route-catalog.json
Normal file
60492
web/app/src/capabilities/blender-feature-route-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,9 @@ 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 { BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
|
||||
import type { BlenderFeatureRouteEntry, BlenderFeatureRouteInvocation, BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
|
||||
import { parseServerOperatorJobRequest, type ServerOperatorJobRequestIR, type ServerOperatorJobResultIR } from "../../../protocol/server-operator-job";
|
||||
import featureRouteCatalog from "../capabilities/blender-feature-route-catalog.json";
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (result: WebEngineResult) => void;
|
||||
@@ -114,6 +116,58 @@ 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 : []);
|
||||
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 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 } : {}),
|
||||
...(typeof location.evidence === "string" ? { evidence: location.evidence } : {}),
|
||||
})) : undefined;
|
||||
return {
|
||||
id: String(entry.id ?? entry.identifier ?? "").trim(),
|
||||
kind: String(entry.kind ?? "feature"),
|
||||
identifier: String(entry.identifier ?? entry.id ?? "").trim(),
|
||||
family: String(entry.family ?? "Blender"),
|
||||
route,
|
||||
reason: String(entry.reason ?? "classified source-unit route"),
|
||||
...(typeof entry.label === "string" ? { label: entry.label } : {}),
|
||||
...(typeof entry.description === "string" ? { description: entry.description } : {}),
|
||||
...(Array.isArray(entry.properties) ? { properties: entry.properties as BlenderFeatureRouteEntry["properties"] } : {}),
|
||||
...(locations && locations.length > 0 ? { locations } : {}),
|
||||
};
|
||||
}).filter((entry) => entry.id.length > 0 && entry.identifier.length > 0);
|
||||
}
|
||||
|
||||
const FEATURE_ROUTE_CATALOG = normalizeFeatureRouteCatalog(featureRouteCatalog);
|
||||
|
||||
function mergeFeatureRoutes(operators: BlenderOperatorDescriptor[]): BlenderFeatureRouteEntry[] {
|
||||
const merged = FEATURE_ROUTE_CATALOG.map((route) => ({ ...route }));
|
||||
for (const operator of operators) {
|
||||
const existing = merged.find((route) => route.route === "WEB_MAIN" && route.identifier === operator.id && route.kind.toLowerCase().includes("operator"));
|
||||
if (existing) {
|
||||
existing.label = operator.label;
|
||||
existing.description = operator.description;
|
||||
existing.properties = operator.properties;
|
||||
continue;
|
||||
}
|
||||
merged.push({
|
||||
id: `operator:${operator.id}`,
|
||||
kind: "operator",
|
||||
identifier: operator.id,
|
||||
family: operator.family,
|
||||
route: "WEB_MAIN",
|
||||
reason: "typed native WebEngine operator",
|
||||
label: operator.label,
|
||||
description: operator.description,
|
||||
properties: operator.properties,
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export class WebEngineClient {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly workerFactory: () => Worker;
|
||||
@@ -186,6 +240,11 @@ export class WebEngineClient {
|
||||
return { status, operators: normalizeOperatorCatalog(result) };
|
||||
}
|
||||
|
||||
async listFeatureRoutes(): Promise<{ status: WebEngineStatus; routes: BlenderFeatureRouteEntry[] }> {
|
||||
const { status, operators } = await this.listBlenderOperators();
|
||||
return { status, routes: mergeFeatureRoutes(operators) };
|
||||
}
|
||||
|
||||
async listRNADataBlocks(params: Record<string, unknown> = {}): Promise<{ status: WebEngineStatus; dataBlocks: RNADataBlockDescriptor[] }> {
|
||||
const { status, result } = await this.callJson<unknown>("listRNADataBlocks", params);
|
||||
const dataBlocks = Array.isArray(result) ? result : (result && typeof result === "object" && Array.isArray((result as { dataBlocks?: unknown }).dataBlocks) ? (result as { dataBlocks: unknown[] }).dataBlocks : []);
|
||||
@@ -208,6 +267,30 @@ export class WebEngineClient {
|
||||
return { status, collections: collections.filter((item): item is RNACollectionDescriptor => Boolean(item && typeof item === "object")) };
|
||||
}
|
||||
|
||||
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
|
||||
: result as RNAPropertyDescriptor;
|
||||
return { status, schema };
|
||||
}
|
||||
|
||||
async setRNAArrayProperty(params: Record<string, unknown>): Promise<{ status: WebEngineStatus; result: unknown }> {
|
||||
return this.callJson("setRNAArrayProperty", params);
|
||||
}
|
||||
|
||||
async setRNAEnumProperty(params: Record<string, unknown>): Promise<{ status: WebEngineStatus; result: unknown }> {
|
||||
return this.callJson("setRNAEnumProperty", params);
|
||||
}
|
||||
|
||||
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
|
||||
: result as RNACollectionDescriptor;
|
||||
return { status, collection };
|
||||
}
|
||||
|
||||
async invokeBlenderOperator(invocation: BlenderOperatorInvocation): Promise<BlendOpenResult & { delta: SceneDelta; operatorResult?: unknown }> {
|
||||
if (!invocation.operator.trim()) throw this.report("INVALID_ARGUMENT", "Blender operator 不能为空", true);
|
||||
const result = await this.request({ type: "jsonCommand", cmd: "invokeBlenderOperator", params: invocation as unknown as Record<string, unknown> });
|
||||
@@ -227,6 +310,40 @@ export class WebEngineClient {
|
||||
};
|
||||
}
|
||||
|
||||
async invokeFeatureRoute(invocation: BlenderFeatureRouteInvocation & { serverJob?: ServerOperatorJobRequestIR }): Promise<BlendOpenResult & { delta: SceneDelta; operatorResult?: unknown }> {
|
||||
const route = invocation.route;
|
||||
if (route.route === "WEB_MAIN" && (route.kind.toLowerCase().includes("operator") || route.identifier.startsWith("operator."))) {
|
||||
return this.invokeBlenderOperator({
|
||||
operator: route.identifier.replace(/^operator:/, ""),
|
||||
properties: invocation.properties,
|
||||
...(invocation.baseRevision === undefined ? {} : { baseRevision: invocation.baseRevision }),
|
||||
});
|
||||
}
|
||||
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 };
|
||||
}
|
||||
throw this.report("INVALID_ARGUMENT", `Feature route ${route.identifier} is not executable in the current browser context`, true);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {
|
||||
const result = await this.request({ type: "delta" });
|
||||
if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type LibraryAppendClosureIR,
|
||||
} from "../../../protocol/library-main-append";
|
||||
import { createWasmModuleLoader, type WasmModuleLoader } from "../engine-client/wasm-module-loader";
|
||||
import { parseServerOperatorJobRequest } from "../../../protocol/server-operator-job";
|
||||
|
||||
type WasmModule = {
|
||||
_malloc: (size: number) => number;
|
||||
@@ -189,6 +190,73 @@ function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation
|
||||
};
|
||||
}
|
||||
|
||||
function validateMacroInvocation(value: unknown): void {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderMacro requires an object params payload", true);
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
if (Object.keys(params).some((key) => !["macro", "properties", "baseRevision", "serverJob"].includes(key))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderMacro received an unknown parameter", true);
|
||||
}
|
||||
if (typeof params.macro !== "string" || params.macro.length === 0 || params.macro.length > 128) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderMacro requires a bounded macro identifier", true);
|
||||
}
|
||||
if (params.properties !== undefined &&
|
||||
(typeof params.properties !== "object" || params.properties === null || Array.isArray(params.properties) || !validOperatorJson(params.properties))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderMacro properties must be typed JSON", true);
|
||||
}
|
||||
if (params.serverJob !== undefined &&
|
||||
(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)) {
|
||||
throw report("REVISION_CONFLICT", "invokeBlenderMacro baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFeatureRouteInvocation(value: unknown): void {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeFeatureRoute requires an object params payload", true);
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
if (Object.keys(params).some((key) => !["route", "properties", "baseRevision", "serverJob"].includes(key))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeFeatureRoute received an unknown parameter", true);
|
||||
}
|
||||
if (typeof params.route !== "object" || params.route === null || Array.isArray(params.route)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeFeatureRoute requires route metadata", true);
|
||||
}
|
||||
const route = params.route as Record<string, unknown>;
|
||||
if (!["WEB_MAIN", "SERVER_NATIVE"].includes(route.route as string) || typeof route.identifier !== "string" || route.identifier.length === 0 || route.identifier.length > 256) {
|
||||
throw report("INVALID_ARGUMENT", "invokeFeatureRoute only accepts typed native or server-job routes", true);
|
||||
}
|
||||
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)) {
|
||||
throw report("REVISION_CONFLICT", "invokeFeatureRoute baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
if (route.route === "SERVER_NATIVE") {
|
||||
if (typeof params.serverJob !== "object" || params.serverJob === null || Array.isArray(params.serverJob)) {
|
||||
throw report("INVALID_ARGUMENT", "SERVER_NATIVE routes require a typed serverJob payload", true);
|
||||
}
|
||||
try { parseServerOperatorJobRequest(params.serverJob); }
|
||||
catch (error) { reportProtocolError(error, "INVALID_ARGUMENT"); }
|
||||
}
|
||||
}
|
||||
|
||||
function validateServerOperatorJobParams(command: string, value: unknown): void {
|
||||
if (command === "submitServerOperatorJob") {
|
||||
try { parseServerOperatorJobRequest(value); }
|
||||
catch (error) { reportProtocolError(error, "INVALID_ARGUMENT"); }
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw report("INVALID_ARGUMENT", `${command} requires an object params payload`, true);
|
||||
const params = value as Record<string, unknown>;
|
||||
if (Object.keys(params).some((key) => !["jobId", "requestSha256"].includes(key)) || typeof params.jobId !== "string" || params.jobId.length === 0 || params.jobId.length > 128 || typeof params.requestSha256 !== "string" || !/^[a-f0-9]{64}$/.test(params.requestSha256)) {
|
||||
throw report("INVALID_ARGUMENT", `${command} requires a bounded jobId and requestSha256`, true);
|
||||
}
|
||||
}
|
||||
|
||||
function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]): never {
|
||||
if (typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string") {
|
||||
const candidate = error as { code: ErrorReport["code"]; message?: unknown };
|
||||
@@ -1299,14 +1367,26 @@ function callUnifiedJson<T>(cmd: string, params: Record<string, unknown> = {}, t
|
||||
}
|
||||
}
|
||||
|
||||
type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene";
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene", "listRNADataBlocks", "readRNAProperties", "listRNACollections"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene", "assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload", "setRNAProperty"]);
|
||||
type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene" | "submitServerOperatorJob" | "cancelServerOperatorJob" | "readServerOperatorResult";
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "listBlenderMacros", "listFeatureRoutes", "exportScene", "listRNADataBlocks", "readRNAProperties", "listRNACollections", "readRNAPropertySchema", "readRNACollection", "submitServerOperatorJob", "cancelServerOperatorJob", "readServerOperatorResult"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "invokeBlenderMacro", "invokeFeatureRoute", "importScene", "assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload", "setRNAProperty", "setRNAArrayProperty", "setRNAEnumProperty"]);
|
||||
|
||||
function validateRNAParams(command: WebEngineRouteJsonCommand, value: unknown): void {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw report("INVALID_ARGUMENT", `${command} requires an object params payload`, true);
|
||||
const params = value as Record<string, unknown>;
|
||||
const allowed = command === "listRNADataBlocks" ? ["type", "dataBlockType", "editableOnly", "limit"] : command === "setRNAProperty" ? ["type", "dataBlockType", "name", "dataBlockName", "property", "propertyIdentifier", "value", "baseRevision"] : ["type", "dataBlockType", "name", "dataBlockName", "limit"];
|
||||
const readSchema = command === "readRNAPropertySchema";
|
||||
const readCollection = command === "readRNACollection";
|
||||
const arrayWrite = command === "setRNAArrayProperty";
|
||||
const enumWrite = command === "setRNAEnumProperty";
|
||||
const allowed = command === "listRNADataBlocks"
|
||||
? ["type", "dataBlockType", "editableOnly", "limit"]
|
||||
: command === "setRNAProperty" || arrayWrite || enumWrite
|
||||
? ["type", "dataBlockType", "name", "dataBlockName", "property", "propertyIdentifier", "value", "baseRevision"]
|
||||
: readSchema
|
||||
? ["type", "dataBlockType", "name", "dataBlockName", "property", "propertyIdentifier"]
|
||||
: readCollection
|
||||
? ["type", "dataBlockType", "name", "dataBlockName", "property", "propertyIdentifier", "path", "limit"]
|
||||
: ["type", "dataBlockType", "name", "dataBlockName", "limit"];
|
||||
if (Object.keys(params).some((key) => !allowed.includes(key))) throw report("INVALID_ARGUMENT", `${command} received an unknown parameter`, true);
|
||||
const type = params.type ?? params.dataBlockType;
|
||||
const name = params.name ?? params.dataBlockName;
|
||||
@@ -1316,7 +1396,7 @@ function validateRNAParams(command: WebEngineRouteJsonCommand, value: unknown):
|
||||
if (property !== undefined && (typeof property !== "string" || property.length === 0 || property.length > 128)) throw report("INVALID_ARGUMENT", `${command} requires a bounded RNA property identifier`, true);
|
||||
if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || (params.limit as number) < 1 || (params.limit as number) > 1024)) throw report("INVALID_ARGUMENT", `${command} limit is outside the bounded reflection budget`, true);
|
||||
if (params.editableOnly !== undefined && typeof params.editableOnly !== "boolean") throw report("INVALID_ARGUMENT", `${command} editableOnly must be boolean`, true);
|
||||
if (command === "setRNAProperty" && (property === undefined || !Object.prototype.hasOwnProperty.call(params, "value"))) throw report("INVALID_ARGUMENT", "setRNAProperty requires property and value", true);
|
||||
if ((command === "setRNAProperty" || arrayWrite || enumWrite) && (property === undefined || !Object.prototype.hasOwnProperty.call(params, "value"))) throw report("INVALID_ARGUMENT", `${command} requires property and value`, true);
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || (params.baseRevision as number) < 0)) throw report("REVISION_CONFLICT", "setRNAProperty baseRevision must be non-negative", true);
|
||||
const valueIsSafe = (candidate: unknown, depth = 0): boolean => {
|
||||
if (depth > 4 || candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return candidate === null || typeof candidate !== "string" || candidate.length <= 4096;
|
||||
@@ -1324,7 +1404,19 @@ function validateRNAParams(command: WebEngineRouteJsonCommand, value: unknown):
|
||||
if (Array.isArray(candidate)) return candidate.length <= 64 && candidate.every((item) => valueIsSafe(item, depth + 1));
|
||||
return false;
|
||||
};
|
||||
if (command === "setRNAProperty" && !valueIsSafe(params.value)) throw report("INVALID_ARGUMENT", "setRNAProperty value must be bounded scalar JSON", true);
|
||||
if ((command === "setRNAProperty" || arrayWrite || enumWrite) && !valueIsSafe(params.value)) throw report("INVALID_ARGUMENT", `${command} value must be bounded JSON`, true);
|
||||
if (readCollection) {
|
||||
const path = params.path ?? property;
|
||||
if (typeof path !== "string" || path.length === 0 || path.length > 256 || path.split(".").some((part) => !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(part))) {
|
||||
throw report("INVALID_ARGUMENT", "readRNACollection requires a bounded RNA collection path", true);
|
||||
}
|
||||
}
|
||||
if (arrayWrite && (!Array.isArray(params.value) || params.value.length === 0 || params.value.length > 64)) {
|
||||
throw report("INVALID_ARGUMENT", "setRNAArrayProperty requires a bounded non-empty array", true);
|
||||
}
|
||||
if (enumWrite && !(typeof params.value === "string" || (typeof params.value === "number" && Number.isSafeInteger(params.value)))) {
|
||||
throw report("INVALID_ARGUMENT", "setRNAEnumProperty requires an enum identifier or integer value", true);
|
||||
}
|
||||
}
|
||||
|
||||
function readSnapshot(): ReturnType<typeof parseSceneSnapshotIR> {
|
||||
@@ -1819,7 +1911,10 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
|
||||
throw report("INVALID_ARGUMENT", "cmd json applyCommand requires params.command", true);
|
||||
}
|
||||
if (commandName === "invokeBlenderOperator") validateOperatorInvocation(request.command.params);
|
||||
if (["listRNADataBlocks", "readRNAProperties", "setRNAProperty", "listRNACollections"].includes(commandName)) validateRNAParams(commandName, request.command.params ?? {});
|
||||
if (commandName === "invokeBlenderMacro") validateMacroInvocation(request.command.params);
|
||||
if (commandName === "invokeFeatureRoute") validateFeatureRouteInvocation(request.command.params);
|
||||
if (["submitServerOperatorJob", "cancelServerOperatorJob", "readServerOperatorResult"].includes(commandName)) validateServerOperatorJobParams(commandName, request.command.params ?? {});
|
||||
if (["listRNADataBlocks", "readRNAProperties", "setRNAProperty", "listRNACollections", "readRNAPropertySchema", "setRNAArrayProperty", "setRNAEnumProperty", "readRNACollection"].includes(commandName)) validateRNAParams(commandName, request.command.params ?? {});
|
||||
if (["assetMark", "assetCatalog", "libraryLink", "libraryOverride", "libraryReload"].includes(commandName)) {
|
||||
try {
|
||||
parseAssetLibraryCommand(commandName, request.command.params ?? {});
|
||||
|
||||
@@ -34,3 +34,65 @@ export interface BlenderOperatorInvocation {
|
||||
properties?: Record<string, BlenderOperatorPropertyValue>;
|
||||
baseRevision?: number;
|
||||
}
|
||||
|
||||
export type BlenderFeatureRoute = "WEB_MAIN" | "SERVER_NATIVE" | "BROWSER_EQUIVALENT" | "UNAVAILABLE";
|
||||
|
||||
export interface BlenderFeatureRouteLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
/** Source-unit route metadata used by the F3 feature palette. */
|
||||
export interface BlenderFeatureRouteEntry {
|
||||
id: string;
|
||||
kind: string;
|
||||
identifier: string;
|
||||
family: string;
|
||||
route: BlenderFeatureRoute;
|
||||
reason: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
properties?: BlenderOperatorPropertySpec[];
|
||||
locations?: BlenderFeatureRouteLocation[];
|
||||
}
|
||||
|
||||
export interface BlenderFeatureRouteInvocation {
|
||||
route: BlenderFeatureRouteEntry;
|
||||
properties?: Record<string, BlenderOperatorPropertyValue>;
|
||||
baseRevision?: number;
|
||||
}
|
||||
|
||||
/** SERVER_NATIVE jobs deliberately carry only typed scalar operator properties. */
|
||||
export const BLENDER_OPERATOR_BACKGROUND_CONTEXT = "BACKGROUND" as const;
|
||||
|
||||
export type BlenderMacroStepKind = "MAIN_COMMAND" | "SERVER_NATIVE" | "CONTEXT_DEPENDENT";
|
||||
|
||||
export interface BlenderMacroStep {
|
||||
id: string;
|
||||
kind: BlenderMacroStepKind;
|
||||
context: "MAIN" | "BACKGROUND" | "UI";
|
||||
operator?: string;
|
||||
command?: Record<string, unknown>;
|
||||
source: BlenderFeatureRouteLocation[];
|
||||
}
|
||||
|
||||
/** A bounded, source-backed native plan assembled from Blender operator units. */
|
||||
export interface BlenderMacroDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
family: string;
|
||||
route: BlenderFeatureRoute;
|
||||
contextDependent: boolean;
|
||||
source: BlenderFeatureRouteLocation[];
|
||||
steps: BlenderMacroStep[];
|
||||
properties?: BlenderOperatorPropertySpec[];
|
||||
}
|
||||
|
||||
export interface BlenderMacroInvocation {
|
||||
macro: string;
|
||||
properties?: Record<string, BlenderOperatorPropertyValue>;
|
||||
baseRevision?: number;
|
||||
serverJob?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -678,10 +678,11 @@ export interface SceneRNADataBlockIR {
|
||||
|
||||
export interface SceneRNAPropertyIR {
|
||||
identifier: string;
|
||||
type: "BOOLEAN" | "INT" | "FLOAT" | "STRING" | "ENUM";
|
||||
type: "BOOLEAN" | "INT" | "FLOAT" | "STRING" | "ENUM" | "COLLECTION";
|
||||
arrayLength: number;
|
||||
readonly: boolean;
|
||||
value?: boolean | number | string | null | Array<boolean | number | string | null>;
|
||||
collection?: { itemType?: string; nestedPath?: string };
|
||||
}
|
||||
|
||||
export interface SceneSnapshotIR {
|
||||
|
||||
154
web/protocol/server-operator-job.ts
Normal file
154
web/protocol/server-operator-job.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
import type { BlenderOperatorPropertyValue } from "./blender-operator";
|
||||
|
||||
export const SERVER_OPERATOR_JOB_SCHEMA = 1 as const;
|
||||
export const SERVER_OPERATOR_JOB_ROUTE = "SERVER_NATIVE" as const;
|
||||
export const SERVER_OPERATOR_JOB_BUDGET = {
|
||||
maxJobIdBytes: 128,
|
||||
maxOperatorBytes: 128,
|
||||
maxPropertiesBytes: 64 * 1024,
|
||||
maxPropertyCount: 128,
|
||||
maxStringPropertyBytes: 4096,
|
||||
maxSourceBytes: 512 * 1024 * 1024,
|
||||
minTimeoutMs: 1000,
|
||||
maxTimeoutMs: 10 * 60 * 1000,
|
||||
minMemoryBytes: 64 * 1024 * 1024,
|
||||
maxMemoryBytes: 4 * 1024 * 1024 * 1024,
|
||||
} as const;
|
||||
export const SERVER_OPERATOR_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
|
||||
export type ServerOperatorJobStatus = typeof SERVER_OPERATOR_JOB_STATUSES[number];
|
||||
export type ServerOperatorResultBinding = "json" | "snapshot" | "scene-delta";
|
||||
|
||||
export interface ServerOperatorBuildIdentityIR {
|
||||
version: string;
|
||||
buildSha256: string;
|
||||
}
|
||||
export interface ServerOperatorJobRequestIR {
|
||||
schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA;
|
||||
route: typeof SERVER_OPERATOR_JOB_ROUTE;
|
||||
jobId: string;
|
||||
sourceBlendSha256: string;
|
||||
sourceBlendByteLength: number;
|
||||
sourceRevision: number;
|
||||
blenderBuild: ServerOperatorBuildIdentityIR;
|
||||
operator: string;
|
||||
properties: Record<string, BlenderOperatorPropertyValue>;
|
||||
propertiesSha256: string;
|
||||
timeoutMs: number;
|
||||
memoryBytes: number;
|
||||
resultBinding: { kind: ServerOperatorResultBinding; key: string };
|
||||
context?: "BACKGROUND";
|
||||
requestSha256: string;
|
||||
}
|
||||
|
||||
export interface ServerOperatorJobResultIR {
|
||||
schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA;
|
||||
route: typeof SERVER_OPERATOR_JOB_ROUTE;
|
||||
jobId: string;
|
||||
status: ServerOperatorJobStatus;
|
||||
sourceBlendSha256: string;
|
||||
sourceRevision: number;
|
||||
blenderBuild: ServerOperatorBuildIdentityIR;
|
||||
propertiesSha256: string;
|
||||
requestSha256: string;
|
||||
resultSha256: string;
|
||||
result?: unknown;
|
||||
errorCode?: ErrorCode;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class ServerOperatorJobValidationError extends Error {
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(message: string, code: ErrorCode = "INVALID_ARGUMENT") {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "ServerOperatorJobValidationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
|
||||
const set = new Set(allowed);
|
||||
if (Object.keys(value).some((key) => !set.has(key))) throw new ServerOperatorJobValidationError(`${name} contains undeclared fields`);
|
||||
}
|
||||
|
||||
function digest(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !SHA256.test(value)) throw new ServerOperatorJobValidationError(`${name} must be a lowercase SHA-256 digest`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > SERVER_OPERATOR_JOB_BUDGET.maxPropertyCount * 4 || state.depth > 8) throw new ServerOperatorJobValidationError("properties exceed the JSON budget");
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableJSON(item, { ...state, depth: state.depth + 1 })).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key], { ...state, depth: state.depth + 1 })}`).join(",")}}`;
|
||||
throw new ServerOperatorJobValidationError("properties contain a non-JSON value");
|
||||
}
|
||||
|
||||
function validateProperties(value: unknown): Record<string, BlenderOperatorPropertyValue> {
|
||||
if (!record(value)) throw new ServerOperatorJobValidationError("properties must be an object");
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > SERVER_OPERATOR_JOB_BUDGET.maxPropertyCount) throw new ServerOperatorJobValidationError("properties exceed the property-count budget");
|
||||
for (const [key, item] of entries) {
|
||||
if (!KEY.test(key)) throw new ServerOperatorJobValidationError(`property key ${key} is invalid`);
|
||||
if (!((typeof item === "string" && new TextEncoder().encode(item).byteLength <= SERVER_OPERATOR_JOB_BUDGET.maxStringPropertyBytes) ||
|
||||
(typeof item === "number" && Number.isFinite(item)) || typeof item === "boolean")) {
|
||||
throw new ServerOperatorJobValidationError(`property ${key} must be a bounded typed scalar`);
|
||||
}
|
||||
}
|
||||
if (new TextEncoder().encode(stableJSON(value)).byteLength > SERVER_OPERATOR_JOB_BUDGET.maxPropertiesBytes) throw new ServerOperatorJobValidationError("properties exceed the byte budget");
|
||||
return value as Record<string, BlenderOperatorPropertyValue>;
|
||||
}
|
||||
|
||||
export function canonicalServerOperatorJobRequest(request: Omit<ServerOperatorJobRequestIR, "requestSha256">): string {
|
||||
return stableJSON({ ...request, context: request.context ?? "BACKGROUND" });
|
||||
}
|
||||
|
||||
export function parseServerOperatorJobRequest(value: unknown): ServerOperatorJobRequestIR {
|
||||
if (!record(value)) throw new ServerOperatorJobValidationError("request must be an object");
|
||||
exactKeys(value, ["schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "context", "requestSha256"], "request");
|
||||
if (value.schemaVersion !== SERVER_OPERATOR_JOB_SCHEMA || value.route !== SERVER_OPERATOR_JOB_ROUTE) throw new ServerOperatorJobValidationError("schemaVersion and route do not match SERVER_NATIVE contract");
|
||||
if (typeof value.jobId !== "string" || !JOB_ID.test(value.jobId)) throw new ServerOperatorJobValidationError("jobId is invalid");
|
||||
const sourceBlendByteLength = value.sourceBlendByteLength;
|
||||
if (typeof sourceBlendByteLength !== "number" || !Number.isSafeInteger(sourceBlendByteLength) || sourceBlendByteLength < 1 || sourceBlendByteLength > SERVER_OPERATOR_JOB_BUDGET.maxSourceBytes) throw new ServerOperatorJobValidationError("source blend size is outside the budget");
|
||||
if (typeof value.sourceRevision !== "number" || !Number.isSafeInteger(value.sourceRevision) || value.sourceRevision < 0) throw new ServerOperatorJobValidationError("sourceRevision is invalid");
|
||||
if (typeof value.operator !== "string" || value.operator.length === 0 || new TextEncoder().encode(value.operator).byteLength > SERVER_OPERATOR_JOB_BUDGET.maxOperatorBytes || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value.operator)) throw new ServerOperatorJobValidationError("operator is invalid");
|
||||
if (!record(value.blenderBuild)) throw new ServerOperatorJobValidationError("blenderBuild must be an object");
|
||||
exactKeys(value.blenderBuild, ["version", "buildSha256"], "blenderBuild");
|
||||
if (typeof value.blenderBuild.version !== "string" || value.blenderBuild.version.length === 0 || value.blenderBuild.version.length > 64) throw new ServerOperatorJobValidationError("blenderBuild.version is invalid");
|
||||
const blenderBuild = { version: value.blenderBuild.version, buildSha256: digest(value.blenderBuild.buildSha256, "blenderBuild.buildSha256") };
|
||||
const properties = validateProperties(value.properties);
|
||||
const timeoutMs = value.timeoutMs;
|
||||
const memoryBytes = value.memoryBytes;
|
||||
if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < SERVER_OPERATOR_JOB_BUDGET.minTimeoutMs || timeoutMs > SERVER_OPERATOR_JOB_BUDGET.maxTimeoutMs) throw new ServerOperatorJobValidationError("timeoutMs is outside the budget");
|
||||
if (typeof memoryBytes !== "number" || !Number.isSafeInteger(memoryBytes) || memoryBytes < SERVER_OPERATOR_JOB_BUDGET.minMemoryBytes || memoryBytes > SERVER_OPERATOR_JOB_BUDGET.maxMemoryBytes) throw new ServerOperatorJobValidationError("memoryBytes is outside the budget");
|
||||
if (!record(value.resultBinding) || !["json", "snapshot", "scene-delta"].includes(value.resultBinding.kind as string) || typeof value.resultBinding.key !== "string" || !KEY.test(value.resultBinding.key)) throw new ServerOperatorJobValidationError("resultBinding is invalid");
|
||||
if (value.context !== undefined && value.context !== "BACKGROUND") throw new ServerOperatorJobValidationError("UI, window and file-browser contexts are not supported");
|
||||
return {
|
||||
schemaVersion: SERVER_OPERATOR_JOB_SCHEMA,
|
||||
route: SERVER_OPERATOR_JOB_ROUTE,
|
||||
jobId: value.jobId,
|
||||
sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"),
|
||||
sourceBlendByteLength,
|
||||
sourceRevision: value.sourceRevision,
|
||||
blenderBuild,
|
||||
operator: value.operator,
|
||||
properties,
|
||||
propertiesSha256: digest(value.propertiesSha256, "propertiesSha256"),
|
||||
timeoutMs,
|
||||
memoryBytes,
|
||||
resultBinding: { kind: value.resultBinding.kind as ServerOperatorResultBinding, key: value.resultBinding.key },
|
||||
...(value.context === undefined ? {} : { context: "BACKGROUND" as const }),
|
||||
requestSha256: digest(value.requestSha256, "requestSha256"),
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
setObjectVisibility: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setModifierEnabled: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setModifierVisibility: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
addModifier: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
removeModifier: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
moveModifier: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setModifierParameters: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
decimateMesh: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
previewDecimateMesh: { family: "MESH", route: "PREVIEW", implementation: "SOURCE_BACKED" },
|
||||
createPrimitive: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -34,6 +38,12 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
setObjectTransform: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
translateMeshVertices: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshEdit: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshSubdivide: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshTriangulate: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshRecalculateNormals: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshBridgeLoops: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshDeleteElements: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
meshUVProject: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
createUVMap: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setActiveUVMap: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
unwrapUV: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -47,6 +57,22 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
deleteObjectKeyframe: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFCurveInterpolation: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setActiveAction: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
createAction: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
removeAction: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setKeyframe: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setTimelineMarkers: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFCurveKeyframes: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setDriverExpression: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setActionGroups: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setKeyingSet: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
bakeAnimation: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setArmatureBones: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setPoseBoneTransform: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
armatureEditOperations: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setBoneRoll: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setBoneSymmetry: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setBoneParenting: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setPoseConstraint: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setConstraint: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setParent: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
createCollection: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -56,13 +82,21 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
separateMeshFaces: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
applyObjectTransform: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setObjectOrigin: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
clearObjectTransform: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
createObjectData: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setObjectSelection: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCollectionVisibility: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
createCurve: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
deleteNonMeshData: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveControlPoints: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveBevel: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveTaper: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
convertCurveType: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveHandle: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveTopology: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCurveSplines: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSurfaceTopology: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSurfaceResolution: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFontBody: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFontProperties: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFontAdvanced: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -81,6 +115,10 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
insertGreasePencilFrame: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
removeGreasePencilFrame: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGreasePencilStrokes: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGreasePencilDrawing: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGreasePencilMaterial: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGreasePencilSelection: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGreasePencilInterpolate: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setVertexColors: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setVertexWeights: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setLightProperties: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -110,6 +148,11 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
setSceneViewLayerPassSettings: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSceneViewLayerAOVs: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSceneViewLayerLightgroups: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
renderFrame: { family: "RENDER", route: "MAIN_GUARDED", implementation: "SOURCE_BACKED" },
|
||||
renderAnimation: { family: "RENDER", route: "MAIN_GUARDED", implementation: "SOURCE_BACKED" },
|
||||
setRenderPasses: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCameraView: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setLightLinking: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setObjectLightgroup: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setWorldLightgroup: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setMetaballElements: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
@@ -117,8 +160,22 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
setSculptMeshAttributes: { family: "SCULPT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setGeometryNodeGraph: { family: "GEOMETRY_NODES", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setPhysicsSimulation: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setRigidBodySettings: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setClothSettings: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setFluidSettings: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setParticleCache: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSimulationPlayback: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setShaderGraph: { family: "SHADER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setNLAStack: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSequencerStrips: { family: "MEDIA", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSequencerStripModifiers: { family: "MEDIA", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSequencerTransitions: { family: "MEDIA", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setSequencerColorBalance: { family: "MEDIA", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setCompositorNodeGraph: { family: "COMPOSITOR", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setTrackingMarkers: { family: "TRACKING", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setTrackingSolve: { family: "TRACKING", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setMaskSpline: { family: "TRACKING", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setMaskPoint: { family: "TRACKING", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
moveNLAStrip: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
undo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
|
||||
redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
|
||||
|
||||
@@ -290,18 +290,20 @@ export interface RNAPropertyDescriptor {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "BOOLEAN" | "INT" | "FLOAT" | "STRING" | "ENUM";
|
||||
type: "BOOLEAN" | "INT" | "FLOAT" | "STRING" | "ENUM" | "COLLECTION";
|
||||
subtype?: number;
|
||||
arrayLength: number;
|
||||
readonly: boolean;
|
||||
value?: RNAPropertyValue | RNAPropertyValue[];
|
||||
enumItems?: Array<{ identifier: string; name: string; value: number }>;
|
||||
collection?: { itemType?: string; nestedPath?: string };
|
||||
}
|
||||
export interface RNACollectionDescriptor {
|
||||
identifier: string;
|
||||
name: string;
|
||||
itemType?: string;
|
||||
items: Array<{ name: string; type?: string }>;
|
||||
path?: string;
|
||||
items: Array<{ name?: string; index?: number; type?: string; items?: Array<unknown> }>;
|
||||
}
|
||||
|
||||
/** Read-only commands supported by the stable Worker-to-WASM JSON facade. */
|
||||
@@ -315,6 +317,10 @@ export type WebEngineJsonCommand =
|
||||
| "redo"
|
||||
| "listBlenderOperators"
|
||||
| "invokeBlenderOperator"
|
||||
| "listBlenderMacros"
|
||||
| "invokeBlenderMacro"
|
||||
| "listFeatureRoutes"
|
||||
| "invokeFeatureRoute"
|
||||
| "assetMark"
|
||||
| "assetCatalog"
|
||||
| "libraryLink"
|
||||
@@ -323,7 +329,11 @@ export type WebEngineJsonCommand =
|
||||
| "listRNADataBlocks"
|
||||
| "readRNAProperties"
|
||||
| "setRNAProperty"
|
||||
| "listRNACollections";
|
||||
| "listRNACollections"
|
||||
| "readRNAPropertySchema"
|
||||
| "setRNAArrayProperty"
|
||||
| "setRNAEnumProperty"
|
||||
| "readRNACollection";
|
||||
|
||||
export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "init" } }
|
||||
|
||||
Reference in New Issue
Block a user