Let the agent copy nodes, also into another graph (#434)

The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 20:39:37 +02:00
1 parent e4b0419a53
commit fb981b5b0f
7 files changed
+386 -45

No files matched your search

+42
View File
@@ -65,6 +65,48 @@ export class AddVariableCommand implements Command {
}
}
/**
* Declares variables of one behavior in another as they are declared there (#434): default, Keep
* and Private included, for copied nodes that name them. A key the target has already is left alone.
*/
export class CarryVariablesCommand implements Command {
readonly label = 'Carry variables';
private added: string[] = [];
constructor(
private readonly assetId: string,
private readonly fromId: string,
private readonly keys: readonly string[],
) {}
execute(project: Project): void {
const from = requireBehavior(project, this.fromId);
const to = requireBehavior(project, this.assetId);
this.added = [];
for (const key of this.keys) {
const def = from.variables.find((v) => v.key === key);
if (!def) throw new CommandError(`${from.name} has no variable "${key}"`);
if (to.variables.some((v) => v.key === key)) continue;
to.variables.push(structuredClone(def));
to.props[key] = structuredClone(from.props[key] ?? def.default);
if (isKeptVariable(from, key)) setKeptVariable(to, key, true);
if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true);
this.added.push(key);
}
}
undo(project: Project): void {
const to = requireBehavior(project, this.assetId);
const added = new Set(this.added);
to.variables = to.variables.filter((v) => !added.has(v.key));
for (const key of added) {
delete to.props[key];
setKeptVariable(to, key, false);
setPrivateVariable(to, key, false);
}
}
}
/** One placed object's own value for a variable, remembered so that undo can put it back. */
interface TakenOverride {
holderId: string;
+99 -2
View File
@@ -19,6 +19,7 @@ import {
ResizeNodeCommand,
SetNodeParamCommand,
} from '../../../core/commands/graphCommands';
import { PasteGraphCommand } from '../../../core/commands/pasteCommands';
import {
AddPortCommand,
MovePortCommand,
@@ -34,25 +35,33 @@ import {
SetStructTypeCommand,
SplitOutputCommand,
} from '../../../core/commands/structTypeCommands';
import { CarryVariablesCommand } from '../../../core/commands/variableCommands';
import {
type AnnotationKind,
type DeclaredPort,
declaresVariablePorts,
findAnnotation,
findNode,
type Graph,
type GraphNode,
nodeDef,
portKindOf,
variablesSourceOf,
} from '../../../core/graph';
import { copyGraph } from '../../../core/model/clipboard';
import type { PropertyBag, PropertyValue } from '../../../core/model/Properties';
import { findGraphAsset, type Project } from '../../../core/model/Project';
import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project';
import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds';
import { coerceValue } from '../../../core/properties/PropertySchema';
import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag';
import { boxesAbout } from '../../graph/annotationFollow';
import { arrangeCommand, layoutOf } from '../../graph/graphLayout';
import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { pickedPortChoices } from '../../graph/portChoices';
import {
type Args,
type BuildContext,
type Built,
type CatalogCommand,
flag,
GRAPH,
@@ -122,6 +131,32 @@ export const GRAPH_COMMANDS: readonly CatalogCommand[] = [
},
shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }),
},
{
name: 'copy_nodes',
description:
'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' +
'and the groups and comments about them alone. made names each copy by the id of its original ' +
'("$copy.<id>"; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' +
'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' +
'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' +
'a copied Variables node reads from its behavior come along into the other behavior.',
args: object(
{
from: id('Id of the asset holding the nodes.'),
nodes: ids('The nodes to copy.'),
to: id('Id of the asset to copy them into; leave out for the same one.'),
x: number('Only to place the copy by hand: its left.'),
y: number('Only to place the copy by hand: its top.'),
},
['from', 'nodes'],
),
build: copyNodes,
shows: (args, made) => ({
kind: 'nodes',
asset: (args.to as string | undefined) ?? text(args, 'from'),
ids: Object.values(made),
}),
},
{
name: 'arrange_graph',
description:
@@ -588,6 +623,68 @@ function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
return coerced;
}
/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */
function copyNodes(args: Args, context: BuildContext): Built {
const { project } = context;
const fromId = text(args, 'from');
const toId = (args.to as string | undefined) ?? fromId;
const nodeIds = listOf(args, 'nodes');
requireNodes(project, fromId, nodeIds);
const source = findGraphAsset(project, fromId)!;
const target = findGraphAsset(project, toId);
if (!target) throw new CommandError(`There is no graph asset ${toId}`);
const layout = layoutOf(project, fromId, context.measure(fromId))!;
const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!;
const { skipped } = fittingPart(project, target, clip);
if (skipped.length > 0) {
const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`);
throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`);
}
const carried = carriedVariables(source, target, clip.nodes);
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId);
const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)];
const shift = {
x: at.x - Math.min(...corners.map((c) => c.x)),
y: at.y - Math.min(...corners.map((c) => c.y)),
};
const paste = new PasteGraphCommand(toId, clip, shift);
const command =
carried.length > 0
? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste])
: paste;
const copies = paste.nodeIds;
const made: Record<string, string> = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! };
clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!));
clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!));
return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } };
}
/**
* The variables of `source` that copied Variables nodes read from it, for a copy into another
* behavior that lacks them; one declared there in another kind is refused.
*/
function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] {
if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return [];
const named = nodes.flatMap((node) =>
declaresVariablePorts(node.type) && !variablesSourceOf(node.params)
? (node.ports ?? []).map((port) => port.key)
: [],
);
const carried: string[] = [];
for (const key of new Set(named)) {
const def = source.variables.find((v) => v.key === key);
const there = target.variables.find((v) => v.key === key);
if (!def) continue;
if (!there) carried.push(key);
else if (there.kind !== def.kind)
throw new CommandError(
`"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`,
);
}
return carried;
}
function requireGraphOf(project: Project, assetId: string): Graph {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
+2 -40
View File
@@ -3,16 +3,7 @@
* copy names assets of this project, which mean nothing anywhere else.
*/
import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands';
import {
BEHAVIOR_PARAM,
findAnnotation,
findNode,
FUNCTION_PARAM,
type GraphNode,
nodeDef,
namesBehavior,
SHADER_CALL,
} from '../core/graph';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance';
@@ -21,12 +12,11 @@ import {
findAssetOf,
findGraphAsset,
findObjectHolder,
type GraphHolder,
type ObjectHolder,
type Project,
} from '../core/model/Project';
import { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries';
import { fittingPart } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state';
import { SCENE_GRID_STEP } from './viewport/sceneViewHost';
@@ -194,34 +184,6 @@ export function pasteObjects(
return true;
}
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean {
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
/**
* Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid
* step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is
+22
View File
@@ -89,6 +89,28 @@ export function followedBoxes(
return done;
}
/**
* The groups and comments that are about these nodes alone (#434), in the graph's order: a group
* holding some of them and no other node, a comment going with such a group or with nodes all among
* them.
*/
export function boxesAbout(graph: LayoutGraph, ids: ReadonlySet<string>): string[] {
const groups = new Set(
graph.boxes
.filter((box) => {
const members = box.kind === 'group' ? graph.nodes.filter((node) => holds(box, node)) : [];
return members.length > 0 && members.every((node) => ids.has(node.id));
})
.map((box) => box.id),
);
const about = (note: LayoutBox) => {
const anchor = note.kind === 'note' ? anchorOf(graph, note) : null;
if (!anchor) return false;
return 'box' in anchor ? groups.has(anchor.box.id) : anchor.nodes.every((node) => ids.has(node.id));
};
return graph.boxes.filter((box) => groups.has(box.id) || about(box)).map((box) => box.id);
}
type Anchor = { box: LayoutBox; below: boolean } | { nodes: LayoutNode[]; below: boolean };
/** What a comment is about, by where it stands now. */
+31
View File
@@ -6,6 +6,7 @@ import {
callsFunction,
createEmptyGraph,
FUNCTION_PARAM,
type GraphNode,
isTerminalType,
namesBehavior,
nextStateName,
@@ -22,6 +23,7 @@ import {
terminalNode,
} from '../../core/graph';
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import type { GraphClip } from '../../core/model/clipboard';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
@@ -188,3 +190,32 @@ export function paletteEntries(project: Project, asset: GraphHolder): PaletteEnt
}
return entries;
}
/** Whether a graph may hold `node`: its palette `entries` offer the node's kind. */
function fitsGraph(entries: readonly PaletteEntry[], node: GraphNode): boolean {
return entries.some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot (#240, #434). */
export function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const entries = paletteEntries(project, asset);
const nodes = clip.nodes.filter((n) => fitsGraph(entries, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}