Arrange graph nodes for the agent and with a button (#429)

An agent that built a graph through MCP left every new node in one
row. In the first real test that put nine nodes on a line 2080 px
long, with the Owner's links running straight through all the others.
Placing nodes by hand cost nine move_node calls and guessed heights.
The agent now gives no coordinates, and the editor lays the nodes out.

- arrange.ts is a pure function. It puts the flow into columns from
  left to right and sets an Owner or Get right before the first node
  it feeds. It orders each column for the fewest crossings and lines
  nodes up by their linked ports without overlaps.
- Node sizes come from the drawn NodeView in the browser and from an
  estimate in unit tests. Both share the constants in nodeBox.ts.
- run_commands arranges the nodes that came without x and y when the
  call ends, inside the same undo step. Nodes that were there stay.
- New command arrange_graph and new tool lint_graph. The lint counts
  overlaps, crossings, links through foreign nodes, backward links
  and the longest links, with example ids.
- The relay instructions tell the agent not to give coordinates and
  to fetch far values with a second Get or Owner node.
- An Arrange button next to the snap magnet in all eight graph tabs.
  It arranges the selected nodes, or all of them, as one undo step.

Found while testing on a real project:

- lint_graph missed a crossing where two links swap rows exactly
  mirrored. Both curves then share a sample point in the middle, and
  only a crossing between two sample points was counted. Links that
  meet on a point count now (segmentsMeet).
- The Arrow-key graph with one Owner for four Moves cannot get below
  8 crossings. The Owner feeds object in row 2 of Move, between exec
  and dt of the key, so it always crosses one of them.

Tests:
- graph-arrange, agent-edit, snap-toggle: 33/33. The swapped-rows
  test was red before the fix (0 instead of 1).
- Four counter-checks that bend arrange or the edit plan, all red.
- e2e 71 on melektaus-atx: 18/18. Lint after the build shows 0
  overlaps, 0 links through nodes, 0 backward. Arrange takes a
  tangled graph from 3 backward links and 2 crossings to 0, and one
  Ctrl+Z restores it.
- Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4917 tests,
  measured before the lint fix.
- Live through MCP on the user's project: lint_graph reports 8
  crossings after Arrange, matching the screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 12:09:52 +02:00
1 parent 25af064249
commit b9d1602b94
25 files changed
+1268 -35

No files matched your search

+9 -1
View File
@@ -1,5 +1,7 @@
import type { Command } from '../../core/commands/Command';
import type { Project } from '../../core/model/Project';
import { lintLayout } from '../graph/arrange';
import { layoutOf, type MeasureNode } from '../graph/graphLayout';
import { type ArgSchema, argProblem } from './argCheck';
import type { AgentView } from './agentView';
import { AgentEdit, deletedAssets, type EditStep } from './editPlan';
@@ -41,6 +43,8 @@ export interface ToolHost {
/** Takes back (or does again) the last edit: its label, null with nothing to take. */
undo(): string | null;
redo(): string | null;
/** How the nodes of a graph are measured: drawn off screen in the editor, worked out without one. */
measure?(assetId: string): MeasureNode;
}
const DEFAULT_SCREENSHOT_WIDTH = 1280;
@@ -105,6 +109,10 @@ async function run(name: string, args: Record<string, unknown>, host: ToolHost):
return answer(getAsset(host.project(), args.asset as string) ?? missing(args.asset));
case 'get_graph':
return answer(getGraph(host.project(), args.asset as string) ?? missing(args.asset, ' with a graph'));
case 'lint_graph': {
const layout = layoutOf(host.project(), args.asset as string, host.measure?.(args.asset as string));
return answer(layout ? lintLayout(layout) : missing(args.asset, ' with a graph'));
}
case 'catalog':
return answer({ [args.topic as string]: catalog(host.project(), args.topic as CatalogTopic, args) });
case 'run_commands':
@@ -148,7 +156,7 @@ async function runCommands(args: Record<string, unknown>, host: ToolHost): Promi
if (refusal !== null) return failure(refusal);
}
const label = `Agent: ${(args.label as string | undefined) ?? steps.map((step) => step.command).join(', ')}`;
const edit = new AgentEdit(label, steps);
const edit = new AgentEdit(label, steps, host.measure && ((assetId) => host.measure!(assetId)));
const refusal = host.execute(edit);
if (refusal !== null) return failure(`Nothing was changed. ${refusal}`);
const made = edit.made.flatMap((ids, index) => (ids ? [{ step: index + 1, ...ids }] : []));
+42 -6
View File
@@ -8,6 +8,7 @@ import {
SetStartSceneCommand,
} from '../../core/commands/assetCommands';
import { type Command, CommandError } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
ConnectCommand,
@@ -35,6 +36,7 @@ import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findGraphAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { VARIABLE_KINDS, type VariableKind } from '../../core/properties/kinds';
import { coerceValue } from '../../core/properties/PropertySchema';
import { arrangeCommand, type MeasureNode } from '../graph/graphLayout';
import { type PaletteEntry, paletteEntries } from '../graph/paletteEntries';
import type { ArgSchema } from './argCheck';
@@ -43,12 +45,16 @@ export interface BuildContext {
project: Project;
/** A free place for a node the agent gave none, right of what the graph and this edit hold. */
spot(assetId: string): { x: number; y: number };
/** How the nodes of a graph are measured for arranging. */
measure(assetId: string): MeasureNode;
}
/** The command, and the ids of what it makes – what later steps of the same edit can name. */
export interface Built {
command: Command;
made?: Record<string, string>;
/** Nodes it added without a place: they are arranged when the edit's steps have run. */
unplaced?: { asset: string; nodes: string[] };
}
type Args = Record<string, unknown>;
@@ -311,7 +317,8 @@ export const COMMANDS: readonly CatalogCommand[] = [
name: 'add_node',
description:
"Adds a node to a graph. `node` is an entry of catalog topic 'nodes' for that graph (its id, e.g. " +
'"OnKeyHeld", "Move", or "RunBehavior:<id>"). Leave x and y out: the editor finds the node a place.',
'"OnKeyHeld", "Move", or "RunBehavior:<id>"). Leave x and y out: when the call has run, the editor ' +
'arranges the nodes added without a place, flow left to right, and leaves the others where they are.',
args: object(
{
asset: GRAPH,
@@ -325,15 +332,44 @@ export const COMMANDS: readonly CatalogCommand[] = [
build: (args, context) => {
const assetId = text(args, 'asset');
const entry = paletteEntry(context.project, assetId, text(args, 'node'));
const spot =
typeof args.x === 'number' && typeof args.y === 'number'
? { x: args.x, y: args.y }
: context.spot(assetId);
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const spot = placed ? { x: args.x as number, y: args.y as number } : context.spot(assetId);
const params = { ...entry.params, ...coercedParams(entry, (args.params ?? {}) as PropertyBag) };
const command = new AddNodeCommand(assetId, entry.def.type, spot.x, spot.y, params);
const made: Record<string, string> = { id: command.node.id };
if (command.closing) made.closing = command.closing.id;
return { command, made };
return placed
? { command, made }
: { command, made, unplaced: { asset: assetId, nodes: Object.values(made) } };
},
},
{
name: 'arrange_graph',
description:
'Arranges nodes of a graph as the Arrange button does: flow left to right, the order that crosses the ' +
'fewest links, no overlaps. Without nodes it arranges the whole graph – moving what the user placed, so ' +
'only do that when asked.',
args: object(
{
asset: GRAPH,
nodes: {
type: 'array',
items: NODE,
minItems: 1,
description: 'The nodes to arrange; the rest stay.',
},
},
['asset'],
),
build: (args, context) => {
const assetId = text(args, 'asset');
const holder = findGraphAsset(context.project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
const nodes = (args.nodes as string[] | undefined) ?? null;
const unknown = nodes?.find((nodeId) => !holder.graph.nodes.some((node) => node.id === nodeId));
if (unknown !== undefined) throw new CommandError(`${holder.name} has no node ${unknown}`);
const command = arrangeCommand(context.project, assetId, nodes, context.measure(assetId));
return { command: command ?? new CompoundCommand('Arrange nodes', []) };
},
},
{
+23 -4
View File
@@ -1,5 +1,7 @@
import { type Command, CommandError } from '../../core/commands/Command';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { arrangeCommand, type MeasureNode } from '../graph/graphLayout';
import { estimatedShape } from '../graph/nodeBox';
import { argProblem } from './argCheck';
import { type BuildContext, type Built, catalogCommand } from './commandCatalog';
@@ -19,8 +21,9 @@ const REF = /^\$([A-Za-z_][\w-]*)(?:\.(\w+))?$/;
/**
* An agent's edit (#429): its steps as one undo step. Each step is built when the one before it has
* run, so a step can work on what an earlier one made – the nodes of a behavior made in the same
* edit. A step that fails takes the done ones back and throws a `CommandError` naming it; a redo
* runs the commands built the first time.
* edit. Nodes added without a place are arranged once all steps have run, those already there
* staying put. A step that fails takes the done ones back and throws a `CommandError` naming it;
* a redo runs the commands built the first time.
*/
export class AgentEdit implements Command {
/** What each step made, by the names of `Built.made`; filled by the first run. */
@@ -30,6 +33,7 @@ export class AgentEdit implements Command {
constructor(
readonly label: string,
private readonly steps: readonly EditStep[],
private readonly measure: (assetId: string) => MeasureNode = () => estimatedShape,
) {}
execute(project: Project): void {
@@ -39,7 +43,12 @@ export class AgentEdit implements Command {
}
const done: Command[] = [];
const names = new Map<string, Record<string, string>>();
const context: BuildContext = { project, spot: (assetId) => freeSpot(project, assetId) };
const unplaced = new Map<string, string[]>();
const context: BuildContext = {
project,
spot: (assetId) => freeSpot(project, assetId),
measure: this.measure,
};
try {
for (const [index, step] of this.steps.entries()) {
const where = `Step ${index + 1} (${step.command})`;
@@ -48,6 +57,10 @@ export class AgentEdit implements Command {
built.command.execute(project);
done.push(built.command);
this.made.push(built.made ?? null);
if (built.unplaced) {
const { asset, nodes } = built.unplaced;
unplaced.set(asset, [...(unplaced.get(asset) ?? []), ...nodes]);
}
if (step.as !== undefined) {
if (!built.made) throw new CommandError(`makes nothing a later step could call "${step.as}"`);
if (names.has(step.as)) throw new CommandError(`"${step.as}" names an earlier step already`);
@@ -58,6 +71,12 @@ export class AgentEdit implements Command {
throw error;
}
}
for (const [assetId, nodes] of unplaced) {
const arranged = arrangeCommand(project, assetId, nodes, this.measure(assetId));
if (!arranged) continue;
arranged.execute(project);
done.push(arranged);
}
} catch (error) {
for (const command of done.reverse()) command.undo(project);
this.made.length = 0;
@@ -123,7 +142,7 @@ function resolve(value: unknown, names: ReadonlyMap<string, Record<string, strin
return value;
}
/** Right of every node the graph has, level with the topmost; the origin in an empty graph. */
/** Where a node waits until the edit arranges it: right of every node, level with the topmost. */
function freeSpot(project: Project, assetId: string): { x: number; y: number } {
const nodes = findGraphAsset(project, assetId)?.graph.nodes ?? [];
if (nodes.length === 0) return { x: 0, y: 0 };
+2
View File
@@ -2,6 +2,7 @@ import type { Application } from 'pixi.js';
import { findAsset, findGraphAsset, findObjectHolder } from '../../core/model/Project';
import { Dialog } from '../../ui';
import { activateAsset, deleteQuestion } from '../actions';
import { measuredNodes } from '../graph/measureNodes';
import { type EditorState, nodeSelection, objectSelection } from '../state';
import type { AgentSelection, PlayAction, ToolHost } from './agentTools';
import type { AgentView } from './agentView';
@@ -55,6 +56,7 @@ export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost {
if (label !== null) state.redo();
return label;
},
measure: (assetId) => measuredNodes(state, assetId),
};
}
+12
View File
@@ -65,6 +65,18 @@
},
"annotations": { "readOnlyHint": true, "openWorldHint": false }
},
{
"name": "lint_graph",
"title": "Check how a graph reads",
"description": "Counts what makes a graph hard to read, as the user sees it: nodes on top of each other (overlaps), links that cross (crossings), links running over a node that is not theirs (throughNodes), links running right to left (backwards), and the longest links, with a few ids of each. Aim for 0 overlaps and 0 throughNodes. To mend: the command arrange_graph, or a second Get or Owner node next to a far-off user instead of one long link.",
"inputSchema": {
"type": "object",
"properties": { "asset": { "type": "string", "description": "Id of the asset holding the graph." } },
"required": ["asset"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": true, "openWorldHint": false }
},
{
"name": "catalog",
"title": "What can be made",
+17
View File
@@ -83,6 +83,8 @@ import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
import type { NodeFace } from './nodeFace';
import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
import { GRAPH_SNAPS_AT_FIRST } from './SnapToggle';
import { arrangeCommand } from './graphLayout';
import { measuredNodes } from './measureNodes';
import { GraphWiring } from './wiring';
/** One running instance's answer for a machine: which state it holds, and whose instance it is. */
@@ -415,6 +417,21 @@ export class GraphCanvas extends Widget {
this.state.select(annotationSelection(this.assetId, [command.annotation.id]));
}
/** *Arrange* (#429): the selected nodes when there are several, else all of them, as one undo step. */
arrange(): void {
const asset = this.asset();
if (!asset) return;
const selected = this.state.selectedNodeIds.filter((id) => this.views.has(id));
const offscreen = measuredNodes(this.state, asset.id);
const command = arrangeCommand(
this.state.project,
asset.id,
selected.length > 1 ? selected : null,
(node, def, graph) => this.views.get(node.id)?.shape() ?? offscreen(node, def, graph),
);
if (command) this.state.execute(command);
}
/** A group drawn around the selected nodes, with room for the title bar above them. */
groupSelectedNodes(): void {
const ids = new Set(this.state.selectedNodeIds);
+1 -1
View File
@@ -11,7 +11,7 @@ import { backTo, descend, follow, type Trail } from './trail';
const INFO_WIDTH = 150;
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
readonly breadcrumb = new Breadcrumb();
private trail: Trail = [];
/** A call followed into another behavior: the tab goes on to that document (#171). */
+14 -5
View File
@@ -33,17 +33,14 @@ import {
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { RESIZE_HANDLE } from './AnnotationView';
import { collapsedNodeWidth } from './folding';
import { NODE_WIDTH, type NodeShape, PAD, ROW_HEIGHT, TITLE_HEIGHT } from './nodeBox';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
export const NODE_WIDTH = 176;
export const TITLE_HEIGHT = 24;
const ROW_HEIGHT = 20;
export { NODE_WIDTH, TITLE_HEIGHT };
const PORT_RADIUS = 5;
/** Distance (graph units) within which a press counts as hitting a port. */
const PORT_HIT_RADIUS = 9;
const PAD = 6;
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
const CHEVRON_SLOT = 14;
const TITLE_X = PAD + CHEVRON_SLOT;
@@ -516,6 +513,18 @@ export class NodeView extends Widget {
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
}
/** The node as drawn, for arranging and measuring a graph (#429). */
shape(): NodeShape {
const ports = (list: readonly PortDef[], isOutput: boolean) =>
Object.fromEntries(list.map((p) => [p.key, this.portPosition(p.key, isOutput)!.y]));
return {
w: this.w,
h: this.h,
inputs: ports(this.def.inputs, false),
outputs: ports(this.def.outputs, true),
};
}
/** A face has its ports in the middle of its sides. */
private rowY(index: number): number {
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
+14 -3
View File
@@ -1,9 +1,10 @@
import { Button, Theme } from '../../ui';
import { HEADER_ICON_SVG } from '../panels/headerIcons';
/** Whatever a tab snaps: the graph canvas, the vector art canvas. */
/** Whatever a tab snaps: the graph canvas, the vector art canvas. A graph arranges its nodes too. */
export interface Snappable {
setSnapping(on: boolean): void;
arrange?(): void;
}
/** A graph, any of them, snaps from the start (#391); a curve and a vector art wait for the magnet. */
@@ -11,14 +12,17 @@ export const GRAPH_SNAPS_AT_FIRST = true;
/**
* The magnet every tab that snaps carries in its header (#194): an icon toggle, lit at first where
* its target starts snapping, as every graph does (#391).
* its target starts snapping, as every graph does (#391). A graph's tab has *Arrange* beside it
* (#429), which is there and greyed out exactly when the magnet is.
*/
export class SnapToggle {
readonly button: Button;
readonly arrangeButton: Button | null;
constructor(
private readonly target: () => Snappable,
private on = false,
arranges = false,
) {
this.button = new Button('', {
width: Theme.controlHeight,
@@ -28,6 +32,12 @@ export class SnapToggle {
onClick: () => this.set(!this.on),
});
this.button.setToggled(on);
this.arrangeButton = arranges
? new Button('Arrange', {
tooltip: 'Arrange the selected nodes, or all of them, left to right',
onClick: () => this.target().arrange?.(),
})
: null;
}
get snapping(): boolean {
@@ -35,7 +45,7 @@ export class SnapToggle {
}
get widgets(): readonly Button[] {
return [this.button];
return this.arrangeButton ? [this.button, this.arrangeButton] : [this.button];
}
set(on: boolean): void {
@@ -46,5 +56,6 @@ export class SnapToggle {
setEnabled(on: boolean): void {
this.button.setEnabled(on);
this.arrangeButton?.setEnabled(on);
}
}
+563
View File
@@ -0,0 +1,563 @@
import type { Box } from '../framing';
import { curvePoint } from './beads';
/**
* Where nodes go when nobody says (#429): the flow runs left to right in columns, a column's nodes
* are put in the order that crosses the fewest links and moved up or down towards what they link
* to, and no two nodes overlap. Nodes that are not to move stay where they are; what is arranged
* keeps its place if it fits there, else it goes right of everything.
*/
/** A node as drawn: its box, and how far down its box each port sits. */
export interface LayoutNode {
id: string;
x: number;
y: number;
w: number;
h: number;
inputs: Readonly<Record<string, number>>;
outputs: Readonly<Record<string, number>>;
}
export interface LayoutEdge {
id: string;
from: { node: string; port: string };
to: { node: string; port: string };
/** Carries the flow: it is pulled straight harder than one that carries a value. */
exec: boolean;
}
export interface LayoutGraph {
nodes: LayoutNode[];
edges: LayoutEdge[];
/** The groups and notes, which nothing arranged may be put over. */
boxes: Box[];
}
export const COLUMN_GAP = 72;
export const ROW_GAP = 24;
/** Between parts of the graph that no link joins. */
const PART_GAP = 48;
/** Between what stays and what is arranged right of it. */
const BESIDE_GAP = 96;
const EXEC_PULL = 10;
const ORDER_SWEEPS = 8;
const SHIFT_SWEEPS = 8;
interface Slot {
id: string;
/** Null for a point a long link passes through on its way over a column. */
node: LayoutNode | null;
h: number;
layer: number;
/** Position in its column while ordering, top first. */
order: number;
y: number;
}
/** A link between neighbouring columns: a long one is cut into these at its waypoints. */
interface Hop {
from: Slot;
to: Slot;
fromY: number;
toY: number;
weight: number;
}
/** The new top-left corners of the movable nodes (all of them, by default). */
export function arrange(
graph: LayoutGraph,
movable?: ReadonlySet<string>,
): Map<string, { x: number; y: number }> {
const moving = graph.nodes.filter((node) => !movable || movable.has(node.id));
const placed = new Map<string, { x: number; y: number }>();
if (moving.length === 0) return placed;
const ids = new Set(moving.map((node) => node.id));
const edges = graph.edges.filter((edge) => ids.has(edge.from.node) && ids.has(edge.to.node));
let top = 0;
for (const part of parts(moving, edges)) {
const partIds = new Set(part.map((node) => node.id));
const own = edges.filter((edge) => partIds.has(edge.from.node));
const spots = arrangePart(part, own);
const height = Math.max(...part.map((node) => spots.get(node.id)!.y + node.h));
for (const [id, spot] of spots) placed.set(id, { x: spot.x, y: spot.y + top });
top += height + PART_GAP;
}
return settle(graph, moving, placed);
}
/** Groups of nodes that links join, in the order their first node has in the graph. */
function parts(nodes: readonly LayoutNode[], edges: readonly LayoutEdge[]): LayoutNode[][] {
const root = new Map(nodes.map((node) => [node.id, node.id]));
const find = (id: string): string => {
let at = id;
while (root.get(at) !== at) at = root.get(at)!;
root.set(id, at);
return at;
};
for (const edge of edges) root.set(find(edge.from.node), find(edge.to.node));
const groups = new Map<string, LayoutNode[]>();
for (const node of nodes) {
const key = find(node.id);
groups.set(key, [...(groups.get(key) ?? []), node]);
}
return [...groups.values()];
}
function arrangePart(
nodes: readonly LayoutNode[],
edges: readonly LayoutEdge[],
): Map<string, { x: number; y: number }> {
const layer = layers(nodes, edges);
const slots = new Map<string, Slot>(
nodes.map((node) => [
node.id,
{ id: node.id, node, h: node.h, layer: layer.get(node.id)!, order: 0, y: 0 },
]),
);
const hops: Hop[] = [];
for (const edge of edges) {
const from = slots.get(edge.from.node)!;
const to = slots.get(edge.to.node)!;
const fromY = from.node!.outputs[edge.from.port] ?? from.h / 2;
const toY = to.node!.inputs[edge.to.port] ?? to.h / 2;
const weight = edge.exec ? EXEC_PULL : 1;
// A link that runs backwards or within one column has no column to follow: it is drawn but not pulled.
if (to.layer <= from.layer) continue;
let last = from;
let lastY = fromY;
for (let l = from.layer + 1; l < to.layer; l++) {
const via: Slot = { id: `${edge.id}@${l}`, node: null, h: 0, layer: l, order: 0, y: 0 };
slots.set(via.id, via);
hops.push({ from: last, to: via, fromY: lastY, toY: 0, weight });
last = via;
lastY = 0;
}
hops.push({ from: last, to, fromY: lastY, toY, weight });
}
const columns = orderColumns([...slots.values()], hops);
placeRows(columns, hops);
const spots = new Map<string, { x: number; y: number }>();
let x = 0;
for (const column of columns) {
const real = column.filter((slot) => slot.node);
for (const slot of real) spots.set(slot.id, { x, y: slot.y });
x += Math.max(0, ...real.map((slot) => slot.node!.w)) + COLUMN_GAP;
}
const minY = Math.min(...[...spots.values()].map((spot) => spot.y));
for (const spot of spots.values()) spot.y -= minY;
return spots;
}
/**
* Each node's column: one right of the furthest node that leads into it. A node only feeding others
* (a Get, the Owner) goes just left of the first one it feeds, not into the first column.
*/
function layers(nodes: readonly LayoutNode[], edges: readonly LayoutEdge[]): Map<string, number> {
const forward = acyclic(nodes, edges);
const into = new Map<string, string[]>(nodes.map((node) => [node.id, []]));
const outOf = new Map<string, string[]>(nodes.map((node) => [node.id, []]));
for (const edge of forward) {
into.get(edge.to.node)!.push(edge.from.node);
outOf.get(edge.from.node)!.push(edge.to.node);
}
const order = topological(nodes, outOf, into);
const layer = new Map<string, number>();
for (const id of order) layer.set(id, Math.max(-1, ...into.get(id)!.map((from) => layer.get(from)!)) + 1);
for (const id of [...order].reverse()) {
const next = outOf.get(id)!;
if (into.get(id)!.length === 0 && next.length > 0)
layer.set(id, Math.min(...next.map((to) => layer.get(to)!)) - 1);
}
const least = Math.min(...layer.values());
for (const [id, l] of layer) layer.set(id, l - least);
return layer;
}
/** The links without the ones that close a loop, found depth first from the nodes in graph order. */
function acyclic(nodes: readonly LayoutNode[], edges: readonly LayoutEdge[]): LayoutEdge[] {
const outOf = new Map<string, LayoutEdge[]>(nodes.map((node) => [node.id, []]));
for (const edge of edges) if (edge.from.node !== edge.to.node) outOf.get(edge.from.node)!.push(edge);
const state = new Map<string, 'open' | 'done'>();
const kept: LayoutEdge[] = [];
const visit = (id: string) => {
state.set(id, 'open');
for (const edge of outOf.get(id)!) {
const seen = state.get(edge.to.node);
if (seen === 'open') continue;
kept.push(edge);
if (seen === undefined) visit(edge.to.node);
}
state.set(id, 'done');
};
// Starting from the nodes nothing leads into puts the loops' back links where they belong.
const heads = nodes.filter(
(node) => !edges.some((edge) => edge.to.node === node.id && edge.from.node !== node.id),
);
for (const node of [...heads, ...nodes]) if (!state.has(node.id)) visit(node.id);
return kept;
}
function topological(
nodes: readonly LayoutNode[],
outOf: ReadonlyMap<string, string[]>,
into: ReadonlyMap<string, string[]>,
): string[] {
const waiting = new Map(nodes.map((node) => [node.id, into.get(node.id)!.length]));
const ready = nodes.filter((node) => waiting.get(node.id) === 0).map((node) => node.id);
const order: string[] = [];
while (ready.length > 0) {
const id = ready.shift()!;
order.push(id);
for (const to of outOf.get(id)!) {
const left = waiting.get(to)! - 1;
waiting.set(to, left);
if (left === 0) ready.push(to);
}
}
return order;
}
/** The columns, each in the order that crossed the fewest links over all the sweeps tried. */
function orderColumns(slots: readonly Slot[], hops: readonly Hop[]): Slot[][] {
const count = Math.max(...slots.map((slot) => slot.layer)) + 1;
const columns: Slot[][] = Array.from({ length: count }, () => []);
// The first order is the one the nodes stand in now, top to bottom.
const startY = (slot: Slot) => slot.node?.y ?? Number.POSITIVE_INFINITY;
for (const slot of slots) columns[slot.layer]!.push(slot);
for (const column of columns) {
column.sort((a, b) => startY(a) - startY(b));
column.forEach((slot, i) => (slot.order = i));
}
const into = new Map<Slot, Hop[]>();
const outOf = new Map<Slot, Hop[]>();
for (const hop of hops) {
into.set(hop.to, [...(into.get(hop.to) ?? []), hop]);
outOf.set(hop.from, [...(outOf.get(hop.from) ?? []), hop]);
}
let best = columns.map((column) => [...column]);
let fewest = crossingsOf(hops);
for (let sweep = 0; sweep < ORDER_SWEEPS && fewest > 0; sweep++) {
const down = sweep % 2 === 0;
const range = down ? columns.keys() : [...columns.keys()].reverse();
for (const l of range) {
const column = columns[l]!;
const centre = new Map<Slot, number>();
for (const slot of column) {
const links = down ? (into.get(slot) ?? []) : (outOf.get(slot) ?? []);
if (links.length === 0) continue;
const at = links.map((hop) => (down ? key(hop.from, hop.fromY) : key(hop.to, hop.toY)));
centre.set(slot, at.reduce((a, b) => a + b, 0) / at.length);
}
// A slot with nothing on that side keeps its place among the others.
const sorted = [...column].sort((a, b) => (centre.get(a) ?? a.order) - (centre.get(b) ?? b.order));
sorted.forEach((slot, i) => (slot.order = i));
columns[l] = sorted;
}
const crossings = crossingsOf(hops);
if (crossings < fewest) {
fewest = crossings;
best = columns.map((column) => [...column]);
}
}
for (const column of best) column.forEach((slot, i) => (slot.order = i));
return best;
}
/** Where along its column a port sits: its slot's place, and a fraction for how far down the slot. */
function key(slot: Slot, portY: number): number {
return slot.order + (slot.h > 0 ? portY / slot.h : 0.5) * 0.9;
}
function crossingsOf(hops: readonly Hop[]): number {
let count = 0;
for (let i = 0; i < hops.length; i++) {
const a = hops[i]!;
for (let j = i + 1; j < hops.length; j++) {
const b = hops[j]!;
if (a.from.layer !== b.from.layer) continue;
const start = key(a.from, a.fromY) - key(b.from, b.fromY);
const end = key(a.to, a.toY) - key(b.to, b.toY);
if (start * end < 0) count += 1;
}
}
return count;
}
/**
* Each slot's height in its column: stacked in order with a gap, then moved towards the ports it
* links to, sweep by sweep, as far as the order and the gaps let it.
*/
function placeRows(columns: readonly Slot[][], hops: readonly Hop[]): void {
for (const column of columns) {
let y = 0;
for (const slot of column) {
slot.y = y;
y += slot.h + ROW_GAP;
}
}
for (let sweep = 0; sweep < SHIFT_SWEEPS; sweep++) {
const down = sweep % 2 === 0;
const both = sweep >= SHIFT_SWEEPS - 2;
const range = down ? [...columns.keys()] : [...columns.keys()].reverse();
for (const l of range) {
const column = columns[l]!;
const wanted = column.map((slot) => {
let sum = 0;
let weight = 0;
for (const hop of hops) {
if (hop.to === slot && (down || both)) {
sum += (hop.from.y + hop.fromY - hop.toY) * hop.weight;
weight += hop.weight;
}
if (hop.from === slot && (!down || both)) {
sum += (hop.to.y + hop.toY - hop.fromY) * hop.weight;
weight += hop.weight;
}
}
return weight > 0 ? { y: sum / weight, weight } : { y: slot.y, weight: 0.01 };
});
const ys = keepApart(
wanted,
column.map((slot) => slot.h),
);
column.forEach((slot, i) => (slot.y = ys[i]!));
}
}
}
/**
* The heights closest to the wanted ones (least weighted squares) that keep the order and a gap
* between neighbours: pool adjacent violators, on heights with the room above each taken off.
*/
function keepApart(wanted: readonly { y: number; weight: number }[], heights: readonly number[]): number[] {
const offset: number[] = [];
let room = 0;
for (const h of heights) {
offset.push(room);
room += h + ROW_GAP;
}
const blocks: { sum: number; weight: number; count: number }[] = [];
wanted.forEach(({ y, weight }, i) => {
blocks.push({ sum: (y - offset[i]!) * weight, weight, count: 1 });
while (blocks.length > 1) {
const last = blocks[blocks.length - 1]!;
const before = blocks[blocks.length - 2]!;
if (before.sum / before.weight <= last.sum / last.weight) break;
blocks.pop();
before.sum += last.sum;
before.weight += last.weight;
before.count += last.count;
}
});
const ys: number[] = [];
for (const block of blocks) {
for (let i = 0; i < block.count; i++) ys.push(block.sum / block.weight + offset[ys.length]!);
}
return ys.map(Math.round);
}
/**
* Moves the arrangement as one to where the nodes were, or right of everything else when it would
* land on a node that stays or on a group or note.
*/
function settle(
graph: LayoutGraph,
moving: readonly LayoutNode[],
placed: Map<string, { x: number; y: number }>,
): Map<string, { x: number; y: number }> {
const ids = new Set(moving.map((node) => node.id));
const staying = graph.nodes.filter((node) => !ids.has(node.id));
const inside = (box: Box, node: LayoutNode) =>
node.x + node.w / 2 > box.x &&
node.x + node.w / 2 < box.x + box.w &&
node.y + node.h / 2 > box.y &&
node.y + node.h / 2 < box.y + box.h;
// A group that held what is arranged would only stand in its way.
const obstacles: Omit<Box, 'id'>[] = [
...staying,
...graph.boxes.filter((box) => !moving.some((node) => inside(box, node))),
];
const at = (dx: number, dy: number) =>
moving.map((node) => {
const spot = placed.get(node.id)!;
return { x: spot.x + dx, y: spot.y + dy, w: node.w, h: node.h };
});
const left = Math.min(...moving.map((node) => node.x));
const top = Math.min(...moving.map((node) => node.y));
const hits = (boxes: readonly Omit<Box, 'id'>[]) =>
boxes.some((box) => obstacles.some((o) => overlap(box, o)));
let shift = { x: left, y: top };
if (hits(at(left, top))) {
const linked = staying.filter((node) =>
graph.edges.some(
(edge) =>
(edge.from.node === node.id && ids.has(edge.to.node)) ||
(edge.to.node === node.id && ids.has(edge.from.node)),
),
);
shift = {
x: Math.max(...obstacles.map((o) => o.x + o.w)) + BESIDE_GAP,
y: Math.min(...(linked.length > 0 ? linked : obstacles).map((o) => o.y)),
};
}
for (const spot of placed.values()) {
spot.x = Math.round(spot.x + shift.x);
spot.y = Math.round(spot.y + shift.y);
}
return placed;
}
function overlap(a: Omit<Box, 'id'>, b: Omit<Box, 'id'>): boolean {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
/** What makes a graph hard to read, counted – for the agent to mend and the checks to hold to. */
export interface GraphLint {
nodes: number;
edges: number;
/** Pairs of nodes on top of each other. */
overlaps: number;
/** Pairs of links that cross; links leaving or reaching one port together do not count. */
crossings: number;
/** Links running over a node that is neither of their ends. */
throughNodes: number;
/** Links that run right to left. */
backwards: number;
longest: { edge: string; length: number }[];
/** A few of each kind, with the ids, so they can be found. */
examples: { kind: 'overlap' | 'crossing' | 'through' | 'backwards'; ids: string[] }[];
}
const CURVE_STEPS = 16;
const MAX_EXAMPLES = 12;
const LONGEST = 3;
/** A link may graze a node's edge; only through its inside counts. */
const THROUGH_INSET = 4;
export function lintLayout(graph: LayoutGraph): GraphLint {
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
const examples: GraphLint['examples'] = [];
const example = (kind: GraphLint['examples'][number]['kind'], ids: string[]) => {
if (examples.filter((e) => e.kind === kind).length < MAX_EXAMPLES / 4) examples.push({ kind, ids });
};
let overlaps = 0;
for (let i = 0; i < graph.nodes.length; i++) {
for (let j = i + 1; j < graph.nodes.length; j++) {
const a = graph.nodes[i]!;
const b = graph.nodes[j]!;
if (!overlap(a, b)) continue;
overlaps += 1;
example('overlap', [a.id, b.id]);
}
}
const curves = graph.edges.flatMap((edge) => {
const from = byId.get(edge.from.node);
const to = byId.get(edge.to.node);
if (!from || !to) return [];
const start = { x: from.x + from.w, y: from.y + (from.outputs[edge.from.port] ?? from.h / 2) };
const end = { x: to.x, y: to.y + (to.inputs[edge.to.port] ?? to.h / 2) };
const points = Array.from({ length: CURVE_STEPS + 1 }, (_, i) =>
curvePoint(start.x, start.y, end.x, end.y, i / CURVE_STEPS),
);
return [{ edge, start, end, points }];
});
let crossings = 0;
for (let i = 0; i < curves.length; i++) {
for (let j = i + 1; j < curves.length; j++) {
const a = curves[i]!;
const b = curves[j]!;
const sharesEnd =
(a.edge.from.node === b.edge.from.node && a.edge.from.port === b.edge.from.port) ||
(a.edge.to.node === b.edge.to.node && a.edge.to.port === b.edge.to.port);
if (sharesEnd || !polylinesCross(a.points, b.points)) continue;
crossings += 1;
example('crossing', [a.edge.id, b.edge.id]);
}
}
let throughNodes = 0;
let backwards = 0;
for (const { edge, start, end, points } of curves) {
if (end.x < start.x) {
backwards += 1;
example('backwards', [edge.id]);
}
for (const node of graph.nodes) {
if (node.id === edge.from.node || node.id === edge.to.node) continue;
const inner = {
x: node.x + THROUGH_INSET,
y: node.y + THROUGH_INSET,
w: node.w - THROUGH_INSET * 2,
h: node.h - THROUGH_INSET * 2,
};
if (!points.some((p, i) => i > 0 && segmentHitsBox(points[i - 1]!, p, inner))) continue;
throughNodes += 1;
example('through', [edge.id, node.id]);
}
}
const longest = curves
.map(({ edge, start, end }) => ({
edge: edge.id,
length: Math.round(Math.hypot(end.x - start.x, end.y - start.y)),
}))
.sort((a, b) => b.length - a.length)
.slice(0, LONGEST);
return {
nodes: graph.nodes.length,
edges: graph.edges.length,
overlaps,
crossings,
throughNodes,
backwards,
longest,
examples,
};
}
type P = { x: number; y: number };
/**
* Two links that swap rows in a perfect mirror meet exactly on a point both curves are sampled at
* (t = 1/2), where no segment crosses another properly – so meeting on a point counts as well.
*/
function polylinesCross(a: readonly P[], b: readonly P[]): boolean {
for (let i = 1; i < a.length; i++) {
for (let j = 1; j < b.length; j++) {
if (segmentsMeet(a[i - 1]!, a[i]!, b[j - 1]!, b[j]!)) return true;
}
}
return false;
}
function side(a: P, b: P, c: P): number {
return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
}
function segmentsMeet(a: P, b: P, c: P, d: P): boolean {
if (segmentsCross(a, b, c, d)) return true;
const on = (p: P, q: P, r: P) =>
side(p, q, r) === 0 &&
r.x >= Math.min(p.x, q.x) &&
r.x <= Math.max(p.x, q.x) &&
r.y >= Math.min(p.y, q.y) &&
r.y <= Math.max(p.y, q.y);
return on(a, b, c) || on(a, b, d) || on(c, d, a) || on(c, d, b);
}
/** Proper crossings only: lines that just touch, at a shared end say, do not cross. */
function segmentsCross(a: P, b: P, c: P, d: P): boolean {
return side(a, b, c) * side(a, b, d) < 0 && side(c, d, a) * side(c, d, b) < 0;
}
function segmentHitsBox(a: P, b: P, box: Omit<Box, 'id'>): boolean {
if (box.w <= 0 || box.h <= 0) return false;
const inside = (p: P) => p.x > box.x && p.x < box.x + box.w && p.y > box.y && p.y < box.y + box.h;
if (inside(a) || inside(b)) return true;
const corners = [
{ x: box.x, y: box.y },
{ x: box.x + box.w, y: box.y },
{ x: box.x + box.w, y: box.y + box.h },
{ x: box.x, y: box.y + box.h },
];
return corners.some((corner, i) => segmentsCross(a, b, corner, corners[(i + 1) % 4]!));
}
+63
View File
@@ -0,0 +1,63 @@
import { oneStep } from '../../core/commands/CompoundCommand';
import type { Command } from '../../core/commands/Command';
import { MoveNodeCommand } from '../../core/commands/graphCommands';
import { drawnDef, type Graph, type GraphNode, type NodeDef, resolveDef } from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { annotationBoxes } from './annotationDrag';
import { arrange, type LayoutGraph } from './arrange';
import { estimatedShape, type NodeShape } from './nodeBox';
/** How a node's shape is found: worked out, or measured on a drawn node. */
export type MeasureNode = (node: GraphNode, def: NodeDef, graph: Graph) => NodeShape;
/** A graph asset as `arrange` and `lintLayout` read it; null when there is no such graph. */
export function layoutOf(
project: Project,
assetId: string,
measure: MeasureNode = estimatedShape,
): LayoutGraph | null {
const holder = findGraphAsset(project, assetId);
if (!holder) return null;
const { graph } = holder;
const lookup = graphLookup(project);
const defs = new Map<string, NodeDef>();
const nodes = graph.nodes.flatMap((node) => {
const resolved = resolveDef(node, graph, lookup);
if (!resolved) return [];
const def = drawnDef(node, graph, resolved, lookup);
defs.set(node.id, def);
return [{ id: node.id, x: node.x, y: node.y, ...measure(node, def, graph) }];
});
const edges = graph.edges
.filter((edge) => defs.has(edge.from.node) && defs.has(edge.to.node) && !edge.folded)
.map((edge) => ({
id: edge.id,
from: edge.from,
to: edge.to,
exec: defs.get(edge.from.node)!.outputs.find((p) => p.key === edge.from.port)?.type === 'exec',
}));
return { nodes, edges, boxes: annotationBoxes(graph) };
}
/**
* The moves that arrange these nodes of a graph (all of them without a list), as one undo step;
* null when none of them has to move.
*/
export function arrangeCommand(
project: Project,
assetId: string,
nodeIds: readonly string[] | null,
measure?: MeasureNode,
): Command | null {
const layout = layoutOf(project, assetId, measure);
if (!layout) return null;
const spots = arrange(layout, nodeIds ? new Set(nodeIds) : undefined);
const moves = layout.nodes.flatMap((node) => {
const spot = spots.get(node.id);
return spot && (spot.x !== node.x || spot.y !== node.y)
? [new MoveNodeCommand(assetId, node.id, spot.x, spot.y)]
: [];
});
return moves.length > 0 ? oneStep(moves.length === 1 ? 'Arrange node' : 'Arrange nodes', moves) : null;
}
+26
View File
@@ -0,0 +1,26 @@
import type { Project } from '../../core/model/Project';
import { nodeFaceFor } from '../soundfx/nodeFaces';
import type { EditorState } from '../state';
import type { MeasureNode } from './graphLayout';
import { NodeView } from './NodeView';
/**
* Measures a node by drawing it once off screen, the way the graph draws it – the agent arranges
* graphs no tab shows (#429). A problem line is left out: finding it takes the whole graph's check.
*/
export function measuredNodes(
state: EditorState,
assetId: string,
project: Project = state.project,
): MeasureNode {
return (node, def, graph) => {
const view = new NodeView(node, def, nodeFaceFor(state, assetId, node));
try {
view.sync(node, graph, project, def);
return view.shape();
} finally {
view.face?.hide();
view.destroy({ children: true });
}
};
}
+44
View File
@@ -0,0 +1,44 @@
import { edgesInto, type Graph, type GraphNode, type NodeDef } from '../../core/graph';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
export const NODE_WIDTH = 176;
export const TITLE_HEIGHT = 24;
export const ROW_HEIGHT = 20;
export const PAD = 6;
/** A node's size and where its ports sit, down from its top. */
export interface NodeShape {
w: number;
h: number;
inputs: Record<string, number>;
outputs: Record<string, number>;
}
/**
* A node's shape as `NodeView` draws it, worked out without drawing: a setting row each, a port row
* each. What it cannot know is a problem line and a sound device's face, so it is a little short
* there – where it matters the editor measures the drawn node instead.
*/
export function estimatedShape(node: GraphNode, def: NodeDef, graph: Graph): NodeShape {
if (node.collapsed) {
return {
w: NODE_WIDTH,
h: TITLE_HEIGHT,
inputs: Object.fromEntries(def.inputs.map((p) => [p.key, TITLE_HEIGHT / 2])),
outputs: Object.fromEntries(def.outputs.map((p) => [p.key, TITLE_HEIGHT / 2])),
};
}
const hidden = new Set([
...(def.hiddenParams?.(node.params) ?? []),
...(def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []),
]);
const header = TITLE_HEIGHT + PAD + def.params.filter((p) => !hidden.has(p.key)).length * ROW_HEIGHT;
const rows = def.transitionEdges === true ? 0 : Math.max(def.inputs.length, def.outputs.length);
const row = (i: number) => header + i * ROW_HEIGHT + ROW_HEIGHT / 2;
return {
w: NODE_WIDTH,
h: header + rows * ROW_HEIGHT + PAD,
inputs: Object.fromEntries(def.inputs.map((p, i) => [p.key, row(i)])),
outputs: Object.fromEntries(def.outputs.map((p, i) => [p.key, row(i)])),
};
}
+1 -1
View File
@@ -51,7 +51,7 @@ const AUDITION_DELAY_MS = 150;
* them at once, a preset is heard on the keys.
*/
export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
/** Public for the check scripts. */
readonly keyboard = new PianoKeyboard();
readonly stopButton: Button;
+1 -1
View File
@@ -25,7 +25,7 @@ const HOLD_WIDTH = 52;
*/
export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> {
readonly preview: ParticlePreview;
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
/** Public for the check scripts: the one control they have to hit rather than read. */
readonly slider = new Slider(0, 0, MIN_SPAN, 120);
private readonly holdButton = new Button('Hold', {
+1 -1
View File
@@ -23,7 +23,7 @@ export class RoutingEditor extends VStack {
constructor(private readonly state: EditorState) {
super();
this.canvas = new GraphCanvas(state, 'routing');
this.snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
this.snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
this.fitButton = new Button('Fit', { onClick: () => this.canvas.fit() });
this.info.minWidth = INFO_WIDTH;
const header = new TabHeader();
+1 -1
View File
@@ -50,7 +50,7 @@ const FUNCTION_SUBJECT = 'A function shows in the shaders calling it';
* frame – so the shader is judged on the image it is meant for; before that, a built-in test image.
*/
export class ShaderEditor extends AssetTabEditor<ShaderTabAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
readonly preview: ShaderPreview;
/** Assigned by `body`; public for the check scripts, which drag it. */
splitter!: Splitter;
+1 -1
View File
@@ -93,7 +93,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
readonly routingButton: Button;
readonly bakeButton: BakeButton;
readonly routing: GraphCanvas;
readonly snap = new SnapToggle(() => this.routing, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.routing, GRAPH_SNAPS_AT_FIRST, true);
private shown: SongView = 'timeline';
private readonly pane = new VStack();
/** Heard through the sound FX (#352), then straight into the master: a preview leaves the routing out (#368). */
+1 -1
View File
@@ -27,7 +27,7 @@ const PREVIEW_OWNER = 'soundfx-preview';
* only there to listen with.
*/
export class SoundFxEditor extends AssetTabEditor<SoundFxAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
/** Public for the check scripts. */
readonly soundPicker: Dropdown;
readonly playButton: Button;
@@ -14,7 +14,7 @@ const INFO_WIDTH = 90;
* and undo are all the graph's.
*/
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST, true);
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
+98 -8
View File
@@ -6,6 +6,8 @@ import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/
import type { AgentView } from '../../src/editor/agent/agentView';
import { COMMANDS } from '../../src/editor/agent/commandCatalog';
import { NODE_STEP } from '../../src/editor/agent/editPlan';
import { COLUMN_GAP, type GraphLint } from '../../src/editor/graph/arrange';
import { NODE_WIDTH } from '../../src/editor/graph/nodeBox';
import TOOLS from '../../src/editor/agent/tools.json';
import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers';
@@ -120,7 +122,7 @@ describe('run_commands', () => {
expect(JSON.stringify(fixture.project)).toBe(after);
});
it('places nodes given no place side by side, right of those the graph has', async () => {
it('arranges the nodes it gave no place right of those the graph has, which stay put', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const { host } = hostFor(fixture);
@@ -133,17 +135,104 @@ describe('run_commands', () => {
'run_commands',
{
steps: [
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' } },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' } },
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
{
command: 'connect',
args: {
asset: behavior.id,
from: { node: '$owner', port: 'object' },
to: { node: '$move', port: 'object' },
},
},
],
},
host,
);
expect(behavior.graph.nodes.map(({ x, y }) => [x, y])).toEqual([
[40, 80],
[40 + NODE_STEP, 80],
[40 + 2 * NODE_STEP, 80],
]);
const [start, owner, move] = behavior.graph.nodes;
expect([start!.x, start!.y]).toEqual([40, 80]);
// The new part keeps the place it waited at, right of the start, and reads left to right.
expect(owner!.x).toBe(40 + NODE_STEP);
expect(move!.x).toBe(owner!.x + NODE_WIDTH + COLUMN_GAP);
expect(fixture.stack.undoLabel).toBe('Agent: add_node, add_node, connect');
const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
expect(lint).toMatchObject({
nodes: 3,
edges: 1,
overlaps: 0,
crossings: 0,
throughNodes: 0,
backwards: 0,
});
});
it('leaves a node given a place where it was put, and arranges the rest around it', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const { host } = hostFor(fixture);
await runTool(
'run_commands',
{
steps: [
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 0, y: 0 }, as: 'start' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
{
command: 'connect',
args: {
asset: behavior.id,
from: { node: '$start', port: 'exec' },
to: { node: '$move', port: 'exec' },
},
},
],
},
host,
);
const [start, move] = behavior.graph.nodes;
expect([start!.x, start!.y]).toEqual([0, 0]);
expect(move!.x).toBeGreaterThanOrEqual(NODE_WIDTH);
});
it('arrange_graph arranges what it is given as one step of the edit, and undo puts it back', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const { host } = hostFor(fixture);
const steps = [
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 500, y: 0 }, as: 'start' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move', x: 0, y: 0 }, as: 'move' },
{
command: 'connect',
args: {
asset: behavior.id,
from: { node: '$start', port: 'exec' },
to: { node: '$move', port: 'exec' },
},
},
];
await runTool('run_commands', { steps }, host);
const tangled = JSON.stringify(behavior.graph.nodes);
const before = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
expect(before.backwards).toBe(1);
const result = await runTool(
'run_commands',
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id } }], label: 'tidy' },
host,
);
expect(result.isError).toBeFalsy();
const [start, move] = behavior.graph.nodes;
expect(move!.x).toBe(start!.x + NODE_WIDTH + COLUMN_GAP);
const after = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
expect(after.backwards).toBe(0);
await runTool('undo', {}, host);
expect(JSON.stringify(behavior.graph.nodes)).toBe(tangled);
const unknown = await runTool(
'run_commands',
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id, nodes: ['nope'] } }] },
host,
);
expect(text(unknown)).toContain('Step 1 (arrange_graph): Jump has no node nope');
});
it('changes nothing when a step fails, and names the step and why', async () => {
@@ -336,6 +425,7 @@ describe('the catalog', () => {
'list_assets',
'get_asset',
'get_graph',
'lint_graph',
'catalog',
'run_commands',
'open',
+275
View File
@@ -0,0 +1,275 @@
/**
* How the editor arranges a graph (#429): columns left to right, no overlaps, few crossings, what
* stays standing where it stood – and the counts the agent reads to see whether a graph reads well.
*/
import { describe, expect, it } from 'vitest';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import {
arrange,
COLUMN_GAP,
type LayoutEdge,
type LayoutGraph,
type LayoutNode,
lintLayout,
} from '../../src/editor/graph/arrange';
import { layoutOf } from '../../src/editor/graph/graphLayout';
import { makeBehavior, makeProject } from '../helpers';
/** A node of `rows` port rows, `in`/`out` port keys from the top, 20 units a row under a 30-unit header. */
function node(id: string, inputs: string[], outputs: string[], x = 0, y = 0): LayoutNode {
const rows = (keys: string[]) => Object.fromEntries(keys.map((key, i) => [key, 30 + i * 20 + 10]));
return {
id,
x,
y,
w: 176,
h: 36 + Math.max(inputs.length, outputs.length) * 20,
inputs: rows(inputs),
outputs: rows(outputs),
};
}
let edgeCount = 0;
function edge(
from: string,
fromPort: string,
to: string,
toPort: string,
exec = fromPort === 'exec',
): LayoutEdge {
edgeCount += 1;
return { id: `e${edgeCount}`, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, exec };
}
function placed(graph: LayoutGraph, movable?: ReadonlySet<string>): LayoutGraph {
const spots = arrange(graph, movable);
return { ...graph, nodes: graph.nodes.map((n) => ({ ...n, ...(spots.get(n.id) ?? {}) })) };
}
/**
* What the agent built for "walk with the arrow keys": four keys, four moves, in one row as the
* agent left them – with one owner for all of them, or one owner next to each move.
*/
function arrowKeys(owners: 'one' | 'each' = 'one'): LayoutGraph {
const keys = ['left', 'right', 'up', 'down'];
const owner = (k: string) => (owners === 'one' ? 'owner' : `owner-${k}`);
return {
nodes: [
...keys.map((k, i) => node(`key-${k}`, [], ['exec', 'dt'], i * 260)),
...keys.map((k, i) => node(`move-${k}`, ['exec', 'object', 'dx', 'dy', 'dt'], ['exec'], (i + 4) * 260)),
...[...new Set(keys.map(owner))].map((id, i) => node(id, [], ['object'], (i + 8) * 260)),
],
edges: keys.flatMap((k) => [
edge(`key-${k}`, 'exec', `move-${k}`, 'exec'),
edge(`key-${k}`, 'dt', `move-${k}`, 'dt'),
edge(owner(k), 'object', `move-${k}`, 'object'),
]),
boxes: [],
};
}
describe('arrange', () => {
it('lines a chain up left to right, the flow running straight', () => {
const graph = placed({
nodes: [
node('c', ['exec'], ['exec'], 0, 300),
node('a', [], ['exec'], 500, 0),
node('b', ['exec'], ['exec'], 90, 90),
],
edges: [edge('a', 'exec', 'b', 'exec'), edge('b', 'exec', 'c', 'exec')],
boxes: [],
});
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
expect(at.b!.x).toBe(at.a!.x + 176 + COLUMN_GAP);
expect(at.c!.x).toBe(at.b!.x + 176 + COLUMN_GAP);
// The exec ports sit level: a = 0 rows of inputs, b and c one, so the boxes line up as well.
expect(at.a!.y + at.a!.outputs.exec!).toBe(at.b!.y + at.b!.inputs.exec!);
expect(at.b!.y + at.b!.outputs.exec!).toBe(at.c!.y + at.c!.inputs.exec!);
// The whole keeps the top-left corner the nodes had.
expect(Math.min(...graph.nodes.map((n) => n.x))).toBe(0);
expect(Math.min(...graph.nodes.map((n) => n.y))).toBe(0);
});
it('turns the agent’s row of nine into a graph without overlaps, links over nodes or backward links', () => {
const row = arrowKeys();
const before = lintLayout(row);
const after = lintLayout(placed(row));
expect(before.throughNodes).toBeGreaterThan(10);
expect(after).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0 });
// The owner's link crosses one link of each move's own key, and the links of the key in between
// on its way to the top and the bottom move: 4 + 2 + 2.
expect(after.crossings).toBeLessThanOrEqual(8);
expect(after.longest[0]!.length).toBeLessThan(before.longest[0]!.length / 3);
});
it('crosses less and keeps the links short when each move has an owner of its own', () => {
const lint = lintLayout(placed(arrowKeys('each')));
expect(lint).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0 });
// The owner feeds the second row of a move, between the key's flow and its dt: one crossing each.
expect(lint.crossings).toBe(4);
expect(lint.longest[0]!.length).toBeLessThan(150);
});
it('runs the flow straight from a key into its move', () => {
const graph = placed(arrowKeys('each'));
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
for (const k of ['left', 'right', 'up', 'down']) {
const key = at[`key-${k}`]!;
const move = at[`move-${k}`]!;
expect(Math.abs(key.y + key.outputs.exec! - (move.y + move.inputs.exec!))).toBeLessThanOrEqual(12);
}
});
it('puts the owner just left of the moves it feeds, among the keys, not on top of the column', () => {
const graph = placed(arrowKeys());
const owner = graph.nodes.find((n) => n.id === 'owner')!;
const keys = graph.nodes.filter((n) => n.id.startsWith('key-'));
const moves = graph.nodes.filter((n) => n.id.startsWith('move-'));
expect(owner.x).toBe(keys[0]!.x);
expect(moves.every((m) => m.x > owner.x)).toBe(true);
const above = keys.filter((k) => k.y < owner.y).length;
expect(above).toBeGreaterThan(0);
expect(above).toBeLessThan(4);
});
it('leaves the nodes that are not to move, and keeps clear of them and of groups', () => {
const graph: LayoutGraph = {
nodes: [
node('kept', [], ['exec'], 0, 0),
node('new-a', ['exec'], ['exec'], 0, 10),
node('new-b', ['exec'], [], 30, 20),
],
edges: [edge('kept', 'exec', 'new-a', 'exec'), edge('new-a', 'exec', 'new-b', 'exec')],
boxes: [{ id: 'group', x: 200, y: -40, w: 300, h: 200 }],
};
const result = placed(graph, new Set(['new-a', 'new-b']));
const kept = result.nodes.find((n) => n.id === 'kept')!;
expect([kept.x, kept.y]).toEqual([0, 0]);
expect(lintLayout(result).overlaps).toBe(0);
const box = graph.boxes[0]!;
for (const n of result.nodes.filter((n) => n.id !== 'kept')) {
const clear = n.x >= box.x + box.w || n.x + n.w <= box.x || n.y >= box.y + box.h || n.y + n.h <= box.y;
expect(clear).toBe(true);
}
// Right of everything, level with the node they hang on.
expect(Math.min(...result.nodes.filter((n) => n.id !== 'kept').map((n) => n.x))).toBeGreaterThan(
box.x + box.w,
);
expect(Math.min(...result.nodes.filter((n) => n.id !== 'kept').map((n) => n.y))).toBe(0);
});
it('arranges nodes inside the group that holds them where they are, rather than outside it', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], ['exec'], 220, 0), node('b', ['exec'], [], 30, 20)],
edges: [edge('a', 'exec', 'b', 'exec')],
boxes: [{ id: 'group', x: 0, y: -40, w: 700, h: 300 }],
};
const result = placed(graph);
expect(Math.min(...result.nodes.map((n) => n.x))).toBe(30);
});
it('survives a loop and a link to itself, and stacks parts no link joins', () => {
const graph = placed({
nodes: [
node('a', ['exec'], ['exec']),
node('b', ['exec'], ['exec']),
node('c', ['exec'], ['exec']),
node('lone', [], ['exec']),
node('other', ['exec'], []),
],
edges: [
edge('a', 'exec', 'b', 'exec'),
edge('b', 'exec', 'c', 'exec'),
edge('c', 'exec', 'a', 'exec'),
edge('b', 'exec', 'b', 'exec'),
edge('lone', 'exec', 'other', 'exec'),
],
boxes: [],
});
expect(lintLayout(graph).overlaps).toBe(0);
const xs = new Set(['a', 'b', 'c'].map((id) => graph.nodes.find((n) => n.id === id)!.x));
expect(xs.size).toBe(3);
});
it('gives the same answer every time', () => {
expect([...arrange(arrowKeys())]).toEqual([...arrange(arrowKeys())]);
});
});
describe('lintLayout', () => {
it('counts overlaps, crossings, links over nodes and links running backwards', () => {
const graph: LayoutGraph = {
nodes: [
node('a', [], ['exec', 'v'], 0, 0),
node('b', ['exec', 'v'], [], 400, 0),
node('mid', [], [], 200, 30),
node('on-mid', [], [], 210, 40),
node('back', ['exec'], [], -400, 200),
],
edges: [
edge('a', 'exec', 'b', 'v'),
edge('a', 'v', 'b', 'exec', false),
edge('a', 'exec', 'back', 'exec'),
],
boxes: [],
};
const lint = lintLayout(graph);
expect(lint.overlaps).toBe(1);
// The two links from a to b swap rows, and the one to `back` loops across the lower of them;
// it shares its start port with the upper one, which does not count.
expect(lint.crossings).toBe(2);
expect(lint.throughNodes).toBeGreaterThanOrEqual(2);
expect(lint.backwards).toBe(1);
expect(lint.examples.find((e) => e.kind === 'overlap')?.ids).toEqual(['mid', 'on-mid']);
});
it('counts two links that swap rows in a perfect mirror, meeting on a point of both curves', () => {
// A key and an owner side by side feeding one move, as the agent's arrange left them live:
// dt runs down from the key's second row, the owner's object up into the move's second row.
const graph: LayoutGraph = {
nodes: [
node('key', [], ['exec', 'dt'], 0, 0),
node('owner', [], ['object'], 0, 80),
node('move', ['exec', 'object', 'dx', 'dy', 'dt'], [], 248, 0),
],
edges: [
edge('key', 'exec', 'move', 'exec'),
edge('key', 'dt', 'move', 'dt'),
edge('owner', 'object', 'move', 'object'),
],
boxes: [],
};
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
// The mirror: each link starts where the other ends, row for row.
expect(at.key!.outputs.dt).toBe(at.move!.inputs.object);
expect(at.owner!.y + at.owner!.outputs.object!).toBe(at.move!.inputs.dt);
expect(lintLayout(graph).crossings).toBe(1);
});
});
describe('layoutOf', () => {
it('reads a behavior’s nodes with their port rows, and which links carry the flow', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture);
const key = new AddNodeCommand(behavior.id, 'OnKeyHeld', 0, 0, {});
const move = new AddNodeCommand(behavior.id, 'Move', 300, 0, {});
fixture.stack.execute(key);
fixture.stack.execute(move);
fixture.stack.execute(
new ConnectCommand(
behavior.id,
{ node: key.node.id, port: 'exec' },
{ node: move.node.id, port: 'exec' },
),
);
fixture.stack.execute(
new ConnectCommand(behavior.id, { node: key.node.id, port: 'dt' }, { node: move.node.id, port: 'dt' }),
);
const layout = layoutOf(fixture.project, behavior.id)!;
expect(layout.edges.map((e) => e.exec)).toEqual([true, false]);
const [keyNode, moveNode] = layout.nodes;
expect(keyNode!.outputs.dt! - keyNode!.outputs.exec!).toBe(20);
expect(moveNode!.h).toBeGreaterThan(keyNode!.h);
expect(layoutOf(fixture.project, 'nope')).toBeNull();
});
});
+13
View File
@@ -54,6 +54,19 @@ describe('SnapToggle', () => {
expect(canvas.calls).toEqual([false]);
});
it('has Arrange beside it in a graph’s tab, greyed out with it, arranging the target of the click (#429)', () => {
let arranged = 0;
const canvas = { ...target(), arrange: () => (arranged += 1) };
const snap = new SnapToggle(() => canvas, GRAPH_SNAPS_AT_FIRST, true);
expect(snap.widgets).toEqual([snap.button, snap.arrangeButton]);
expect(snap.arrangeButton!.text).toBe('Arrange');
snap.arrangeButton!.onClick?.();
expect(arranged).toBe(1);
snap.setEnabled(false);
expect(snap.arrangeButton!.enabled).toBe(false);
expect(new SnapToggle(() => canvas).arrangeButton).toBeNull();
});
it('asks for the target at the click, so a canvas mounted later is the one switched', () => {
let canvas = target();
const snap = new SnapToggle(() => canvas);
+42
View File
@@ -8,6 +8,7 @@ const TOOLS = [
'list_assets',
'get_asset',
'get_graph',
'lint_graph',
'catalog',
'run_commands',
'open',
@@ -260,6 +261,47 @@ export async function mcp(t) {
JSON.stringify({ built: built?.content?.[0]?.text?.slice(0, 300), graph: graph?.edges?.length }),
);
// 71g2. The nodes the agent gave no place come out arranged, as the drawn nodes measure.
const lint = walkId ? (await callTool('lint_graph', { asset: walkId }))?.structuredContent : null;
check(
'the agent’s nodes are arranged: no overlaps, no link over a node, none backwards',
lint?.nodes === 3 && lint.overlaps === 0 && lint.throughNodes === 0 && lint.backwards === 0,
JSON.stringify(lint),
);
// 71g3. Arrange in the graph's header puts a tangle right again, as one step of the user's own.
await callTool('open', { asset: walkId });
const [keyNode, moveNode] = graph.nodes;
await callTool('run_commands', {
label: 'tangle',
steps: [
{
command: 'move_node',
args: { asset: walkId, node: moveNode.id, x: keyNode.x - 400, y: keyNode.y + 300 },
},
],
});
await settle();
const tangled = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
await clickText('Arrange');
await settle();
const tidied = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
const arrangedLabel = await json('__gm.state.commands.undoLabel');
await press('z', { ctrl: true });
await settle();
const backTo = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
await callTool('undo');
check(
'Arrange in the header turns the links forward again in one undo step, and Ctrl+Z brings the tangle back',
tangled?.backwards > 0 &&
tidied?.backwards === 0 &&
tidied.overlaps === 0 &&
arrangedLabel === 'Arrange nodes' &&
backTo?.backwards === tangled.backwards &&
(await json('__gm.state.commands.undoLabel')) === 'Agent: walk left',
JSON.stringify({ tangled, tidied, arrangedLabel, backTo }),
);
// 71h. Played by the agent, the object walks left while the user holds the arrow.
const started = await callTool('play', { action: 'play' });
await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });
+3
View File
@@ -38,6 +38,9 @@ const INSTRUCTIONS = [
'are commands from catalog topic "commands", run as one undo step, and a step can name what an',
'earlier one made ("as": "x", then "$x"). Nodes for a graph come from catalog topic "nodes".',
'A behavior acts on the object it is placed under: place it with parent set to that object.',
'Give new nodes no x and y: the editor arranges them when the call ends, and leaves the nodes',
'already there where they are. Keep a graph readable: a value needed far away comes from a second',
'Get or Owner node next to its user, not over a long link; check with lint_graph.',
'When a tool answers that no editor is connected, ask the user to open the editor and switch on',
'"Agent" in its account menu.',
].join(' ');