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:
@@ -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}
|
||||
|
||||
438
web/app/src/app/ui/SpreadsheetView.tsx
Normal file
438
web/app/src/app/ui/SpreadsheetView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
123
web/tests/e2e/web-spreadsheet-readonly.spec.ts
Normal file
123
web/tests/e2e/web-spreadsheet-readonly.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user