diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index 3485482a9..962464692 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -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 秒;课程录制只做本地,页面需提示首载时间。 diff --git a/tests/golden/GEONODES-CANVAS-20261113.png b/tests/golden/GEONODES-CANVAS-20261113.png new file mode 100644 index 000000000..00707e899 Binary files /dev/null and b/tests/golden/GEONODES-CANVAS-20261113.png differ diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 60ce94f20..b5a34e25d 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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: 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"))} />
桌面 EEVEE 渲染(可选)nativeAbortRef.current?.abort()} />
: null} - {workspaceNodeEditor ? {workspaceNodeEditor==='Shader Editor'? void packExternalImage(imageId, file)} textureAssets={gpuTextureAssets} activeMaterialId={activeMaterialId} onSavePreview={(material, png) => void saveMaterialPreview(material, png)}/>: fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />} : null} + {workspaceNodeEditor ? {workspaceNodeEditor==='Shader Editor'? void packExternalImage(imageId, file)} textureAssets={gpuTextureAssets} activeMaterialId={activeMaterialId} onSavePreview={(material, png) => void saveMaterialPreview(material, png)}/>:workspaceNodeEditor==='Geometry Nodes'? applyEditCommand(geometryNodeCommands.setGraph(meshId, graph))}/>: fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} />} : null} {workspace === "Shading" ? {localOnly ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> diff --git a/web/app/src/app/ui/GeometryNodeCanvas.tsx b/web/app/src/app/ui/GeometryNodeCanvas.tsx new file mode 100644 index 000000000..92c9ee567 --- /dev/null +++ b/web/app/src/app/ui/GeometryNodeCanvas.tsx @@ -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 { + node: GeometryNodeIR; +} + +type GeometryFlowNode = Node; +type GeometryFlowEdge = Edge>; + +function GeometryNodeCard({ data, selected }: NodeProps) { + const node = data.node; + const inputs = node.sockets.filter((socket) => socket.direction === "INPUT"); + const outputs = node.sockets.filter((socket) => socket.direction === "OUTPUT"); + return ( +
+
{node.name}{node.type}
+
+
+ {inputs.map((socket) => ( +
+ + {socket.name} +
+ ))} +
+
+ {outputs.map((socket) => ( +
+ {socket.name} + +
+ ))} +
+
+
+ ); +} + +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 }) { + 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(null); + const [positions, setPositions] = useState>({}); + const [selectedId, setSelectedId] = useState(""); + const [addType, setAddType] = useState(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 = {}; + 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 => { + 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[]): 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
选择一个网格对象后编辑 Geometry Nodes 图。
; + + return ( +
+
+ Geometry Nodes + + + + + {status} +
+
+ + + + + +
+ +
+ ); +} + +export function GeometryNodeCanvas(props: { snapshot: SceneSnapshotIR | null; onCommit: (meshId: string, graph: GeometryNodeGraphIR) => Promise }) { + return ; +} diff --git a/web/app/src/app/wasm-editors.css b/web/app/src/app/wasm-editors.css index 7f3d17ed7..363174e41 100644 --- a/web/app/src/app/wasm-editors.css +++ b/web/app/src/app/wasm-editors.css @@ -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; } diff --git a/web/tests/e2e/web-geonodes-canvas.spec.ts b/web/tests/e2e/web-geonodes-canvas.spec.ts new file mode 100644 index 000000000..c98939807 --- /dev/null +++ b/web/tests/e2e/web-geonodes-canvas.spec.ts @@ -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 { + 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 { + await page.locator(".workspace-tabs button", { hasText: name }).evaluate((element) => (element as HTMLElement).click()); +} + +async function openCanvas(page: import("@playwright/test").Page): Promise { + 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 { + 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"); +});