feat(web): add an interactive Geometry Nodes canvas
- 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:
@@ -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 })} />
|
||||
|
||||
220
web/app/src/app/ui/GeometryNodeCanvas.tsx
Normal file
220
web/app/src/app/ui/GeometryNodeCanvas.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user