Files
feuerwerk/src/core/commands/graphCommands.ts
T
adminandClaude Opus 5.5 c087993000 Edit a Filter in its node and resize EQ and Filter nodes (#385)
A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:30:00 +02:00

415 lines
15 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 { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { closesLoop } from '../graph/dropTarget';
import type { GraphLookup } from '../graph/lookup';
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
import { nodeDef, portsMeet, takesManyEdges } from '../graph/NodeRegistry';
import { graphLookup } from '../model/graphLookup';
import { newId } from '../ids';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { Project } from '../model/Project';
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireEdge, requireGraph, requireNode, requireNodeDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
export class AddNodeCommand implements Command {
readonly label: string;
readonly node: GraphNode;
/** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */
readonly closing: GraphNode | null;
constructor(
private readonly assetId: string,
type: string,
x: number,
y: number,
params: PropertyBag = {},
) {
// An unknown type is rejected in `execute`: constructors run outside the editor's error
// handling, so throwing here would crash instead of showing a notice.
const def = nodeDef(type);
this.node = {
id: newId(),
type,
x: Math.round(x),
y: Math.round(y),
params: { ...defaultsOf(def?.params ?? []), ...params },
};
const opens = def?.opens;
this.closing = opens
? {
id: newId(),
type: opens.type,
x: this.node.x + opens.dx,
y: this.node.y,
params: defaultsOf(nodeDef(opens.type)?.params ?? []),
}
: null;
this.label = def ? `Add ${def.label}` : `Add ${type}`;
}
execute(project: Project): void {
const def = requireNodeDef(this.node.type);
if (def.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
const graph = requireGraph(project, this.assetId);
// The two terminals give the behavior its signature; a second one of either would make
// "which ports does this behavior have" ambiguous.
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`);
graph.nodes.push(this.node);
if (this.closing) graph.nodes.push(this.closing);
}
undo(project: Project): void {
const graph = requireGraph(project, this.assetId);
graph.nodes = removeById(graph.nodes, this.node.id);
if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.id);
}
}
/** Removes a node and every edge touching it. */
export class RemoveNodeCommand implements Command {
readonly label = 'Remove node';
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
constructor(
private readonly assetId: string,
private readonly nodeId: string,
) {}
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
const def = nodeDef(findNode(graph, this.nodeId)?.type ?? '');
if (def?.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
const touching = edgesOfNode(graph, this.nodeId);
this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) };
}
undo(project: Project): void {
if (!this.removed) return;
const graph = requireGraph(project, this.assetId);
restoreAt(graph.nodes, [this.removed.node]);
restoreAt(graph.edges, this.removed.edges);
}
}
/**
* The previous position is read at execution time, so a caller that previewed the drag must restore
* the node before executing.
*/
export class MoveNodeCommand implements Command {
readonly label = 'Move node';
private previous = { x: 0, y: 0 };
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly x: number,
private readonly y: number,
) {}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
this.previous = { x: node.x, y: node.y };
node.x = Math.round(this.x);
node.y = Math.round(this.y);
}
undo(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
node.x = this.previous.x;
node.y = this.previous.y;
}
}
/**
* Sets a node param or the inline value of an unconnected data input. A param can decide how many
* ports the node has, so this takes the edges of ports the new value does away with: an edge on a
* port that is gone draws as nothing but survives every save, and comes back to life if the port
* ever returns.
*/
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
private orphans: Removed<GraphEdge>[] = [];
constructor(
private readonly assetId: string,
private readonly nodeId: string,
key: string,
private readonly value: PropertyValue,
) {
super(key, `Set ${key}`);
}
override execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
const lookup = graphLookup(project);
// A param can reshape another node too – a Find's mode decides its Match's outputs (#259).
const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup)));
super.execute(project);
this.orphans = removeWhere(
graph.edges,
(e) =>
(held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) &&
!edgeHolds(graph, e, lookup),
);
}
override undo(project: Project): void {
super.undo(project);
restoreAt(requireGraph(project, this.assetId).edges, this.orphans);
}
protected override resolveTarget(project: Project): GraphNode {
return requireNode(project, this.assetId, this.nodeId);
}
protected override bagOf(node: GraphNode): PropertyBag {
return node.params;
}
protected override computeNext(node: GraphNode): PropertyValue {
const param = requireNodeDef(node.type).params.find((p) => p.key === this.key);
// Inline input values are stored as-is; declared params are coerced against their definition.
return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
}
}
/** Whether both ends of an edge name a port their node has; a node of unknown type is not judged. */
function edgeHolds(graph: Graph, edge: GraphEdge, lookup: GraphLookup): boolean {
const has = (end: GraphEdge['from'], side: 'inputs' | 'outputs') => {
const node = findNode(graph, end.node);
const def = node && resolveDef(node, graph, lookup);
return !def || def[side].some((p) => p.key === end.port);
};
return has(edge.from, 'outputs') && has(edge.to, 'inputs');
}
/** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */
export class ConnectCommand implements Command {
readonly label = 'Connect';
readonly edge: GraphEdge;
private replaced: Removed<GraphEdge>[] = [];
constructor(
private readonly assetId: string,
from: { node: string; port: string },
to: { node: string; port: string },
) {
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
}
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
const fromNode = findNode(graph, this.edge.from.node);
const toNode = findNode(graph, this.edge.to.node);
if (!fromNode || !toNode) throw new CommandError('Node not found');
const lookup = graphLookup(project);
const fromDef = resolveDef(fromNode, graph, lookup);
// A transition back into its own state re-enters it; any other edge onto its own node is a loop.
if (fromNode === toNode && fromDef?.transitionEdges !== true)
throw new CommandError('A node cannot connect to itself');
const out = fromDef?.outputs.find((p) => p.key === this.edge.from.port);
const toDef = resolveDef(toNode, graph, lookup);
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
if (closesLoop(graph, this.edge.from.node, this.edge.to.node, lookup))
throw new CommandError('That link would lead round in a loop');
if (graph.edges.some((e) => sameEnds(e, this.edge))) {
// Transitions are told apart by name, so a further one between the same states gets its own.
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
throw new CommandError('Already connected');
this.edge.label = nextTransitionName(graph, this.edge);
}
const occupied = takesManyEdges(inp) ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
graph.edges.push(this.edge);
}
undo(project: Project): void {
const graph = requireGraph(project, this.assetId);
graph.edges = removeById(graph.edges, this.edge.id);
restoreAt(graph.edges, this.replaced);
}
}
/** Folding is drawing only: the edge keeps carrying its value either way. */
export class FoldEdgeCommand implements Command {
readonly label: string;
private previous = false;
constructor(
private readonly assetId: string,
private readonly edgeId: string,
private readonly folded: boolean,
) {
this.label = folded ? 'Hide link' : 'Show link';
}
execute(project: Project): void {
const edge = requireEdge(project, this.assetId, this.edgeId);
this.previous = edge.folded === true;
this.apply(edge, this.folded);
}
undo(project: Project): void {
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
}
private apply(edge: GraphEdge, folded: boolean): void {
if (folded) edge.folded = true;
else delete edge.folded;
}
}
/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */
export class NameTransitionCommand implements Command {
readonly label = 'Name transition';
private previous = '';
constructor(
private readonly assetId: string,
private readonly edgeId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const edge = requireEdge(project, this.assetId, this.edgeId);
const name = this.name.trim();
// Between the same two states the name is all that tells transitions apart.
if (parallelTransitions(requireGraph(project, this.assetId), edge).some((e) => (e.label ?? '') === name))
throw new CommandError(
name
? `Another transition between these states is already called "${name}"`
: 'Another transition between these states is already unnamed',
);
this.previous = edge.label ?? '';
this.apply(edge, name);
}
undo(project: Project): void {
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
}
private apply(edge: GraphEdge, name: string): void {
if (name) edge.label = name;
else delete edge.label;
}
}
/** One undo step for the whole selection; every node is found before any of them changes. */
export class CollapseNodesCommand implements Command {
readonly label: string;
private previous: boolean[] = [];
constructor(
private readonly assetId: string,
private readonly nodeIds: readonly string[],
private readonly collapsed: boolean,
) {
const noun = nodeIds.length === 1 ? 'node' : 'nodes';
this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`;
}
execute(project: Project): void {
const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id));
this.previous = nodes.map((n) => n.collapsed === true);
for (const node of nodes) setCollapsed(node, this.collapsed);
}
undo(project: Project): void {
this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!));
}
}
function setCollapsed(node: GraphNode, collapsed: boolean): void {
if (collapsed) node.collapsed = true;
else delete node.collapsed;
}
/**
* The size a node's face was dragged to (#385). The previous size is read at execution time, so a
* caller that previewed the drag must restore the node before executing.
*/
export class ResizeNodeCommand implements Command {
readonly label = 'Resize node';
private previous: GraphNode['size'];
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly size: { w: number; h: number },
) {}
execute(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
this.previous = node.size && { ...node.size };
node.size = { w: Math.round(this.size.w), h: Math.round(this.size.h) };
}
undo(project: Project): void {
const node = requireNode(project, this.assetId, this.nodeId);
if (this.previous) node.size = { ...this.previous };
else delete node.size;
}
}
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
export class ReplaceGraphCommand implements Command {
private previous?: Pick<Graph, 'nodes' | 'edges'>;
constructor(
private readonly assetId: string,
private readonly graph: Pick<Graph, 'nodes' | 'edges'>,
readonly label: string,
) {}
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
this.previous = { nodes: graph.nodes, edges: graph.edges };
graph.nodes = structuredClone(this.graph.nodes);
graph.edges = structuredClone(this.graph.edges);
}
undo(project: Project): void {
if (!this.previous) return;
const graph = requireGraph(project, this.assetId);
graph.nodes = this.previous.nodes;
graph.edges = this.previous.edges;
}
}
export class DisconnectCommand implements Command {
readonly label = 'Disconnect';
private removed: Removed<GraphEdge>[] = [];
constructor(
private readonly assetId: string,
private readonly edgeId: string,
) {}
execute(project: Project): void {
const graph = requireGraph(project, this.assetId);
this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId);
if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`);
}
undo(project: Project): void {
restoreAt(requireGraph(project, this.assetId).edges, this.removed);
}
}
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
return (
a.from.node === b.from.node &&
a.from.port === b.from.port &&
a.to.node === b.to.node &&
a.to.port === b.to.port
);
}