Files
feuerwerk/src/editor/graph/wiring.ts
T
adminandClaude Opus 5.5 daeffe0f9d
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:18:09 +02:00

276 lines
11 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 type { Graphics } from 'pixi.js';
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { ConnectCommand } from '../../core/commands/graphCommands';
import {
canConnect,
defAcceptsFrom,
dropTargetPort,
firstPortFor,
type Graph,
type PortSide,
type PortDef,
portOf,
STATE_IN,
STATE_OUT,
} from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { Theme, ui } from '../../ui';
import { nodeSelection } from '../state';
import { arrowHead, bezier, loopPath } from './EdgeLayer';
import type { GraphCanvasHost } from './graphHost';
import { NodePalette } from './NodePalette';
import type { PaletteEntry } from './paletteEntries';
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
/**
* Making edges: a drag from a pin, the palette a drag into empty space opens, and a transition
* armed from a state's menu that waits for the click naming its target.
*/
export class GraphWiring {
private connecting: { from: PortSide; pointer: Point } | null = null;
/** A finished drag whose palette is still open – drawn like the running one, so the connection
* being made stays visible while the node type is chosen. */
private pendingDrop: { from: PortSide; pointer: Point } | null = null;
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
* the shape of a drag because it is one in everything but the held button – that way the running
* preview draws it without a second code path. `release` takes the Escape handler back off. */
private armed: { from: PortSide; pointer: Point; release: () => void } | null = null;
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
private dropTarget: PortSide | null = null;
/** Whether the last redraw actually stroked the dangling edge. */
private tempEdgeShown = false;
constructor(private readonly host: GraphCanvasHost) {}
/** A transition is waiting for its target. */
get isArmed(): boolean {
return this.armed !== null;
}
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
get target(): PortSide | null {
return this.dropTarget;
}
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
get edgeShown(): boolean {
return this.tempEdgeShown;
}
/** A press on a pin: the edge follows the pointer until the button comes up. */
startDrag(from: PortSide, global: Point): void {
this.connecting = { from, pointer: this.host.toGraph(global) };
this.dropTarget = null;
this.host.redraw();
}
/** Follows the pointer with a drag or an armed transition; false when there is neither. */
move(global: Point): boolean {
const g = this.host.toGraph(global);
if (this.connecting) {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(global);
} else if (this.armed) {
this.armed.pointer = g;
this.dropTarget = this.armedTarget(global);
} else return false;
this.host.redraw();
return true;
}
/** Ends a drag: onto a port it connects, into empty space it offers the palette. */
release(global: Point): void {
const connecting = this.connecting;
const target = connecting ? (this.dropTarget ?? this.resolveDrop(global)) : null;
this.connecting = null;
this.dropTarget = null;
if (!connecting || !this.host.assetId()) return;
if (target) this.connect(connecting.from, target);
else if (!this.host.nodeUnder(global)) this.openPaletteForDrop(connecting, global);
}
private connect(from: PortSide, to: PortSide): void {
const assetId = this.host.assetId();
if (assetId) this.host.state.execute(connectSides(assetId, from, to));
}
/**
* Dropped into empty space: offer the node types this edge could reach, then wire the new node
* up the same way a drop on its body would.
*/
private openPaletteForDrop(drag: { from: PortSide; pointer: Point }, global: Point): void {
const graph = this.host.asset()?.graph;
const from = drag.from;
const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project));
if (!graph || fromPort === undefined || this.host.entries().length === 0) return;
const at = this.host.toGraph(global);
this.pendingDrop = drag;
const palette = new NodePalette(
this.host.entries(),
(entry) => this.addConnectedNode(entry, from, fromPort, at),
(def) => defAcceptsFrom(def, fromPort, from.isOutput),
);
palette.onClose = () => {
this.pendingDrop = null;
this.host.redraw();
};
palette.open(global.x, global.y);
}
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
private addConnectedNode(entry: PaletteEntry, from: PortSide, fromPort: PortDef, at: Point): void {
const assetId = this.host.assetId();
if (!assetId) return;
const add = this.host.newNode(entry, at);
const port = firstPortFor(entry.def, fromPort, from.isOutput);
const commands: Command[] = [add];
if (port)
commands.push(connectSides(assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }));
if (this.host.state.execute(oneStep(add.label, commands)))
this.host.state.select(nodeSelection(assetId, [add.node.id]));
}
/**
* Where the running drag would connect: the port under the pointer, or – dropped on a node's
* body – the port that node picks for it. Null means the drop does nothing.
*/
private resolveDrop(global: Point): PortSide | null {
const graph = this.host.asset()?.graph;
const from = this.connecting?.from;
if (!graph || !from) return null;
const lookup = graphLookup(this.host.state.project);
const hit = this.portUnder(global);
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
const node = this.host.nodeUnder(global);
if (!node || this.host.views().get(node)?.collapsed) return null;
return dropTargetPort(graph, from, node, lookup);
}
private portUnder(global: Point): PortSide | null {
for (const view of this.host.topDownViews()) {
const local = view.toLocal(global);
const hit = view.portAt(local.x, local.y);
// A ghost is no target, and it must not shadow the node body underneath it either.
if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
}
return null;
}
/** Begin a transition out of this state; the next click on another one completes it. */
arm(nodeId: string, pointer: Point): void {
this.cancel();
const handlers = ui().focus.globalHandlers;
const onKey = (event: KeyboardEvent): boolean => {
if (event.key !== 'Escape' || !this.armed) return false;
this.cancel();
this.host.redraw();
return true;
};
handlers.push(onKey);
this.armed = {
from: { nodeId, port: STATE_OUT, isOutput: true },
pointer,
release: () => {
const at = handlers.indexOf(onKey);
if (at >= 0) handlers.splice(at, 1);
},
};
this.dropTarget = null;
this.host.redraw();
}
cancel(): void {
this.armed?.release();
this.armed = null;
}
/** Where an armed transition would land: the state under the pointer, its own source included. */
private armedTarget(global: Point): PortSide | null {
if (!this.armed) return null;
const node = this.host.nodeUnder(global);
if (!node) return null;
const view = this.host.views().get(node);
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
}
/** Finish or abandon an armed transition. True when the click belonged to it. */
finish(global: Point, connect: boolean): boolean {
const armed = this.armed;
if (!armed) return false;
const target = connect ? this.armedTarget(global) : null;
this.cancel();
this.dropTarget = null;
if (target) this.connect(armed.from, target);
this.host.redraw();
return true;
}
/** The edge being made, from its pin to the pointer – or to the port it would land on. */
draw(g: Graphics, graph: Graph): void {
const c = Theme.colors;
const views = this.host.views();
const target = this.dropTarget;
g.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from);
const home = drag && target?.nodeId === drag.from.nodeId ? views.get(target.nodeId) : undefined;
if (home?.def.transitionEdges === true) {
const ring = graph.edges.filter(
(e) => e.from.node === home.node.id && e.to.node === home.node.id,
).length;
const loop = selfLoop(home, ring);
loopPath(g, loop);
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
arrowHead(g, loop.c2, loop.end, c.textMuted);
this.tempEdgeShown = true;
} else if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
// Dragged off a state, the line already looks like the transition it is about to become.
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
const ends = preview ? [preview.start, preview.end] : [a, b];
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
if (preview) {
const shaft = arrowShaft(from, to);
g.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
} else bezier(g, from.x, from.y, to.x, to.y);
g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(g, from, to, c.textMuted);
this.tempEdgeShown = true;
}
}
/** Null unless the drag started on a state: only those draw as a straight arrow. */
private transitionPreviewOf(
from: PortSide,
target: PortSide | null,
pointer: Point,
): { start: Point; end: Point } | null {
const views = this.host.views();
const source = views.get(from.nodeId);
if (source?.def.transitionEdges !== true) return null;
const to = target ? views.get(target.nodeId) : undefined;
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
}
/** A port's centre in graph coordinates. */
private anchorPoint(side: PortSide | null): Point | null {
if (!side) return null;
const view = this.host.views().get(side.nodeId);
const p = view?.portPosition(side.port, side.isOutput);
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
}
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
return new ConnectCommand(
assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
);
}