feat(web): complete Blender route runtime execution
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 00:12:32 +08:00
parent e2e81c7c00
commit e9bfef0c5a
35 changed files with 57527 additions and 75 deletions

View File

@@ -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

View File

@@ -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 }> {

View File

@@ -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;
}