An edge to a vanished port needs a handle, so the port stays as a ghost (#60)
game-maker / suite (push) Successful in 10m7s

Four ways erase a derived port and take no edge with them: deleting a state,
deleting the `State machine` literal, deleting `Behavior start`/`end`, deleting
the asset. The common cause is `RemoveNodeCommand` clearing only its own graph,
so the fix sits in `drawnDef` (graph/nested.ts) and touches no delete site.

A right-click on an edge goes through `view.portAt` only — there is no curve
hit-test. Without a port row such an edge is not merely invisible, it is
unreachable, so the ghost row is the handle Disconnect hangs on.

`resolveDef` stays untouched: it is the truth for interpreter, compiler,
ConnectCommand and dropTarget, so nothing runs or connects through a ghost
without a second guard.
This commit is contained in:
root committed 2026-09-14 11:50:36 +02:00
1 parent d5e67cd80c
commit a041322a0e
8 files changed
+372 -16

No files matched your search

+44 -4
View File
@@ -18,6 +18,10 @@ const FOLD_STUB = 16;
const FOLD_MARK_RADIUS = 5;
const FOLD_LABEL_WIDTH = 96;
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 {
@@ -99,9 +103,13 @@ export class EdgeLayer extends Container {
const a = from?.portPosition(edge.from.port, true);
const b = to?.portPosition(edge.to.port, false);
if (!from || !to || !a || !b) return;
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
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 start = { x: from.x + a.x, y: from.y + a.y };
const end = { x: to.x + b.x, y: to.y + b.y };
@@ -110,7 +118,8 @@ export class EdgeLayer extends Container {
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
if (!foldedEdgeShown(edge, selected, hot)) return;
}
bezier(this.curves, start.x, start.y, end.x, end.y);
if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color,
@@ -191,3 +200,34 @@ export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: numb
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;
}
}