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

@@ -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 秒;课程录制只做本地,页面需提示首载时间。

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

View File

@@ -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 })} />

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

View 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");
});