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 → 最近项目恢复 → 重开后中心像素仍为红色。
|
||||
|
||||
### 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/属性、结果面板;
|
||||
需要一次 WASM 重建(location/properties)。
|
||||
2. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。
|
||||
3. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次
|
||||
1. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。
|
||||
2. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次
|
||||
WASM 工作。
|
||||
4. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。
|
||||
5. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。
|
||||
3. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。
|
||||
4. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。
|
||||
|
||||
## 已知限制 / 未验证
|
||||
|
||||
- 纹理绘制只支持本地 packed 图像;库链接图像(直接分配的 RE 材质贴图)被原生
|
||||
`packImage cannot modify linked library images` 拒绝。课程通过导入 RE 派生的本地 PNG 并设为
|
||||
Base Color 来绘制;后续可加“Make Local”入口。
|
||||
- Compositor 画布、GN 画布、Spreadsheet 尚未实现;课程/视频尚未开始。
|
||||
- GN 画布节点位置目前只在会话内保存;`GeometryNodeIR` 尚无 `location`,原生 writer/reader 未写
|
||||
`locx/locy`,保存重开保留节点/连线但不保留布局。Properties 表单与画布各自持有 draft,未完全共享。
|
||||
- Compositor 画布、Spreadsheet 尚未实现;课程/视频尚未开始。
|
||||
- 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。
|
||||
- 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。
|
||||
- 远端首次 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 {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; }
|
||||
|
||||
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