Files
feuerwerk/src/core/commands/portCommands.ts
T
root 14f542365e Build a Pack or Expand node's ports from a struct variable
The port editor gets 'From variable' next to '+ Field': pick a struct variable
of the behavior and its fields land as ports. It only adds - a field the node
already declares stays as it is, because it may carry an edge - and the entry
is disabled once there is nothing left to add. A list of structs counts too:
it declares the shape of every item, which is exactly the bundle a Pack builds
for it. A color field arrives as a number port; the graph has no color type.

AddPortsCommand is new underneath: adding five ports had to be one undo step,
and it checks every key before it appends a single one, so a collision does
not leave half the list behind. AddPortCommand is a three-line subclass of it,
which leaves its sixteen call sites untouched.
2026-09-10 21:35:30 +02:00

279 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
declaresVariablePorts,
type DeclaredPort,
findNode,
type Graph,
type GraphEdge,
type GraphNode,
isTerminalNode,
isValidPortName,
requireNodeDef,
RUN_BEHAVIOR,
} from '../graph';
import type { PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireBehavior } from './require';
interface PortSite {
graph: Graph;
nodeId: string;
/** Whether the port is an input there – the terminal and the call node face opposite ways. */
asInput: boolean;
}
/**
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
* itself. A behavior's terminal is the exception: its ports are also ports of every node calling
* the behavior – facing the other way, because what a start node hands out the caller passes in.
*/
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
const asset = requireBehavior(project, assetId);
const asInput = requireNodeDef(node.type).declaresPorts === 'inputs';
const sites: PortSite[] = [{ graph: asset.graph, nodeId: node.id, asInput }];
if (!isTerminalNode(node)) return sites;
for (const other of project.assets) {
if (other.type !== 'behavior') continue;
for (const caller of other.graph.nodes) {
if (caller.type !== RUN_BEHAVIOR || caller.params[BEHAVIOR_PARAM] !== assetId) continue;
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START });
}
}
return sites;
}
function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean {
return (edge) =>
site.asInput
? edge.to.node === site.nodeId && edge.to.port === key
: edge.from.node === site.nodeId && edge.from.port === key;
}
/** The node whose declaration is being edited; a terminal is addressed by id like any other. */
function requirePortNode(project: Project, assetId: string, nodeId: string): GraphNode {
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
if (!node) throw new CommandError(`Node ${nodeId} not found`);
return node;
}
function nodeAt(site: PortSite): GraphNode | undefined {
return site.graph.nodes.find((n) => n.id === site.nodeId);
}
function renameParam(site: PortSite, from: string, to: string): void {
const node = nodeAt(site);
const value = node?.params[from];
if (!node || value === undefined) return;
node.params[to] = value;
delete node.params[from];
}
// A struct field may be called `exec`, a port may not – so this message has to name that reason.
const INVALID_NAME =
'Port names must start with a letter, contain only letters, digits or _, and "exec" is taken by the flow port';
/** Several ports as one step, so seeding a node from a struct is one undo rather than one per field. */
export class AddPortsCommand implements Command {
readonly label: string;
protected readonly ports: readonly DeclaredPort[];
constructor(
private readonly assetId: string,
private readonly nodeId: string,
ports: readonly DeclaredPort[],
) {
this.ports = ports.map((p) => ({ ...p }));
this.label = this.ports.length === 1 ? `Add ${this.ports[0]?.key}` : `Add ${this.ports.length} ports`;
}
execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const keys = new Set((node.ports ?? []).map((p) => p.key));
for (const port of this.ports) {
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
keys.add(port.key);
}
node.ports = [...(node.ports ?? []), ...this.ports.map((p) => ({ ...p }))];
}
undo(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const added = new Set(this.ports.map((p) => p.key));
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
}
}
export class AddPortCommand extends AddPortsCommand {
constructor(assetId: string, nodeId: string, port: DeclaredPort) {
super(assetId, nodeId, [port]);
}
}
/** Takes the edges hanging on the port with it – in this graph and in every calling one. */
export class RemovePortCommand implements Command {
readonly label: string;
private removed?: Removed<DeclaredPort>;
private edges: { graph: Graph; entries: Removed<GraphEdge>[] }[] = [];
private values: { site: PortSite; value: PropertyValue }[] = [];
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly key: string,
) {
this.label = `Remove ${key}`;
}
execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node);
const ports = [...(node.ports ?? [])];
const removed = removeFirst(ports, (p) => p.key === this.key);
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
this.removed = removed;
node.ports = ports;
this.edges = sites.map((site) => ({
graph: site.graph,
entries: removeWhere(site.graph.edges, edgesOnPort(site, this.key)),
}));
this.values = [];
for (const site of sites) {
const target = site.asInput ? nodeAt(site) : undefined;
const value = target?.params[this.key];
if (!target || value === undefined) continue;
this.values.push({ site, value });
delete target.params[this.key];
}
}
undo(project: Project): void {
if (!this.removed) return;
const node = requirePortNode(project, this.assetId, this.nodeId);
const ports = [...(node.ports ?? [])];
restoreAt(ports, [this.removed]);
node.ports = ports;
for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries);
for (const { site, value } of this.values) {
const target = nodeAt(site);
if (target) target.params[this.key] = value;
}
}
}
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
export class RenamePortCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly to: string,
) {
this.label = `Rename ${from}`;
}
execute(project: Project): void {
this.rename(project, this.from, this.to);
}
undo(project: Project): void {
this.rename(project, this.to, this.from);
}
private rename(project: Project, from: string, to: string): void {
if (from === to) return;
const node = requirePortNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node);
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
const ports = node.ports ?? [];
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const site of sites) {
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
// An input keeps its typed-in value under the port key, so a rename has to carry it along
// or the value silently falls back to the default.
if (site.asInput) renameParam(site, from, to);
}
}
}
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
const ports = node.ports ?? [];
if (from === to || !ports.some((p) => p.key === from)) return;
const site: PortSite = {
graph,
nodeId: node.id,
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
};
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
if (site.asInput) renameParam(site, from, to);
}
/**
* Points one declared port at another variable. Key and kind change together, because on a
* Get/Set variables node the kind is the variable's rather than a choice of its own; the edges
* hanging on the port come along, so correcting a pick is not a rewiring.
*/
export class SetVariablePortCommand implements Command {
readonly label: string;
private previous?: DeclaredPort;
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly port: DeclaredPort,
) {
this.label = `Point ${from} at ${port.key}`;
}
execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const ports = node.ports ?? [];
const current = ports.find((p) => p.key === this.from);
if (!current) throw new CommandError(`Port "${this.from}" not found`);
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
throw new CommandError(`"${this.port.key}" already exists`);
this.previous = { ...current };
this.swap(project, node, this.from, this.port);
}
undo(project: Project): void {
if (!this.previous) return;
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
}
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
}
}
/**
* Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
* values move with it – the port key *is* the variable name there.
*/
export function renameVariablePorts(graph: Graph, from: string, to: string): void {
for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
}
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
export function nextPortName(node: GraphNode, base: string): string {
const taken = new Set((node.ports ?? []).map((p) => p.key));
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
}