feat(web): add an interactive Geometry Nodes canvas
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

- Render the active mesh node graph with React Flow: add/remove nodes,
  drag/connect sockets, edit defaults and commit through setGeometryNodeGraph.
- Keep the Properties form editor as an alternate entry.
- Add canvas E2E for add/connect/commit and OPFS save/reopen.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 05:09:04 +08:00
parent d959f66839
commit aa2c469db4
6 changed files with 347 additions and 8 deletions

View File

@@ -17,6 +17,7 @@ import type {NativeExportFormat} from '../../../protocol/native-workflow';
import {NativeSessionPanel} from './NativeSessionPanel';
import {WasmGPUCanvas} from './WasmGPUCanvas';
import {WasmShaderEditor} from './WasmShaderEditor';
import {GeometryNodeCanvas} from './ui/GeometryNodeCanvas';
import { ensurePbrAssetLibrary, ensurePbrHighQualityLibrary, PBR_LIBRARY_PROJECT_ID, pbrLibraryWarnings, repairPbrAssetLibrary } from './pbr-library-seed';
import { createAssetPreviewIdentity } from '../../../protocol/asset-preview';
import { AssetLibraryPanel } from './AssetLibraryPanel';
@@ -8207,7 +8208,7 @@ else:
<PbrLessonPanel catalog={assetCatalog} disabled={projectActionLocked} busy={assetLibraryBusy} activeObjectName={snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId)?.name ?? ""} onLoadPreview={loadPbrAssetPreview} onAssignMaterial={assignPbrMaterial} onLoadStudio={() => openTutorialScene("/tutorial/pbr/studio.blend", "PBR_Studio.blend")} onImportGlb={() => fileInputRef.current?.click()} onEnableHighQuality={enableHighQualityLibrary} highQualityReady={Boolean(assetCatalog?.libraries.some((library) => library.libraryId === "library:re-pbr-hq-v1"))} />
<details className="native-render-fallback"><summary>桌面 EEVEE 渲染(可选)</summary><NativeRenderPanel client={webClientRef.current} snapshot={snapshot} busy={nativeBusy} output={nativeOutput} files={nativeFiles} onRun={runNativeWorkflow} onCancel={()=>nativeAbortRef.current?.abort()} /></details>
</Area> : null}
{workspaceNodeEditor ? <Area className="node-editor-area" editor={workspaceNodeEditor}>{workspaceNodeEditor==='Shader Editor'?<WasmShaderEditor snapshot={snapshot} onCommand={applyEditCommand} onPackExternalImage={(imageId, file) => void packExternalImage(imageId, file)} textureAssets={gpuTextureAssets} activeMaterialId={activeMaterialId} onSavePreview={(material, png) => void saveMaterialPreview(material, png)}/>:<WorkspaceEditorSurface editor={workspaceNodeEditor} snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />}</Area> : null}
{workspaceNodeEditor ? <Area className="node-editor-area" editor={workspaceNodeEditor}>{workspaceNodeEditor==='Shader Editor'?<WasmShaderEditor snapshot={snapshot} onCommand={applyEditCommand} onPackExternalImage={(imageId, file) => void packExternalImage(imageId, file)} textureAssets={gpuTextureAssets} activeMaterialId={activeMaterialId} onSavePreview={(material, png) => void saveMaterialPreview(material, png)}/>:workspaceNodeEditor==='Geometry Nodes'?<GeometryNodeCanvas snapshot={snapshot} onCommit={(meshId, graph) => applyEditCommand(geometryNodeCommands.setGraph(meshId, graph))}/>:<WorkspaceEditorSurface editor={workspaceNodeEditor} snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />}</Area> : null}
{workspace === "Shading" ? <Area className="file-browser-area" editor={localOnly ? "File Browser" : "Asset Library"}>
{localOnly
? <WorkspaceEditorSurface editor="File Browser" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />

View File

@@ -0,0 +1,220 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import {
Background,
Controls,
Handle,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
type Connection,
type Edge,
type Node,
type NodeProps,
type NodeChange,
} from "@xyflow/react";
import type { SceneSnapshotIR } from "../../../../protocol/scene-ir";
import {
GEOMETRY_NODE_ALLOWLIST,
type GeometryNodeGraphIR,
type GeometryNodeIR,
type GeometryNodeLinkIR,
} from "../../../../protocol/geometry-nodes";
import { geometryNodeCommands } from "../../../../protocol/geometry-node-commands";
interface GeometryNodeData extends Record<string, unknown> {
node: GeometryNodeIR;
}
type GeometryFlowNode = Node<GeometryNodeData, "geometryBlenderNode">;
type GeometryFlowEdge = Edge<Record<string, unknown>>;
function GeometryNodeCard({ data, selected }: NodeProps<GeometryFlowNode>) {
const node = data.node;
const inputs = node.sockets.filter((socket) => socket.direction === "INPUT");
const outputs = node.sockets.filter((socket) => socket.direction === "OUTPUT");
return (
<div className={`gn-canvas-node${selected ? " selected" : ""}`}>
<header><strong>{node.name}</strong><small>{node.type}</small></header>
<div className="gn-canvas-sockets">
<div className="gn-canvas-inputs">
{inputs.map((socket) => (
<div className="gn-canvas-socket" key={`in:${socket.id}`}>
<Handle id={`INPUT:${socket.id}`} type="target" position={Position.Left} />
<span>{socket.name}</span>
</div>
))}
</div>
<div className="gn-canvas-outputs">
{outputs.map((socket) => (
<div className="gn-canvas-socket" key={`out:${socket.id}`}>
<span>{socket.name}</span>
<Handle id={`OUTPUT:${socket.id}`} type="source" position={Position.Right} />
</div>
))}
</div>
</div>
</div>
);
}
const nodeTypes = { geometryBlenderNode: GeometryNodeCard };
function defaultPosition(graph: GeometryNodeGraphIR, node: GeometryNodeIR, index: number): { x: number; y: number } {
if (node.type === "NodeGroupInput") return { x: 20, y: 80 };
if (node.type === "NodeGroupOutput") return { x: 620, y: 80 };
return { x: 250 + (index % 3) * 220, y: 60 + Math.floor(index / 3) * 150 };
}
function CanvasInner({ snapshot, onCommit }: { snapshot: SceneSnapshotIR | null; onCommit: (meshId: string, graph: GeometryNodeGraphIR) => Promise<boolean> }) {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const meshId = activeNode?.dataId?.startsWith("mesh:") ? activeNode.dataId : "";
const source = snapshot?.geometryNodeGraphs?.[0];
const [draft, setDraft] = useState<GeometryNodeGraphIR | null>(null);
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
const [selectedId, setSelectedId] = useState("");
const [addType, setAddType] = useState<string>(GEOMETRY_NODE_ALLOWLIST[2]);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("读取当前网格的 Geometry Nodes 图");
useEffect(() => {
if (!meshId) { setDraft(null); return; }
const next = source ? JSON.parse(JSON.stringify(source)) as GeometryNodeGraphIR : geometryNodeCommands.createGraph(meshId, `${activeNode?.name ?? "Mesh"} Geometry`);
setDraft(next);
setPositions((current) => {
const merged: Record<string, { x: number; y: number }> = {};
next.nodes.forEach((node, index) => { merged[node.id] = current[node.id] ?? defaultPosition(next, node, index); });
return merged;
});
setSelectedId(next.nodes[0]?.id ?? "");
setStatus(source ? `已载入 ${source.name} · ${source.nodes.length} 节点` : "已创建新图(尚未提交)");
}, [meshId, source?.id, source?.graphHash, activeNode?.name]);
const flowNodes: GeometryFlowNode[] = useMemo(() => (draft?.nodes ?? []).map((node, index) => ({
id: node.id,
type: "geometryBlenderNode" as const,
position: positions[node.id] ?? defaultPosition(draft!, node, index),
data: { node },
selected: node.id === selectedId,
})), [draft, positions, selectedId]);
const flowEdges: GeometryFlowEdge[] = useMemo(() => (draft?.links ?? []).map((link) => ({
id: `${link.fromNodeId}:${link.fromSocketId}->${link.toNodeId}:${link.toSocketId}`,
source: link.fromNodeId,
target: link.toNodeId,
sourceHandle: `OUTPUT:${link.fromSocketId}`,
targetHandle: `INPUT:${link.toSocketId}`,
animated: false,
style: { stroke: "#d09b45", strokeWidth: 2 },
})), [draft]);
const commit = async (): Promise<void> => {
if (!draft || !meshId) return;
setBusy(true);
try {
const ok = await onCommit(meshId, draft);
setStatus(ok ? "Geometry Nodes 图已提交(可撤销)" : "提交失败,请查看引擎状态");
}
finally { setBusy(false); }
};
const addNode = (): void => {
if (!draft) return;
const next = geometryNodeCommands.addNode(draft, addType, addType);
const created = next.nodes[next.nodes.length - 1];
setDraft(next);
if (created) setPositions((current) => ({ ...current, [created.id]: { x: 240 + (next.nodes.length % 4) * 190, y: 90 + Math.floor(next.nodes.length / 4) * 130 } }));
setSelectedId(created?.id ?? selectedId);
setStatus(`已添加 ${addType}(未提交)`);
};
const connect = (connection: Connection): void => {
if (!draft || !connection.source || !connection.target) return;
const fromSocket = connection.sourceHandle?.replace(/^OUTPUT:/, "");
const toSocket = connection.targetHandle?.replace(/^INPUT:/, "");
if (!fromSocket || !toSocket) return;
const link: GeometryNodeLinkIR = { fromNodeId: connection.source, fromSocketId: fromSocket, toNodeId: connection.target, toSocketId: toSocket };
setDraft(geometryNodeCommands.connect(draft, link));
setStatus("已连线(未提交)");
};
const removeSelected = (): void => {
if (!draft || !selectedId) return;
const node = draft.nodes.find((candidate) => candidate.id === selectedId);
if (!node || node.type === "NodeGroupInput" || node.type === "NodeGroupOutput") return;
setDraft(geometryNodeCommands.removeNode(draft, selectedId));
setSelectedId("");
setStatus(`已移除 ${node.name}(未提交)`);
};
const selectedNode = draft?.nodes.find((node) => node.id === selectedId) ?? null;
const onNodesChange = (changes: NodeChange<GeometryFlowNode>[]): void => {
for (const change of changes) {
if (change.type === "position" && change.position) setPositions((current) => ({ ...current, [change.id]: change.position! }));
if (change.type === "select" && change.selected) setSelectedId(change.id);
}
};
const onNodeClick = (_event: MouseEvent, node: GeometryFlowNode): void => setSelectedId(node.id);
if (!meshId) return <section className="gn-canvas-shell"><div className="gn-canvas-empty">选择一个网格对象后编辑 Geometry Nodes 图。</div></section>;
return (
<section className="gn-canvas-shell" data-testid="geometry-node-canvas" data-node-count={draft?.nodes.length ?? 0} data-link-count={draft?.links.length ?? 0} data-selected-node={selectedId}>
<header className="gn-canvas-toolbar">
<strong>Geometry Nodes</strong>
<select aria-label="添加节点类型" value={addType} onChange={(event) => setAddType(event.target.value)}>
{GEOMETRY_NODE_ALLOWLIST.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
<button type="button" onClick={addNode}>添加节点</button>
<button type="button" disabled={!selectedId} onClick={removeSelected}>移除节点</button>
<button type="button" disabled={!draft || busy} onClick={() => void commit()}>{busy ? "提交中…" : "提交图"}</button>
<output>{status}</output>
</header>
<div className="gn-canvas-flow">
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeClick={onNodeClick}
onConnect={connect}
fitView
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
<aside className="gn-canvas-inspector">
<strong>{selectedNode ? selectedNode.name : "未选择节点"}</strong>
{selectedNode ? <>
<small>{selectedNode.type}</small>
{selectedNode.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => (
<label key={socket.id}>{socket.name}
{socket.defaultValue === undefined
? <span>linked</span>
: <input
aria-label={`${selectedNode.name} ${socket.name}`}
type={typeof socket.defaultValue === "boolean" ? "checkbox" : "text"}
checked={typeof socket.defaultValue === "boolean" ? socket.defaultValue : undefined}
value={typeof socket.defaultValue === "boolean" ? undefined : Array.isArray(socket.defaultValue) ? socket.defaultValue.join(",") : String(socket.defaultValue)}
onChange={(event) => {
const raw = typeof socket.defaultValue === "boolean" ? event.target.checked : event.target.value;
const value = Array.isArray(socket.defaultValue)
? raw.toString().split(",").map((item) => Number(item.trim())).filter((item) => Number.isFinite(item))
: typeof socket.defaultValue === "number" ? Number(raw) : raw;
setDraft((current) => current ? geometryNodeCommands.setSocketValue(current, selectedNode.id, socket.id, value) : current);
}}
/>}
</label>
))}
<output>{selectedNode.sockets.length} sockets</output>
</> : <p>点击节点查看输入;拖拽节点调整位置;拖动端口连线。</p>}
</aside>
</section>
);
}
export function GeometryNodeCanvas(props: { snapshot: SceneSnapshotIR | null; onCommit: (meshId: string, graph: GeometryNodeGraphIR) => Promise<boolean> }) {
return <ReactFlowProvider><CanvasInner {...props} /></ReactFlowProvider>;
}

View File

@@ -78,3 +78,24 @@
.texture-paint-canvas-wrap { position: relative; flex: 1; min-height: 0; overflow: auto; display: grid; place-items: center; background: #202020; }
.texture-paint-canvas-wrap canvas { max-width: 100%; max-height: 100%; display: block; cursor: crosshair; touch-action: none; background: #303030; }
.texture-paint-empty { padding: 20px; color: #aaa; text-align: center; }
.gn-canvas-shell { height: 100%; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 220px; grid-template-rows: auto minmax(0, 1fr); color: #ddd; background: #1f1f1f; }
.gn-canvas-toolbar { grid-column: 1 / 3; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 5px; background: #292929; border-bottom: 1px solid #444; }
.gn-canvas-toolbar output { flex-basis: 100%; color: #b8b8b8; }
.gn-canvas-toolbar button { padding: 3px 7px; color: #ddd; background: #454545; border-radius: 2px; }
.gn-canvas-toolbar select { color: #ddd; background: #252525; border: 1px solid #505050; border-radius: 2px; }
.gn-canvas-flow { min-height: 0; position: relative; }
.gn-canvas-flow .react-flow { width: 100%; height: 100%; }
.gn-canvas-inspector { display: flex; flex-direction: column; gap: 5px; min-height: 0; overflow: auto; padding: 7px; background: #242424; border-left: 1px solid #444; }
.gn-canvas-inspector label { display: flex; flex-direction: column; gap: 2px; color: #bdbdbd; font-size: 11px; }
.gn-canvas-inspector input { min-width: 0; padding: 3px 5px; color: #ddd; background: #1f1f1f; border: 1px solid #505050; border-radius: 2px; }
.gn-canvas-node { width: 190px; color: #ddd; background: #353535; border: 1px solid #565656; border-radius: 5px; box-shadow: 0 4px 10px #0007; font-size: 11px; }
.gn-canvas-node.selected { border-color: #e58b35; box-shadow: 0 0 0 1px #e58b35; }
.gn-canvas-node > header { display: flex; justify-content: space-between; gap: 4px; padding: 5px 7px; background: #4775a3; border-radius: 4px 4px 0 0; }
.gn-canvas-node > header small { overflow: hidden; color: #dbe7f5; font-size: 9px; text-overflow: ellipsis; }
.gn-canvas-sockets { display: flex; justify-content: space-between; gap: 8px; padding: 6px 7px; }
.gn-canvas-inputs, .gn-canvas-outputs { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.gn-canvas-socket { position: relative; display: flex; align-items: center; gap: 4px; color: #c9c9c9; white-space: nowrap; }
.gn-canvas-outputs .gn-canvas-socket { justify-content: flex-end; }
.gn-canvas-socket .react-flow__handle { width: 8px; height: 8px; background: #d09b45; border: 1px solid #ffe0a3; }
.gn-canvas-empty { padding: 24px; color: #aaa; text-align: center; }