diff --git a/src/editor/agent/agentTools.ts b/src/editor/agent/agentTools.ts index 617dec19..b6eff1ef 100644 --- a/src/editor/agent/agentTools.ts +++ b/src/editor/agent/agentTools.ts @@ -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, 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, 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 }] : [])); diff --git a/src/editor/agent/commandCatalog.ts b/src/editor/agent/commandCatalog.ts index affb47d6..d65f5d9b 100644 --- a/src/editor/agent/commandCatalog.ts +++ b/src/editor/agent/commandCatalog.ts @@ -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; + /** Nodes it added without a place: they are arranged when the edit's steps have run. */ + unplaced?: { asset: string; nodes: string[] }; } type Args = Record; @@ -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:"). Leave x and y out: the editor finds the node a place.', + '"OnKeyHeld", "Move", or "RunBehavior:"). 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 = { 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', []) }; }, }, { diff --git a/src/editor/agent/editPlan.ts b/src/editor/agent/editPlan.ts index 737a903b..d67b6ea9 100644 --- a/src/editor/agent/editPlan.ts +++ b/src/editor/agent/editPlan.ts @@ -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>(); - const context: BuildContext = { project, spot: (assetId) => freeSpot(project, assetId) }; + const unplaced = new Map(); + 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 void): ToolHost { if (label !== null) state.redo(); return label; }, + measure: (assetId) => measuredNodes(state, assetId), }; } diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index 57f3e98f..5b3efb9b 100644 --- a/src/editor/agent/tools.json +++ b/src/editor/agent/tools.json @@ -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", diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 2990fca7..bf3b41e4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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); diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 24d622cc..c1de7354 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -11,7 +11,7 @@ import { backTo, descend, follow, type Trail } from './trail'; const INFO_WIDTH = 150; export class GraphEditor extends AssetTabEditor { - 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). */ diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index a061ae94..11e2672a 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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; diff --git a/src/editor/graph/SnapToggle.ts b/src/editor/graph/SnapToggle.ts index 73667ebf..96511c08 100644 --- a/src/editor/graph/SnapToggle.ts +++ b/src/editor/graph/SnapToggle.ts @@ -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); } } diff --git a/src/editor/graph/arrange.ts b/src/editor/graph/arrange.ts new file mode 100644 index 00000000..53e77c9c --- /dev/null +++ b/src/editor/graph/arrange.ts @@ -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>; + outputs: Readonly>; +} + +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, +): Map { + const moving = graph.nodes.filter((node) => !movable || movable.has(node.id)); + const placed = new Map(); + 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(); + 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 { + const layer = layers(nodes, edges); + const slots = new Map( + 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(); + 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 { + const forward = acyclic(nodes, edges); + const into = new Map(nodes.map((node) => [node.id, []])); + const outOf = new Map(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(); + 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(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(); + 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, + into: ReadonlyMap, +): 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(); + const outOf = new Map(); + 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(); + 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, +): Map { + 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[] = [ + ...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[]) => + 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, b: Omit): 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): 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]!)); +} diff --git a/src/editor/graph/graphLayout.ts b/src/editor/graph/graphLayout.ts new file mode 100644 index 00000000..5b861f01 --- /dev/null +++ b/src/editor/graph/graphLayout.ts @@ -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(); + 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; +} diff --git a/src/editor/graph/measureNodes.ts b/src/editor/graph/measureNodes.ts new file mode 100644 index 00000000..8f8d1e08 --- /dev/null +++ b/src/editor/graph/measureNodes.ts @@ -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 }); + } + }; +} diff --git a/src/editor/graph/nodeBox.ts b/src/editor/graph/nodeBox.ts new file mode 100644 index 00000000..6813cb2b --- /dev/null +++ b/src/editor/graph/nodeBox.ts @@ -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; + outputs: Record; +} + +/** + * 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)])), + }; +} diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index fa8ab6dd..e67e9063 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -51,7 +51,7 @@ const AUDITION_DELAY_MS = 150; * them at once, a preset is heard on the keys. */ export class InstrumentEditor extends AssetTabEditor { - 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; diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index d41c0010..619f1e18 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -25,7 +25,7 @@ const HOLD_WIDTH = 52; */ export class ParticlesEditor extends AssetTabEditor { 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', { diff --git a/src/editor/routing/RoutingEditor.ts b/src/editor/routing/RoutingEditor.ts index cd235c34..65a50932 100644 --- a/src/editor/routing/RoutingEditor.ts +++ b/src/editor/routing/RoutingEditor.ts @@ -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(); diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index f2b84671..78e178bc 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -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 { - 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; diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 4c3f7ba1..cecef9a2 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -93,7 +93,7 @@ export class SongEditor extends AssetTabEditor { 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). */ diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index 674c8816..c21cb71a 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -27,7 +27,7 @@ const PREVIEW_OWNER = 'soundfx-preview'; * only there to listen with. */ export class SoundFxEditor extends AssetTabEditor { - 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; diff --git a/src/editor/statemachine/StateMachineEditor.ts b/src/editor/statemachine/StateMachineEditor.ts index 612e30a2..d24e9d67 100644 --- a/src/editor/statemachine/StateMachineEditor.ts +++ b/src/editor/statemachine/StateMachineEditor.ts @@ -14,7 +14,7 @@ const INFO_WIDTH = 90; * and undo are all the graph's. */ export class StateMachineEditor extends AssetTabEditor { - 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 }); diff --git a/tests/editor/agent-edit.test.ts b/tests/editor/agent-edit.test.ts index 3b41ccda..34827b09 100644 --- a/tests/editor/agent-edit.test.ts +++ b/tests/editor/agent-edit.test.ts @@ -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', diff --git a/tests/editor/graph-arrange.test.ts b/tests/editor/graph-arrange.test.ts new file mode 100644 index 00000000..acfd3e51 --- /dev/null +++ b/tests/editor/graph-arrange.test.ts @@ -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): 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(); + }); +}); diff --git a/tests/editor/snap-toggle.test.ts b/tests/editor/snap-toggle.test.ts index 99328016..68492e8c 100644 --- a/tests/editor/snap-toggle.test.ts +++ b/tests/editor/snap-toggle.test.ts @@ -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); diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index dc2eb501..9e97a36e 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -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' }); diff --git a/tools/mcp/relay.mjs b/tools/mcp/relay.mjs index bfadfed7..6b3bdf31 100644 --- a/tools/mcp/relay.mjs +++ b/tools/mcp/relay.mjs @@ -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(' ');