feat(web): close remaining editor, layout and persistence gaps

- Area headers get a per-area editor switcher backed by a shared
  AreaEditorContext; Preferences can hide it.
- Animation camera area is a real navigable viewport aligned to the
  scene camera; Graph Editor adds channel selection, keyframe drag/
  insert/delete and interpolation.
- Geometry Nodes node locations round-trip through the native writer
  and reader, so saved graphs keep their layout.
- Texture Paint can make linked library images local via a new native
  makeImageLocal command before packing strokes.
- Compositor IMAGE and Render Layers nodes now persist to Main and
  execute against packed images / the last PBR render.
- Spreadsheet row cap raised to 250k; Outliner gains Ctrl+A and arrow/
  Home/End navigation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-13 07:47:10 +08:00
parent d11387ef6b
commit d0b6d2a164
31 changed files with 877 additions and 61 deletions

View File

@@ -29,20 +29,26 @@ function uniqueId(base: string, ids: ReadonlySet<string>): string {
/** Workbench constructors for validated Geometry Node graph transactions. */
export const geometryNodeCommands = Object.freeze({
createGraph(meshId: string, name = "Web Geometry") : GeometryNodeGraphIR {
const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("GeometryInput", "Geometry", "OUTPUT", "GEOMETRY")] };
const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("GeometryOutput", "Geometry", "INPUT", "GEOMETRY")] };
const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("GeometryInput", "Geometry", "OUTPUT", "GEOMETRY")], location: { x: 20, y: 80 } };
const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("GeometryOutput", "Geometry", "INPUT", "GEOMETRY")], location: { x: 620, y: 80 } };
return { schemaVersion: GEOMETRY_NODE_GRAPH_SCHEMA, id: `geometry-graph:${meshId}`, name, interfaceInputs: [socket("GeometryInput", "Geometry", "INPUT", "GEOMETRY")], interfaceOutputs: [socket("GeometryOutput", "Geometry", "OUTPUT", "GEOMETRY")], nodes: [input, output], links: [{ fromNodeId: input.id, fromSocketId: "GeometryInput", toNodeId: output.id, toSocketId: "GeometryOutput" }] };
},
addNode(graph: GeometryNodeGraphIR, type: string, name = type): GeometryNodeGraphIR {
addNode(graph: GeometryNodeGraphIR, type: string, name = type, location?: { x: number; y: number }): GeometryNodeGraphIR {
const next = clone(graph);
const ids = new Set(next.nodes.map((node) => node.id));
const id = uniqueId(`node-${type.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`, ids);
const sockets = nodeSockets(type).map((item, index, all) => all.some((other, otherIndex) => otherIndex !== index && other.id === item.id)
? { ...item, id: `${item.direction.toLowerCase()}:${item.id}` } : item);
const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets };
const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets, location };
next.nodes.push(node);
return next;
},
moveNode(graph: GeometryNodeGraphIR, nodeId: string, location: { x: number; y: number }): GeometryNodeGraphIR {
const next = clone(graph);
const node = next.nodes.find((candidate) => candidate.id === nodeId);
if (node) node.location = { x: location.x, y: location.y };
return next;
},
removeNode(graph: GeometryNodeGraphIR, nodeId: string): GeometryNodeGraphIR {
const next = clone(graph);
next.nodes = next.nodes.filter((node) => node.id !== nodeId);