Files
feuerwerk/src/editor/graph/EdgeLayer.ts
T
rootandClaude Opus 5 68c92daaa6
game-maker / suite (push) Successful in 10m22s
A transition carries a name, so a state needs no visible port (#52)
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.

Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.

Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.

`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.

The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.

One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:05:18 +02:00

291 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 { Container, Graphics, Text } from 'pixi.js';
import { type GraphEdge, portsCompatible } from '../../core/graph';
import { Theme, textStyle, truncate } from '../../ui';
import { dashedLine } from '../../viewport/symbols';
import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { curvePoint, curveReach } from './beads';
import { type NodeView, portColor } from './NodeView';
/** How far a folded edge's stub reaches out of its port. */
const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
const TRANSITION_LABEL_WIDTH = 110;
const BEAD_RADIUS = 4.5;
/** Dash pattern and sampling of an edge hanging on a port that is gone. */
const GHOST_DASH = 7;
const GHOST_GAP = 5;
const DASH_STEPS = 80;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
edgeIds: string[];
/** The nodes at the other ends, deduped – clicking selects them all. */
others: string[];
isOutput: boolean;
x: number;
y: number;
text: string;
}
/** The curves between nodes and the marks of the hidden ones, in graph coordinates. */
export class EdgeLayer extends Container {
private readonly curves = new Graphics();
private readonly beads = new Graphics();
private readonly chips = new Graphics();
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
private readonly labelLayer = new Container();
private readonly labels: Text[] = [];
private labelCount = 0;
private ends: FoldMarkEnd[] = [];
private marks: FoldMark[] = [];
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
private readonly stroked = new Set<string>();
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = [];
/** Sideways shift per transition, so two between the same states do not lie on top of each other. */
private offsets = new Map<string, number>();
private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = [];
constructor() {
super();
this.addChild(this.curves, this.beads, this.chips, this.labelLayer);
}
draw(
edges: readonly GraphEdge[],
views: ReadonlyMap<string, NodeView>,
isHot: (edgeId: string) => boolean,
selected: Set<string>,
beads: (edgeId: string) => readonly number[] = () => [],
): void {
this.curves.clear();
this.beads.clear();
this.beadsDrawn = [];
this.chips.clear();
this.marks = [];
this.ends = [];
this.labelCount = 0;
this.stroked.clear();
this.transitionLabels = [];
this.offsets = transitionOffsets(
edges
.filter((e) => isTransition(e, views))
.map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })),
);
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id));
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
for (let i = this.labelCount; i < this.labels.length; i++) this.labels[i]!.visible = false;
}
markAt(g: { x: number; y: number }): FoldMark | null {
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
}
drawnMarks(): readonly FoldMark[] {
return this.marks;
}
wasStroked(edgeId: string): boolean {
return this.stroked.has(edgeId);
}
drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] {
return this.beadsDrawn;
}
private drawEdge(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
hot: boolean,
selected: Set<string>,
beads: readonly number[],
): void {
const from = views.get(edge.from.node);
const to = views.get(edge.to.node);
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const out = from.def.outputs.find((p) => p.key === edge.from.port);
const inp = to.def.inputs.find((p) => p.key === edge.to.port);
const type = out?.type ?? 'any';
const inType = inp?.type ?? 'any';
// One end on a port that is gone: the edge is drawn, but never as a working link.
const ghost = out?.ghost === true || inp?.ghost === true;
const valid = !ghost && portsCompatible(type, inType);
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
const pins = {
start: { x: from.x + a.x, y: from.y + a.y },
end: { x: to.x + b.x, y: to.y + b.y },
};
const transition = isTransition(edge, views)
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
: null;
const { start, end } = transition ?? pins;
if (edge.folded) {
// The fold mark belongs on the port, whatever the line between the nodes does.
this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
const shaft = transition ? arrowShaft(start, end) : end;
if (ghost && transition)
dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP);
else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
});
if (transition) arrowHead(this.curves, start, end, color);
if (transition && edge.label) this.drawTransitionLabel(edge, start, end);
this.stroked.add(edge.id);
for (const progress of beads) {
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
// Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up.
this.beads
.circle(x, y, BEAD_RADIUS)
.fill(Theme.colors.text)
.stroke({ width: 1.5, color: Theme.colors.graphBackground });
this.beadsDrawn.push({ edgeId: edge.id, progress, x, y });
}
}
/** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */
private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void {
const label = this.nextLabel();
label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH);
const x = (start.x + end.x) / 2 - label.width / 2;
const y = (start.y + end.y) / 2 - label.height / 2;
label.position.set(x, y);
this.chips
.roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y });
}
drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] {
return this.transitionLabels;
}
private addFoldEnd(
edge: GraphEdge,
views: ReadonlyMap<string, NodeView>,
port: { x: number; y: number },
isOutput: boolean,
otherLabel: string,
color: number,
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
this.ends.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput, views.get(side.node)?.collapsed),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of buildFoldMarks(this.ends, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.curves.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.curves.circle(x, y, radius).fill(color);
this.curves.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.nextLabel();
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
const gap = radius + 4;
const lx = isOutput ? x + gap : x - gap - label.width;
label.position.set(lx, y - label.height / 2);
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
this.chips
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
.fill({ color: Theme.colors.node, alpha: 0.85 });
this.marks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
}
}
private nextLabel(): Text {
let label = this.labels[this.labelCount];
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.labels.push(label);
this.labelLayer.addChild(label);
}
label.visible = true;
this.labelCount++;
return label;
}
}
function isTransition(edge: GraphEdge, views: ReadonlyMap<string, NodeView>): boolean {
return (
views.get(edge.from.node)?.def.transitionEdges === true &&
views.get(edge.to.node)?.def.transitionEdges === true
);
}
/** The filled triangle at the target end of a transition. */
export function arrowHead(g: Graphics, start: Point, end: Point, color: number): void {
const corners = arrowCorners(start, end);
if (!corners) return;
const [tip, left, right] = corners;
g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color);
}
/** Horizontal S-curve between two points. */
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
const dx = curveReach(x1, x2);
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
}
/**
* The same curve as dashes. Pixi has no dash style, and unlike a straight line a bezier cannot be
* stepped by length, so it is sampled and the walked distance decides which steps are drawn.
*/
export function dashedBezier(
g: Graphics,
x1: number,
y1: number,
x2: number,
y2: number,
dash: number,
gap: number,
): void {
const dx = curveReach(x1, x2);
const at = (t: number) => {
const u = 1 - t;
return {
x: u * u * u * x1 + 3 * u * u * t * (x1 + dx) + 3 * u * t * t * (x2 - dx) + t * t * t * x2,
y: u * u * u * y1 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y2,
};
};
let prev = at(0);
let walked = 0;
for (let i = 1; i <= DASH_STEPS; i++) {
const point = at(i / DASH_STEPS);
if (walked % (dash + gap) < dash) g.moveTo(prev.x, prev.y).lineTo(point.x, point.y);
walked += Math.hypot(point.x - prev.x, point.y - prev.y);
prev = point;
}
}