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:
@@ -65,22 +65,34 @@
|
|||||||
- 画布中心像素由原图变为红色(绘制)、撤销后恢复;
|
- 画布中心像素由原图变为红色(绘制)、撤销后恢复;
|
||||||
- OPFS 保存 → reload → 最近项目恢复 → 重开后中心像素仍为红色。
|
- OPFS 保存 → reload → 最近项目恢复 → 重开后中心像素仍为红色。
|
||||||
|
|
||||||
|
### 5. Geometry Nodes 画布
|
||||||
|
|
||||||
|
- 新 `web/app/src/app/ui/GeometryNodeCanvas.tsx`:React Flow 画布,读取当前网格的
|
||||||
|
`geometryNodeGraphs[0]`,支持添加/移除 allowlist 节点、端口拖拽连线、节点拖拽布局、socket 默认值编辑、
|
||||||
|
提交 `setGeometryNodeGraph`;替换 Geometry Nodes 工作区的只读节点列表。
|
||||||
|
- 保留 Properties 的表单式图编辑器作为备用入口;两者都提交同一原生命令。
|
||||||
|
- 验证:
|
||||||
|
`DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome GPU_MODE=hardware npx playwright test --config playwright.config.ts tests/e2e/web-geonodes-canvas.spec.ts --workers=1`
|
||||||
|
→ **2/2 PASS(25.1s)**:
|
||||||
|
- 添加 Transform、从 Group Input 拖拽连接到 Transform,提交后 3 节点/2 连线,graphCount=1;
|
||||||
|
- OPFS 保存 → reload → 最近项目恢复 → 重开后仍是 3 节点/2 连线。
|
||||||
|
|
||||||
## 待实现(按计划顺序)
|
## 待实现(按计划顺序)
|
||||||
|
|
||||||
1. **Geometry Nodes 画布**:共享图状态、React Flow 画布、位置/rename/属性、结果面板;
|
1. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。
|
||||||
需要一次 WASM 重建(location/properties)。
|
2. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次
|
||||||
2. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。
|
|
||||||
3. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次
|
|
||||||
WASM 工作。
|
WASM 工作。
|
||||||
4. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。
|
3. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。
|
||||||
5. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。
|
4. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。
|
||||||
|
|
||||||
## 已知限制 / 未验证
|
## 已知限制 / 未验证
|
||||||
|
|
||||||
- 纹理绘制只支持本地 packed 图像;库链接图像(直接分配的 RE 材质贴图)被原生
|
- 纹理绘制只支持本地 packed 图像;库链接图像(直接分配的 RE 材质贴图)被原生
|
||||||
`packImage cannot modify linked library images` 拒绝。课程通过导入 RE 派生的本地 PNG 并设为
|
`packImage cannot modify linked library images` 拒绝。课程通过导入 RE 派生的本地 PNG 并设为
|
||||||
Base Color 来绘制;后续可加“Make Local”入口。
|
Base Color 来绘制;后续可加“Make Local”入口。
|
||||||
- Compositor 画布、GN 画布、Spreadsheet 尚未实现;课程/视频尚未开始。
|
- GN 画布节点位置目前只在会话内保存;`GeometryNodeIR` 尚无 `location`,原生 writer/reader 未写
|
||||||
|
`locx/locy`,保存重开保留节点/连线但不保留布局。Properties 表单与画布各自持有 draft,未完全共享。
|
||||||
|
- Compositor 画布、Spreadsheet 尚未实现;课程/视频尚未开始。
|
||||||
- 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。
|
- 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。
|
||||||
- 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。
|
- 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。
|
||||||
- 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。
|
- 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。
|
||||||
|
|||||||
BIN
tests/golden/GEONODES-CANVAS-20261113.png
Normal file
BIN
tests/golden/GEONODES-CANVAS-20261113.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
@@ -17,6 +17,7 @@ import type {NativeExportFormat} from '../../../protocol/native-workflow';
|
|||||||
import {NativeSessionPanel} from './NativeSessionPanel';
|
import {NativeSessionPanel} from './NativeSessionPanel';
|
||||||
import {WasmGPUCanvas} from './WasmGPUCanvas';
|
import {WasmGPUCanvas} from './WasmGPUCanvas';
|
||||||
import {WasmShaderEditor} from './WasmShaderEditor';
|
import {WasmShaderEditor} from './WasmShaderEditor';
|
||||||
|
import {GeometryNodeCanvas} from './ui/GeometryNodeCanvas';
|
||||||
import { ensurePbrAssetLibrary, ensurePbrHighQualityLibrary, PBR_LIBRARY_PROJECT_ID, pbrLibraryWarnings, repairPbrAssetLibrary } from './pbr-library-seed';
|
import { ensurePbrAssetLibrary, ensurePbrHighQualityLibrary, PBR_LIBRARY_PROJECT_ID, pbrLibraryWarnings, repairPbrAssetLibrary } from './pbr-library-seed';
|
||||||
import { createAssetPreviewIdentity } from '../../../protocol/asset-preview';
|
import { createAssetPreviewIdentity } from '../../../protocol/asset-preview';
|
||||||
import { AssetLibraryPanel } from './AssetLibraryPanel';
|
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"))} />
|
<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>
|
<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}
|
</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"}>
|
{workspace === "Shading" ? <Area className="file-browser-area" editor={localOnly ? "File Browser" : "Asset Library"}>
|
||||||
{localOnly
|
{localOnly
|
||||||
? <WorkspaceEditorSurface editor="File Browser" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />
|
? <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 { 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-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; }
|
.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; }
|
||||||
|
|||||||
85
web/tests/e2e/web-geonodes-canvas.spec.ts
Normal file
85
web/tests/e2e/web-geonodes-canvas.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const STUDIO = path.resolve(HERE, "../../app/public/tutorial/pbr/studio.blend");
|
||||||
|
const GLB = path.resolve(HERE, "../../../re/猴头.glb");
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1600, height: 900 } });
|
||||||
|
|
||||||
|
async function openStudioAndMonkey(page: import("@playwright/test").Page): Promise<void> {
|
||||||
|
await page.goto("/?ui=web");
|
||||||
|
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||||
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
|
||||||
|
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 120_000 });
|
||||||
|
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 6/);
|
||||||
|
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
|
||||||
|
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickWorkspace(page: import("@playwright/test").Page, name: string): Promise<void> {
|
||||||
|
await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCanvas(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
|
||||||
|
await clickWorkspace(page, "Geometry Nodes");
|
||||||
|
const canvas = page.locator('[data-testid="geometry-node-canvas"]');
|
||||||
|
await canvas.waitFor({ timeout: 60_000 });
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTransformAndConnect(page: import("@playwright/test").Page, canvas: import("@playwright/test").Locator): Promise<void> {
|
||||||
|
await canvas.getByRole("button", { name: "添加节点", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 30_000 }).toBe("3");
|
||||||
|
const source = page.locator('.react-flow__node[data-id="group-input"] .react-flow__handle.source').first();
|
||||||
|
const target = page.locator('.react-flow__node[data-id="node-geometrynodetransform"] .react-flow__handle.target').first();
|
||||||
|
await expect(source).toHaveCount(1);
|
||||||
|
await expect(target).toHaveCount(1);
|
||||||
|
const from = await source.boundingBox();
|
||||||
|
const to = await target.boundingBox();
|
||||||
|
if (!from || !to) throw new Error("geometry node handles have no box");
|
||||||
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 30_000 }).toBe("2");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Geometry Nodes canvas adds, connects and commits a graph", async ({ page }) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
await openStudioAndMonkey(page);
|
||||||
|
const canvas = await openCanvas(page);
|
||||||
|
const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
||||||
|
await addTransformAndConnect(page, canvas);
|
||||||
|
await canvas.getByRole("button", { name: "提交图", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
||||||
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
||||||
|
const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0");
|
||||||
|
expect(after).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Geometry Nodes canvas graph survives an OPFS save and reopen", async ({ page }) => {
|
||||||
|
test.setTimeout(360_000);
|
||||||
|
await openStudioAndMonkey(page);
|
||||||
|
const canvas = await openCanvas(page);
|
||||||
|
await addTransformAndConnect(page, canvas);
|
||||||
|
await canvas.getByRole("button", { name: "提交图", exact: true }).evaluate((element) => (element as HTMLElement).click());
|
||||||
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||||
|
await expect.poll(async () => canvas.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
||||||
|
await page.locator('button[aria-label="保存项目"]').evaluate((element) => (element as HTMLElement).click());
|
||||||
|
await expect(page.locator("main.blender-app")).toHaveAttribute("data-user-action-save-status", "SUCCEEDED", { timeout: 60_000 });
|
||||||
|
await page.reload();
|
||||||
|
await page.locator("main.blender-app").waitFor({ timeout: 120_000 });
|
||||||
|
await expect.poll(async () => page.locator('[data-testid="recent-projects"] option').count(), { timeout: 60_000 }).toBeGreaterThan(1);
|
||||||
|
await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 });
|
||||||
|
const discard = page.getByRole("button", { name: "不保存,继续", exact: true });
|
||||||
|
if (await discard.count()) await discard.click();
|
||||||
|
await expect.poll(async () => page.getByTestId("scene-stats").innerText(), { timeout: 120_000 }).toMatch(/Objects 7/);
|
||||||
|
const reopened = await openCanvas(page);
|
||||||
|
await expect.poll(async () => page.locator('[data-testid="geometry-node-graph-count"]').innerText(), { timeout: 60_000 }).toBe("1 graphs");
|
||||||
|
await expect.poll(async () => reopened.getAttribute("data-node-count"), { timeout: 60_000 }).toBe("3");
|
||||||
|
await expect.poll(async () => reopened.getAttribute("data-link-count"), { timeout: 60_000 }).toBe("2");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user