feat(web): add an interactive read-only Spreadsheet view

Geometry Nodes workspace now exposes real mesh buffers (objects,
vertices, edges, faces, UVs, attributes) with filtering, column
show/hide/reorder, row selection, pagination and explicit CSV export.
Interactions never touch the Main revision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 05:13:33 +08:00
parent aa2c469db4
commit 4efb4b778d
5 changed files with 620 additions and 7 deletions

View File

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

View File

@@ -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:
/>}
</Area> : null}
{!localOnly&&workspace === "Animation" ? <Area className="camera-view-area" editor="3D Viewport"><WorkspaceEditorSurface editor="3D Viewport" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{localOnly||workspace === "Geometry Nodes" ? <Area className="spreadsheet-area" editor="Spreadsheet"><WorkspaceEditorSurface editor="Spreadsheet" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{localOnly ? <Area className="spreadsheet-area" editor="Spreadsheet"><WorkspaceEditorSurface editor="Spreadsheet" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : workspace === "Geometry Nodes" ? <Area className="spreadsheet-area" editor="Spreadsheet"><SpreadsheetView snapshot={snapshot} geometryBuffers={geometryBuffers} /></Area> : null}
{scriptingMounted.current ? <ScriptingContainer {...(localOnly?{}:{style:{display:scriptingVisible?"contents":"none"}})}><Area className="text-editor-area" editor="Text Editor">
<WasmTextEditor key={`${projectIdRef.current}:${textEditorEpoch}`} snapshot={snapshot} onCommand={applyEditCommand} projectBusy={projectActionLocked} canEditDraft={()=>!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'}/>
</Area><Area className="console-area" editor="Python Console">{localOnly?(pythonSession?<WasmPythonConsole session={pythonSession} enabled={Boolean(snapshot)&&!headerPending}/>:null):<NativeSessionPanel key={projectIdRef.current} client={webClientRef.current} context={nativeContext()} onApplied={applyNativeScene}/>}</Area><Area className="info-area" editor="Info"><WorkspaceEditorSurface editor="Info" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="secondary-outliner-area" editor="Outliner"><Outliner key={`${projectIdRef.current}:${userActionSequenceRef.current}`} snapshot={snapshot} onToggleExpand={toggleOutlinerExpand} onTreeSelect={selectOutlinerRow} onRemap={(command, feedback) => 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 })} /></Area></ScriptingContainer> : null}

View File

@@ -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<SourceKind, string> = {
objects: "对象 Objects",
vertices: "顶点 Vertices",
edges: "边 Edges",
faces: "面 Faces",
uv: "UV",
attributes: "属性 Attributes",
};
interface SheetColumn { key: string; label: string }
interface SheetRow { key: string; cells: Array<string | number> }
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<string | number> = [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<string>();
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<number, { vertices: number[]; material: number }>();
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<string | number>, 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<SourceKind>("objects");
const [meshId, setMeshId] = useState("");
const [filter, setFilter] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(100);
const [selected, setSelected] = useState<string[]>([]);
const [lastSelected, setLastSelected] = useState(-1);
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
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 (
<section
className="spreadsheet-view"
data-testid="spreadsheet-view"
data-source={source}
data-mesh-id={meshId}
data-total-rows={filteredRows.length}
data-row-count={filteredRows.length}
data-page={pageIndex}
data-page-count={pageCount}
data-selected-rows={selected.length}
data-visible-columns={visibleColumns.map((column) => column.key).join(",")}
data-readonly="true"
>
<header className="spreadsheet-toolbar">
<strong>▦ Spreadsheet</strong>
<span className="spreadsheet-badge">只读</span>
<label>数据源
<select aria-label="数据源" value={source} onChange={(event) => changeSource(event.target.value as SourceKind)}>
{(Object.keys(SOURCE_LABELS) as SourceKind[]).map((kind) => <option key={kind} value={kind}>{SOURCE_LABELS[kind]}</option>)}
</select>
</label>
{source !== "objects" ? (
<label>网格
<select aria-label="网格" value={meshId} onChange={(event) => changeMesh(event.target.value)}>
{meshes.length === 0 ? <option value="">无网格</option> : null}
{meshes.map((mesh) => <option key={mesh.id} value={mesh.id}>{mesh.name} ({mesh.vertexCount} v / {mesh.faceCount} f)</option>)}
</select>
</label>
) : null}
<label className="spreadsheet-filter">过滤
<input aria-label="过滤行" type="search" value={filter} onChange={(event) => { setFilter(event.target.value); setPage(0); }} placeholder="过滤任意列…" />
</label>
<details className="spreadsheet-columns">
<summary>列 {visibleColumns.length}/{table.columns.length}</summary>
<div className="spreadsheet-column-list">
{orderedKeys.map((key, index) => (
<div key={key} className="spreadsheet-column-row">
<label><input type="checkbox" aria-label={`显示列 ${key}`} checked={!hiddenColumns.includes(key)} onChange={() => toggleColumn(key)} />{table.columns.find((column) => column.key === key)?.label ?? key}</label>
<button type="button" aria-label={`左移列 ${key}`} disabled={index === 0} onClick={() => moveColumn(key, -1)}>◀</button>
<button type="button" aria-label={`右移列 ${key}`} disabled={index === orderedKeys.length - 1} onClick={() => moveColumn(key, 1)}>▶</button>
</div>
))}
</div>
</details>
<button type="button" onClick={selectPage} disabled={pageRows.length === 0}>选择本页</button>
<button type="button" onClick={() => { setSelected([]); setLastSelected(-1); }} disabled={selected.length === 0}>清除选择</button>
<button type="button" data-testid="spreadsheet-export" onClick={exportCsv} disabled={filteredRows.length === 0}>导出 CSV</button>
<output className="spreadsheet-selection">{selected.length} 已选择</output>
</header>
<div className="spreadsheet-table-wrap">
{source !== "objects" && meshes.length === 0 ? <div className="spreadsheet-empty">选择网格对象后查看几何数据。</div> : null}
{source !== "objects" && meshes.length > 0 && !geometry && source !== "attributes" ? <div className="spreadsheet-empty">该网格没有几何缓冲数据(可能仅加载了摘要)。</div> : null}
{filteredRows.length > 0 ? (
<table data-testid="spreadsheet-table">
<thead>
<tr><th className="spreadsheet-index">#</th>{visibleColumns.map((column) => <th key={column.key}>{column.label}</th>)}</tr>
</thead>
<tbody>
{pageRows.map((row, rowIndex) => {
const position = pageIndex * pageSize + rowIndex;
const isSelected = selected.includes(row.key);
return (
<tr
key={row.key}
data-testid="spreadsheet-row"
data-row-key={row.key}
aria-selected={isSelected}
className={isSelected ? "selected" : undefined}
onClick={(event) => toggleRow(row.key, position, event.shiftKey)}
>
<td className="spreadsheet-index">{position + 1}</td>
{visibleColumns.map((column) => {
const index = table.columns.findIndex((candidate) => candidate.key === column.key);
return <td key={column.key}>{String(row.cells[index] ?? "")}</td>;
})}
</tr>
);
})}
</tbody>
</table>
) : <div className="spreadsheet-empty">{needle ? "没有匹配过滤条件的行。" : "没有可显示的行。"}</div>}
</div>
<footer className="spreadsheet-footer">
<output>{table.note}{needle ? ` · 过滤后 ${filteredRows.length} 行` : ""}</output>
<span className="spreadsheet-pager">
<button type="button" aria-label="上一页" disabled={pageIndex <= 0} onClick={() => setPage(Math.max(0, pageIndex - 1))}>◀</button>
<output>第 {pageIndex + 1} / {pageCount} 页</output>
<button type="button" aria-label="下一页" disabled={pageIndex >= pageCount - 1} onClick={() => setPage(Math.min(pageCount - 1, pageIndex + 1))}>▶</button>
<label>每页
<select aria-label="每页行数" value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(0); }}>
{PAGE_SIZES.map((size) => <option key={size} value={size}>{size}</option>)}
</select>
</label>
<span>{filteredRows.length} 行</span>
</span>
<output role="status">{status}</output>
</footer>
</section>
);
}

View File

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

View File

@@ -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<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 openSpreadsheet(page: import("@playwright/test").Page): Promise<import("@playwright/test").Locator> {
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);
});