diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index 962464692..a2e195352 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -1,7 +1,7 @@ # Web Blender 全站功能补齐与入门课程 — 实施进度 日期:2026-11-13 -状态:**进行中(UV + OPFS 保存检查点完成;四块编辑器其余三块待实现)** +状态:**进行中(UV、Texture Paint、Geometry Nodes、Spreadsheet 完成;Compositor 与课程待实现)** ## 本轮铁律 @@ -77,13 +77,29 @@ - 添加 Transform、从 Group Input 拖拽连接到 Transform,提交后 3 节点/2 连线,graphCount=1; - OPFS 保存 → reload → 最近项目恢复 → 重开后仍是 3 节点/2 连线。 +### 6. Spreadsheet 交互式只读表 + +- 新 `web/app/src/app/ui/SpreadsheetView.tsx`,替换 Geometry Nodes 工作区的静态三列表: + - 数据源:对象、顶点、边、面、UV、属性;顶点/面/UV 读取 `geometryBuffers` 的 + `positions/normals/colors/indices/edgeVertexIndices/triangleFaceIndices/uvs`; + - 文本过滤(任意列)、列显示/隐藏、列左右重排、行点选与 Shift 范围选择、选择本页/清除、 + 分页(50/100/250/500/1000 行)、CSV 显式导出(用户手动下载,符合 OPFS 铁律); + - `data-testid="spreadsheet-view"` 与 `data-source/data-row-count/data-page/data-selected-rows/ + data-visible-columns/data-readonly`;交互全部只读,不产生 Main revision。 +- 边在缺少 `edgeVertexIndices` 时从三角形索引去重推导;面按 `triangleFaceIndices` 聚合。 + 单表最多显示 50,000 行并在脚注注明截断。 +- 验证: + `DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome GPU_MODE=hardware npx playwright test --config playwright.config.ts tests/e2e/web-spreadsheet-readonly.spec.ts --workers=1` + → **2/2 PASS(24.2s)**: + - 对象 7 行 → 顶点表过滤/分页/列隐藏与重排/选择/CSV 下载,revision 不变; + - 属性表含 position,UV/面/边可切换,切换网格后 OPFS 保存 → reload → 最近项目恢复 → 顶点表仍可用。 + ## 待实现(按计划顺序) -1. **Spreadsheet 交互式只读表**:数据源/过滤/行列/列重排/CSV;原生属性值读取需 WASM。 -2. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次 +1. **Compositor 节点画布**:协议+React Flow+执行/Viewer;原生 writer/reader 缺失,需要最大的一次 WASM 工作。 -3. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 -4. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 +2. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 +3. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 ## 已知限制 / 未验证 @@ -92,7 +108,9 @@ Base Color 来绘制;后续可加“Make Local”入口。 - GN 画布节点位置目前只在会话内保存;`GeometryNodeIR` 尚无 `location`,原生 writer/reader 未写 `locx/locy`,保存重开保留节点/连线但不保留布局。Properties 表单与画布各自持有 draft,未完全共享。 -- Compositor 画布、Spreadsheet 尚未实现;课程/视频尚未开始。 +- Spreadsheet 只读当前网格缓冲,不读取 GN 修改器求值后的属性域(桌面 5.2 也无可编辑单元格); + 单表上限 50,000 行,超出部分截断提示。 +- Compositor 画布尚未实现;课程/视频尚未开始。 - 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。 - 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。 - 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。 diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index b5a34e25d..cc6d1b9d4 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -18,6 +18,7 @@ import {NativeSessionPanel} from './NativeSessionPanel'; import {WasmGPUCanvas} from './WasmGPUCanvas'; import {WasmShaderEditor} from './WasmShaderEditor'; import {GeometryNodeCanvas} from './ui/GeometryNodeCanvas'; +import {SpreadsheetView} from './ui/SpreadsheetView'; import { ensurePbrAssetLibrary, ensurePbrHighQualityLibrary, PBR_LIBRARY_PROJECT_ID, pbrLibraryWarnings, repairPbrAssetLibrary } from './pbr-library-seed'; import { createAssetPreviewIdentity } from '../../../protocol/asset-preview'; import { AssetLibraryPanel } from './AssetLibraryPanel'; @@ -8226,7 +8227,7 @@ else: />} : null} {!localOnly&&workspace === "Animation" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} - {localOnly||workspace === "Geometry Nodes" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {localOnly ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : workspace === "Geometry Nodes" ? : null} {scriptingMounted.current ? !projectActionMutexRef.current.owner} onDraftChanged={dirty=>{if(dirty)textDraftAreas.current.add('web-tools-text');else textDraftAreas.current.delete('web-tools-text');textDraftDirty.current=textDraftAreas.current.size>0;}} onNativeRun={localOnly?undefined:(name,source)=>void runNativeWorkflow({kind:'PYTHON',context:nativeContext(),python:{source,textName:name,console:false}})} onPythonRun={localOnly&&pythonSession?(name,source)=>pythonSession.run(source,name,false):undefined} pythonBusy={pythonState?.status==='running'||pythonState?.status==='stopping'}/> {localOnly?(pythonSession?:null):} fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> applyEditCommand(command, undefined, feedback)} onReparent={(ids, parentId, keepTransform) => void applyEditCommand({ type: 'setParents', objectIds: ids, parentId, keepTransform })} onMoveToCollection={(ids, collectionId, keepExistingLinks) => { for (const id of ids) void applyEditCommand({ type: 'moveObjectToCollection', objectId: id, collectionId, keepExistingLinks }); }} onMoveCollection={(collectionId, parentCollectionId) => void applyEditCommand(coreSceneCommands.moveCollection(collectionId, parentCollectionId))} onSelect={selectObject} onRefresh={refreshOutliner} onSelectAll={selectAllOutliner} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> : null} diff --git a/web/app/src/app/ui/SpreadsheetView.tsx b/web/app/src/app/ui/SpreadsheetView.tsx new file mode 100644 index 000000000..4c60ac6be --- /dev/null +++ b/web/app/src/app/ui/SpreadsheetView.tsx @@ -0,0 +1,438 @@ +import { useEffect, useMemo, useState } from "react"; +import type { SceneNodeIR, SceneSnapshotIR } from "../../../../protocol/scene-ir"; +import type { MeshGeometryBuffer } from "../../../../protocol/web-engine"; + +type SourceKind = "objects" | "vertices" | "edges" | "faces" | "uv" | "attributes"; + +const SOURCE_LABELS: Record = { + objects: "对象 Objects", + vertices: "顶点 Vertices", + edges: "边 Edges", + faces: "面 Faces", + uv: "UV", + attributes: "属性 Attributes", +}; + +interface SheetColumn { key: string; label: string } +interface SheetRow { key: string; cells: Array } +interface SheetTable { columns: SheetColumn[]; rows: SheetRow[]; note: string } + +const MAX_ROWS = 50_000; +const PAGE_SIZES = [50, 100, 250, 500, 1000]; +const EMPTY_TABLE: SheetTable = { columns: [], rows: [], note: "" }; + +function round(value: number, digits = 4): number { + if (!Number.isFinite(value)) return value; + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function floatBuffer(buffer: ArrayBuffer): Float32Array | null { + return buffer.byteLength === 0 ? null : new Float32Array(buffer); +} + + +function objectTable(snapshot: SceneSnapshotIR | null): SheetTable { + const nodes = snapshot?.nodes ?? []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const columns: SheetColumn[] = [ + { key: "name", label: "名称" }, + { key: "type", label: "类型" }, + { key: "data", label: "数据" }, + { key: "visible", label: "可见" }, + { key: "selectable", label: "可选" }, + { key: "parent", label: "父级" }, + { key: "x", label: "位置 X" }, + { key: "y", label: "位置 Y" }, + { key: "z", label: "位置 Z" }, + { key: "sx", label: "缩放 X" }, + { key: "sy", label: "缩放 Y" }, + { key: "sz", label: "缩放 Z" }, + ]; + const rows = nodes.map((node: SceneNodeIR) => ({ + key: node.id, + cells: [ + node.name, + node.type, + node.dataId ?? "", + node.visible ? "True" : "False", + node.selectable ? "True" : "False", + node.parentId ? byId.get(node.parentId)?.name ?? node.parentId : "", + round(node.transform.translation[0]), + round(node.transform.translation[1]), + round(node.transform.translation[2]), + round(node.transform.scale[0]), + round(node.transform.scale[1]), + round(node.transform.scale[2]), + ], + })); + return { columns, rows, note: `${rows.length} 个对象 · 只读` }; +} + +function vertexTable(geometry: MeshGeometryBuffer | null): SheetTable { + if (!geometry) return EMPTY_TABLE; + const positions = floatBuffer(geometry.positions); + if (!positions || positions.length < 3) return EMPTY_TABLE; + const normals = geometry.normals ? new Float32Array(geometry.normals) : null; + const colors = geometry.colors ? new Float32Array(geometry.colors) : null; + const count = Math.min(Math.floor(positions.length / 3), MAX_ROWS); + const columns: SheetColumn[] = [ + { key: "index", label: "Index" }, + { key: "x", label: "Position X" }, + { key: "y", label: "Position Y" }, + { key: "z", label: "Position Z" }, + ]; + const hasNormals = !!normals && normals.length >= count * 3; + if (hasNormals) columns.push({ key: "nx", label: "Normal X" }, { key: "ny", label: "Normal Y" }, { key: "nz", label: "Normal Z" }); + const hasColors = !!colors && colors.length >= count * 4; + if (hasColors) columns.push({ key: "r", label: "Color R" }, { key: "g", label: "Color G" }, { key: "b", label: "Color B" }, { key: "a", label: "Color A" }); + const rows: SheetRow[] = []; + for (let index = 0; index < count; index += 1) { + const cells: Array = [index, round(positions[index * 3]), round(positions[index * 3 + 1]), round(positions[index * 3 + 2])]; + if (hasNormals) cells.push(round(normals![index * 3]), round(normals![index * 3 + 1]), round(normals![index * 3 + 2])); + if (hasColors) cells.push(round(colors![index * 4]), round(colors![index * 4 + 1]), round(colors![index * 4 + 2]), round(colors![index * 4 + 3])); + rows.push({ key: `vert:${index}`, cells }); + } + return { columns, rows, note: `${rows.length} 个顶点${Math.floor(positions.length / 3) > MAX_ROWS ? `(显示前 ${MAX_ROWS} 行)` : ""}` }; +} + +function edgeTable(geometry: MeshGeometryBuffer | null): SheetTable { + if (!geometry) return EMPTY_TABLE; + const columns: SheetColumn[] = [{ key: "index", label: "Index" }, { key: "v0", label: "Vertex 1" }, { key: "v1", label: "Vertex 2" }]; + const edges = geometry.edgeVertexIndices ? new Uint32Array(geometry.edgeVertexIndices) : null; + if (edges && edges.length >= 2) { + const count = Math.min(Math.floor(edges.length / 2), MAX_ROWS); + const rows: SheetRow[] = []; + for (let index = 0; index < count; index += 1) rows.push({ key: `edge:${index}`, cells: [index, edges[index * 2], edges[index * 2 + 1]] }); + return { columns, rows, note: `${rows.length} 条边` }; + } + const indices = geometry.indices ? new Uint32Array(geometry.indices) : null; + if (!indices || indices.length < 3) return EMPTY_TABLE; + const seen = new Set(); + const rows: SheetRow[] = []; + for (let corner = 0; corner + 2 < indices.length && rows.length < MAX_ROWS; corner += 3) { + for (let slot = 0; slot < 3; slot += 1) { + const a = indices[corner + slot]; + const b = indices[corner + ((slot + 1) % 3)]; + const low = Math.min(a, b); + const high = Math.max(a, b); + const key = `${low}:${high}`; + if (seen.has(key)) continue; + seen.add(key); + rows.push({ key: `edge:${rows.length}`, cells: [rows.length, low, high] }); + } + } + return { columns, rows, note: `${rows.length} 条边(从三角形索引推导)` }; +} + +function faceTable(geometry: MeshGeometryBuffer | null): SheetTable { + if (!geometry) return EMPTY_TABLE; + const indices = geometry.indices ? new Uint32Array(geometry.indices) : null; + if (!indices || indices.length < 3) return EMPTY_TABLE; + const faceOfTriangle = geometry.triangleFaceIndices ? new Uint32Array(geometry.triangleFaceIndices) : null; + const materialOfTriangle = geometry.triangleMaterialIndices ? new Uint32Array(geometry.triangleMaterialIndices) : null; + const columns: SheetColumn[] = [ + { key: "index", label: "Index" }, + { key: "size", label: "Size" }, + { key: "vertices", label: "Vertices" }, + { key: "material", label: "Material Index" }, + ]; + const triangles = Math.floor(indices.length / 3); + const faces = new Map(); + for (let triangle = 0; triangle < triangles; triangle += 1) { + const faceIndex = faceOfTriangle && triangle < faceOfTriangle.length ? faceOfTriangle[triangle] : triangle; + const entry = faces.get(faceIndex) ?? { vertices: [], material: materialOfTriangle && triangle < materialOfTriangle.length ? materialOfTriangle[triangle] : -1 }; + for (let corner = 0; corner < 3; corner += 1) entry.vertices.push(indices[triangle * 3 + corner]); + faces.set(faceIndex, entry); + if (faces.size >= MAX_ROWS) break; + } + const rows = [...faces.entries()].sort((left, right) => left[0] - right[0]).map(([faceIndex, entry]) => ({ + key: `face:${faceIndex}`, + cells: [faceIndex, entry.vertices.length, entry.vertices.join(", "), entry.material], + })); + return { columns, rows, note: `${rows.length} 个面 · ${triangles} 个三角形${rows.length >= MAX_ROWS ? `(显示前 ${MAX_ROWS} 行)` : ""}` }; +} + +function uvTable(geometry: MeshGeometryBuffer | null): SheetTable { + if (!geometry || !geometry.uvs) return EMPTY_TABLE; + const uvs = new Float32Array(geometry.uvs); + const count = Math.min(Math.floor(uvs.length / 2), MAX_ROWS); + const columns: SheetColumn[] = [{ key: "index", label: "Index" }, { key: "u", label: "U" }, { key: "v", label: "V" }]; + const rows: SheetRow[] = []; + for (let index = 0; index < count; index += 1) rows.push({ key: `uv:${index}`, cells: [index, round(uvs[index * 2], 6), round(uvs[index * 2 + 1], 6)] }); + return { columns, rows, note: `${rows.length} 个 UV 顶点${Math.floor(uvs.length / 2) > MAX_ROWS ? `(显示前 ${MAX_ROWS} 行)` : ""}` }; +} + +function attributeTable(snapshot: SceneSnapshotIR | null, meshId: string, geometry: MeshGeometryBuffer | null): SheetTable { + const summary = snapshot?.meshes.find((mesh) => mesh.id === meshId); + const columns: SheetColumn[] = [ + { key: "name", label: "Name" }, + { key: "domain", label: "Domain" }, + { key: "type", label: "Type" }, + { key: "count", label: "Count" }, + { key: "bytes", label: "Bytes" }, + ]; + const rows: SheetRow[] = []; + const push = (name: string, domain: string, type: string, bytes: number, stride: number): void => { + rows.push({ key: `attr:${name}`, cells: [name, domain, type, bytes % stride === 0 ? bytes / stride : bytes, bytes] }); + }; + if (geometry) { + if (geometry.positions.byteLength) push("position", "POINT", "FLOAT_VECTOR", geometry.positions.byteLength, 12); + if (geometry.normals?.byteLength) push("normal", "POINT", "FLOAT_VECTOR", geometry.normals.byteLength, 12); + if (geometry.uvs?.byteLength) push("UVMap", "CORNER", "FLOAT2", geometry.uvs.byteLength, 8); + if (geometry.colors?.byteLength) push("color", "POINT", "FLOAT_COLOR", geometry.colors.byteLength, 16); + if (geometry.tangents?.byteLength) push("tangent", "CORNER", "FLOAT_VECTOR", geometry.tangents.byteLength, 12); + if (geometry.splitNormals?.byteLength) push("split_normal", "CORNER", "FLOAT_VECTOR", geometry.splitNormals.byteLength, 12); + if (geometry.edgeVertexIndices?.byteLength) push("edge_verts", "EDGE", "INT2", geometry.edgeVertexIndices.byteLength, 8); + if (geometry.triangleCornerIndices?.byteLength) push("corner_verts", "CORNER", "INT", geometry.triangleCornerIndices.byteLength, 4); + if (geometry.triangleFaceIndices?.byteLength) push("corner_faces", "CORNER", "INT", geometry.triangleFaceIndices.byteLength, 4); + if (geometry.triangleMaterialIndices?.byteLength) push("material_index", "FACE", "INT", geometry.triangleMaterialIndices.byteLength, 4); + if (geometry.sculptMask?.byteLength) push("sculpt_mask", "POINT", "FLOAT", geometry.sculptMask.byteLength, 4); + if (geometry.faceSets?.byteLength) push("sculpt_face_set", "FACE", "INT", geometry.faceSets.byteLength, 4); + } + const note = geometry + ? `${rows.length} 个几何属性 · ${summary?.name ?? meshId}` + : "该网格没有几何缓冲数据"; + return { columns, rows, note }; +} + +function matchesFilter(cells: Array, needle: string): boolean { + if (!needle) return true; + return cells.some((cell) => String(cell).toLowerCase().includes(needle)); +} + +function csvCell(value: string | number): string { + const text = String(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function SpreadsheetView({ snapshot, geometryBuffers }: { + snapshot: SceneSnapshotIR | null; + geometryBuffers: MeshGeometryBuffer[]; +}) { + const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId); + const meshes = useMemo(() => (snapshot?.meshes ?? []).filter((mesh) => mesh.vertexCount > 0 || mesh.geometryStatus !== "summary-only"), [snapshot]); + const [source, setSource] = useState("objects"); + const [meshId, setMeshId] = useState(""); + const [filter, setFilter] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(100); + const [selected, setSelected] = useState([]); + const [lastSelected, setLastSelected] = useState(-1); + const [columnOrder, setColumnOrder] = useState([]); + const [hiddenColumns, setHiddenColumns] = useState([]); + const [status, setStatus] = useState("只读数据检查器"); + + useEffect(() => { + const activeMeshId = activeNode?.dataId?.startsWith("mesh:") ? activeNode.dataId : ""; + const next = activeMeshId && meshes.some((mesh) => mesh.id === activeMeshId) ? activeMeshId : meshes[0]?.id ?? ""; + setMeshId((current) => (current === next ? current : next)); + }, [activeNode?.dataId, meshes]); + + const geometry = useMemo(() => { + if (!meshId) return null; + return geometryBuffers.find((buffer) => buffer.meshId === meshId) + ?? geometryBuffers.find((buffer) => buffer.meshId.startsWith(`${meshId}:`)) + ?? null; + }, [geometryBuffers, meshId]); + + const table = useMemo(() => { + switch (source) { + case "vertices": return vertexTable(geometry); + case "edges": return edgeTable(geometry); + case "faces": return faceTable(geometry); + case "uv": return uvTable(geometry); + case "attributes": return attributeTable(snapshot, meshId, geometry); + default: return objectTable(snapshot); + } + }, [source, geometry, snapshot, meshId]); + + const columnKeys = useMemo(() => table.columns.map((column) => column.key), [table]); + const orderedKeys = useMemo(() => { + const ordered = columnOrder.filter((key) => columnKeys.includes(key)); + for (const key of columnKeys) if (!ordered.includes(key)) ordered.push(key); + return ordered; + }, [columnOrder, columnKeys]); + const visibleKeys = useMemo(() => orderedKeys.filter((key) => !hiddenColumns.includes(key)), [orderedKeys, hiddenColumns]); + const visibleColumns = useMemo(() => visibleKeys.map((key) => table.columns.find((column) => column.key === key)!).filter(Boolean), [visibleKeys, table]); + const needle = filter.trim().toLowerCase(); + const filteredRows = useMemo(() => table.rows.filter((row) => matchesFilter(row.cells, needle)), [table.rows, needle]); + const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const pageIndex = Math.min(page, pageCount - 1); + const pageRows = useMemo(() => filteredRows.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize), [filteredRows, pageIndex, pageSize]); + + const changeSource = (next: SourceKind): void => { + setSource(next); + setFilter(""); + setPage(0); + setSelected([]); + setLastSelected(-1); + setColumnOrder([]); + setHiddenColumns([]); + setStatus(`数据源:${SOURCE_LABELS[next]}`); + }; + + const changeMesh = (next: string): void => { + setMeshId(next); + setFilter(""); + setPage(0); + setSelected([]); + setLastSelected(-1); + setColumnOrder([]); + setHiddenColumns([]); + }; + + const toggleRow = (rowId: string, position: number, shift: boolean): void => { + if (shift && lastSelected >= 0) { + const start = Math.min(lastSelected, position); + const end = Math.max(lastSelected, position); + const range = filteredRows.slice(start, end + 1).map((row) => row.key); + setSelected((current) => [...new Set([...current, ...range])]); + } else { + setSelected((current) => (current.includes(rowId) ? current.filter((key) => key !== rowId) : [...current, rowId])); + } + setLastSelected(position); + }; + + const selectPage = (): void => { + setSelected((current) => [...new Set([...current, ...pageRows.map((row) => row.key)])]); + setStatus(`已选择当前页 ${pageRows.length} 行`); + }; + + const moveColumn = (key: string, direction: -1 | 1): void => { + const ordered = [...orderedKeys]; + const index = ordered.indexOf(key); + const target = index + direction; + if (index < 0 || target < 0 || target >= ordered.length) return; + [ordered[index], ordered[target]] = [ordered[target], ordered[index]]; + setColumnOrder(ordered); + }; + + const toggleColumn = (key: string): void => { + setHiddenColumns((current) => { + if (current.includes(key)) return current.filter((item) => item !== key); + if (visibleKeys.length <= 1) return current; + return [...current, key]; + }); + }; + + const exportCsv = (): void => { + const header = visibleColumns.map((column) => column.key).join(","); + const lines = filteredRows.map((row) => visibleColumns.map((column) => { + const index = table.columns.findIndex((candidate) => candidate.key === column.key); + return csvCell(row.cells[index] ?? ""); + }).join(",")); + const blob = new Blob([`${[header, ...lines].join("\n")}\n`], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const meshName = meshes.find((mesh) => mesh.id === meshId)?.name ?? "scene"; + anchor.href = url; + anchor.download = `spreadsheet-${source}${source === "objects" ? "" : `-${meshName}`}.csv`.replace(/[\\/:*?"<>|]/g, "_"); + document.body.append(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + setStatus(`已导出 ${filteredRows.length} 行 CSV`); + }; + + return ( +
column.key).join(",")} + data-readonly="true" + > +
+ ▦ Spreadsheet + 只读 + + {source !== "objects" ? ( + + ) : null} + +
+ 列 {visibleColumns.length}/{table.columns.length} +
+ {orderedKeys.map((key, index) => ( +
+ + + +
+ ))} +
+
+ + + + {selected.length} 已选择 +
+
+ {source !== "objects" && meshes.length === 0 ?
选择网格对象后查看几何数据。
: null} + {source !== "objects" && meshes.length > 0 && !geometry && source !== "attributes" ?
该网格没有几何缓冲数据(可能仅加载了摘要)。
: null} + {filteredRows.length > 0 ? ( + + + {visibleColumns.map((column) => )} + + + {pageRows.map((row, rowIndex) => { + const position = pageIndex * pageSize + rowIndex; + const isSelected = selected.includes(row.key); + return ( + toggleRow(row.key, position, event.shiftKey)} + > + + {visibleColumns.map((column) => { + const index = table.columns.findIndex((candidate) => candidate.key === column.key); + return ; + })} + + ); + })} + +
#{column.label}
{position + 1}{String(row.cells[index] ?? "")}
+ ) :
{needle ? "没有匹配过滤条件的行。" : "没有可显示的行。"}
} +
+
+ {table.note}{needle ? ` · 过滤后 ${filteredRows.length} 行` : ""} + + + 第 {pageIndex + 1} / {pageCount} 页 + + + {filteredRows.length} 行 + + {status} +
+
+ ); +} diff --git a/web/app/src/app/wasm-editors.css b/web/app/src/app/wasm-editors.css index 363174e41..88c54044a 100644 --- a/web/app/src/app/wasm-editors.css +++ b/web/app/src/app/wasm-editors.css @@ -99,3 +99,36 @@ .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; } + +.spreadsheet-view { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #ddd; background: #252525; font-size: 12px; } +.spreadsheet-toolbar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex: none; padding: 4px 6px; border-bottom: 1px solid #444; background: #2e2e2e; } +.spreadsheet-toolbar label { display: flex; align-items: center; gap: 4px; min-width: 0; } +.spreadsheet-toolbar select, .spreadsheet-toolbar input { min-width: 0; max-width: 200px; color: #ddd; background: #1f1f1f; border: 1px solid #505050; border-radius: 2px; padding: 2px 4px; } +.spreadsheet-toolbar button { padding: 3px 7px; color: #ddd; background: #454545; border-radius: 2px; } +.spreadsheet-toolbar button:disabled { opacity: .45; } +.spreadsheet-badge { padding: 1px 5px; color: #b9c7bd; border: 1px solid #556056; border-radius: 8px; font-size: 10px; } +.spreadsheet-filter input { width: 130px; } +.spreadsheet-columns { position: relative; } +.spreadsheet-columns summary { cursor: pointer; padding: 3px 5px; color: #d8c79a; } +.spreadsheet-column-list { position: absolute; z-index: 5; top: 24px; left: 0; width: 230px; max-height: 260px; overflow: auto; padding: 5px; background: #333; border: 1px solid #606060; border-radius: 3px; box-shadow: 0 6px 14px #0009; } +.spreadsheet-column-row { display: flex; align-items: center; gap: 3px; padding: 2px 0; } +.spreadsheet-column-row label { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.spreadsheet-column-row button { padding: 0 4px; background: #484848; color: #ddd; } +.spreadsheet-column-row button:disabled { opacity: .35; } +.spreadsheet-selection { color: #b9d0c0; } +.spreadsheet-table-wrap { flex: 1; min-height: 0; overflow: auto; } +.spreadsheet-table-wrap table { border-collapse: collapse; width: max-content; min-width: 100%; font-variant-numeric: tabular-nums; } +.spreadsheet-table-wrap th, .spreadsheet-table-wrap td { height: 22px; padding: 2px 8px; text-align: left; white-space: nowrap; border-right: 1px solid #3c3c3c; border-bottom: 1px solid #373737; } +.spreadsheet-table-wrap th { position: sticky; top: 0; z-index: 2; background: #3a3a3a; color: #e5e5e5; font-weight: 600; } +.spreadsheet-table-wrap tbody tr { cursor: default; } +.spreadsheet-table-wrap tbody tr:nth-child(even) { background: #2a2a2a; } +.spreadsheet-table-wrap tbody tr:hover { background: #33403a; } +.spreadsheet-table-wrap tbody tr.selected { background: #3f5c4c; } +.spreadsheet-table-wrap .spreadsheet-index { color: #9aa39c; text-align: right; background: #333; position: sticky; left: 0; } +.spreadsheet-table-wrap th.spreadsheet-index { z-index: 3; } +.spreadsheet-empty { padding: 22px; color: #aaa; text-align: center; } +.spreadsheet-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; flex: none; padding: 3px 7px; border-top: 1px solid #444; background: #2b2b2b; color: #b9b9b9; } +.spreadsheet-pager { display: flex; align-items: center; gap: 5px; margin-left: auto; } +.spreadsheet-pager button { padding: 1px 6px; color: #ddd; background: #454545; } +.spreadsheet-pager button:disabled { opacity: .4; } +.spreadsheet-pager select { color: #ddd; background: #1f1f1f; border: 1px solid #505050; } diff --git a/web/tests/e2e/web-spreadsheet-readonly.spec.ts b/web/tests/e2e/web-spreadsheet-readonly.spec.ts new file mode 100644 index 000000000..47c03fb4d --- /dev/null +++ b/web/tests/e2e/web-spreadsheet-readonly.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs/promises"; +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 openSpreadsheet(page: import("@playwright/test").Page): Promise { + await page.locator(".workspace-tabs button", { hasText: "Geometry Nodes" }).evaluate((element) => (element as HTMLElement).click()); + const sheet = page.getByTestId("spreadsheet-view"); + await sheet.waitFor({ timeout: 60_000 }); + return sheet; +} + +test("Spreadsheet browses mesh data, filters columns and exports CSV without editing the Main", async ({ page }) => { + test.setTimeout(300_000); + await openStudioAndMonkey(page); + const sheet = await openSpreadsheet(page); + await expect(sheet).toHaveAttribute("data-source", "objects"); + await expect.poll(async () => sheet.getAttribute("data-row-count"), { timeout: 30_000 }).toBe("7"); + await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBe(7); + + await sheet.getByLabel("数据源").selectOption("vertices"); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(100); + await expect(sheet.getByTestId("spreadsheet-table")).toBeVisible(); + await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBeGreaterThan(0); + + await sheet.getByTestId("spreadsheet-row").first().click(); + await expect(sheet).toHaveAttribute("data-selected-rows", "1"); + await sheet.getByRole("button", { name: "选择本页", exact: true }).click(); + const pageRows = await sheet.getByTestId("spreadsheet-row").count(); + await expect.poll(async () => Number(await sheet.getAttribute("data-selected-rows"))).toBe(pageRows); + + await sheet.getByLabel("过滤行").fill("no-such-value-4d7a"); + await expect.poll(async () => sheet.getAttribute("data-row-count")).toBe("0"); + await expect(sheet.getByText("没有匹配过滤条件的行。")).toBeVisible(); + await sheet.getByLabel("过滤行").fill(""); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count"))).toBeGreaterThan(pageRows); + + await sheet.getByLabel("每页行数").selectOption("50"); + await expect.poll(async () => sheet.getByTestId("spreadsheet-row").count()).toBe(50); + await sheet.getByRole("button", { name: "下一页", exact: true }).click(); + await expect.poll(async () => sheet.getAttribute("data-page")).toBe("1"); + + const beforeColumns = (await sheet.getAttribute("data-visible-columns")) ?? ""; + await sheet.locator("summary", { hasText: "列" }).click(); + await sheet.getByLabel("显示列 index").uncheck(); + await expect.poll(async () => sheet.getAttribute("data-visible-columns")).not.toContain("index"); + const withoutIndex = (await sheet.getAttribute("data-visible-columns")) ?? ""; + expect(withoutIndex.length).toBeLessThan(beforeColumns.length); + await sheet.getByLabel("右移列 x").click(); + const reordered = (await sheet.getAttribute("data-visible-columns")) ?? ""; + expect(reordered).not.toBe(withoutIndex); + expect(reordered.split(",").slice(0, 2)).toEqual(["y", "x"]); + await sheet.getByLabel("显示列 index").check(); + + const downloadPromise = page.waitForEvent("download", { timeout: 30_000 }); + await sheet.getByTestId("spreadsheet-export").click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^spreadsheet-vertices-.*\.csv$/); + const downloaded = await download.path(); + expect(downloaded).toBeTruthy(); + const csv = await fs.readFile(downloaded!, "utf8"); + const lines = csv.trim().split("\n"); + expect(lines.length).toBeGreaterThan(pageRows); + expect(lines[0].split(",")).toContain("x"); + + const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"); + await page.waitForTimeout(400); + const after = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"); + expect(after).toBe(before); +}); + +test("Spreadsheet reports mesh attributes and keeps working after an OPFS reopen", async ({ page }) => { + test.setTimeout(360_000); + await openStudioAndMonkey(page); + const sheet = await openSpreadsheet(page); + await sheet.getByLabel("数据源").selectOption("attributes"); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0); + const attributeNames = await sheet.getByTestId("spreadsheet-row").allInnerTexts(); + expect(attributeNames.join("\n")).toMatch(/position/); + + await sheet.getByLabel("数据源").selectOption("uv"); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0); + await sheet.getByLabel("数据源").selectOption("faces"); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0); + await sheet.getByLabel("数据源").selectOption("edges"); + await expect.poll(async () => Number(await sheet.getAttribute("data-row-count")), { timeout: 30_000 }).toBeGreaterThan(0); + + const meshSelect = sheet.getByLabel("网格"); + const options = await meshSelect.locator("option").count(); + expect(options).toBeGreaterThan(1); + await meshSelect.selectOption({ index: options - 1 }); + await expect.poll(async () => sheet.getAttribute("data-mesh-id")).not.toBe(""); + + 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 openSpreadsheet(page); + await expect.poll(async () => reopened.getAttribute("data-row-count"), { timeout: 60_000 }).toBe("7"); + await reopened.getByLabel("数据源").selectOption("vertices"); + await expect.poll(async () => Number(await reopened.getAttribute("data-row-count")), { timeout: 60_000 }).toBeGreaterThan(100); +});