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:
@@ -1,4 +1,4 @@
|
||||
import type { CompositorGraphIR, CompositorLinkIR, CompositorNodeIR, CompositorNodeType } from "./compositor";
|
||||
import type { CompositorGraphIR, CompositorLinkIR, CompositorNodeIR, CompositorNodeType, CompositorResourceIR } from "./compositor";
|
||||
|
||||
function clone(graph: CompositorGraphIR): CompositorGraphIR {
|
||||
return structuredClone(graph);
|
||||
@@ -62,6 +62,18 @@ export const compositorCommands = Object.freeze({
|
||||
if (node) node.properties = { ...node.properties, [key]: value };
|
||||
return next;
|
||||
},
|
||||
upsertResource(graph: CompositorGraphIR, resource: CompositorResourceIR): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
const index = next.resources.findIndex((candidate) => candidate.id === resource.id);
|
||||
if (index >= 0) next.resources[index] = { ...resource };
|
||||
else next.resources.push({ ...resource });
|
||||
return next;
|
||||
},
|
||||
removeResource(graph: CompositorGraphIR, resourceId: string): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
next.resources = next.resources.filter((resource) => resource.id !== resourceId);
|
||||
return next;
|
||||
},
|
||||
setOutput(graph: CompositorGraphIR, nodeId: string): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
if (next.nodes.find((node) => node.id === nodeId)?.type !== "COMPOSITE") return graph;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface GeometryNodeIR {
|
||||
sockets: GeometryNodeSocketIR[];
|
||||
groupTreeId?: string | null;
|
||||
properties?: Record<string, boolean | number | string | number[]>;
|
||||
/** Canvas position in Blender node-space units. */
|
||||
location?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface GeometryNodeLinkIR {
|
||||
@@ -421,6 +423,13 @@ export function parseGeometryNodeGraph(value: unknown): GeometryNodeGraphIR {
|
||||
if (record(socket)) socketIds.add(socket.id as string);
|
||||
});
|
||||
if (node.groupTreeId !== undefined && node.groupTreeId !== null) boundedText(node.groupTreeId, `nodes[${index}].groupTreeId`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
|
||||
if (node.location !== undefined) {
|
||||
if (!record(node.location) || typeof node.location.x !== "number" || typeof node.location.y !== "number" ||
|
||||
!Number.isFinite(node.location.x) || !Number.isFinite(node.location.y) ||
|
||||
Math.abs(node.location.x) > 1_000_000 || Math.abs(node.location.y) > 1_000_000) {
|
||||
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].location is invalid`, `nodes[${index}].location`);
|
||||
}
|
||||
}
|
||||
if (node.properties !== undefined) {
|
||||
if (!record(node.properties) || Object.keys(node.properties).length > 64) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
|
||||
for (const [name, property] of Object.entries(node.properties)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EditorType =
|
||||
| "Properties"
|
||||
| "Timeline"
|
||||
| "Dope Sheet"
|
||||
| "Graph Editor"
|
||||
| "Image Editor"
|
||||
| "Render Result"
|
||||
| "Shader Editor"
|
||||
|
||||
@@ -51,6 +51,7 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
addMaterialSlot: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
assignMaterialSlot: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
packImage: { family: "IO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
makeImageLocal: { family: "IO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
removeMaterialSlot: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
assignMaterialFaces: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
setMaterialPrincipled: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
|
||||
@@ -162,6 +162,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "setMaterialPrincipled"; materialId: string; baseColor: [number, number, number, number]; roughness: number; metallic: number; emissionColor?: [number, number, number, number]; alpha?: number; ior?: number; specularIORLevel?: number; transmissionWeight?: number; coatWeight?: number; coatRoughness?: number; emissionStrength?: number }
|
||||
| { type: "importImage"; name: string; mimeType: "image/png" | "image/jpeg"; width: number; height: number; base64: string }
|
||||
| { type: "packImage"; imageId: string; mimeType: "image/png" | "image/jpeg"; width: number; height: number; base64: string }
|
||||
| { type: "makeImageLocal"; imageId: string }
|
||||
| { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "METALLIC" | "ROUGHNESS" | "EMISSION" | "ALPHA" | "NORMAL"; uvMap?: string }
|
||||
| { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation }
|
||||
| { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" }
|
||||
|
||||
Reference in New Issue
Block a user