A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s

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>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-14 14:05:18 +02:00
1 parent a041322a0e
commit 68c92daaa6
27 files changed
+1186 -38

No files matched your search

+62 -5
View File
@@ -1,6 +1,8 @@
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,
@@ -17,6 +19,7 @@ import { type NodeView, portColor } from './NodeView';
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;
@@ -48,6 +51,9 @@ export class EdgeLayer extends Container {
/** 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();
@@ -69,6 +75,12 @@ export class EdgeLayer extends Container {
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();
@@ -111,14 +123,25 @@ export class EdgeLayer extends Container {
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 };
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) {
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
// 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;
}
if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
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,
@@ -126,6 +149,8 @@ export class EdgeLayer extends Container {
// 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);
@@ -138,6 +163,23 @@ export class EdgeLayer extends Container {
}
}
/** 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>,
@@ -195,6 +237,21 @@ export class EdgeLayer extends Container {
}
}
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);
+136 -6
View File
@@ -27,6 +27,8 @@ import {
portTypeOf,
resolveDef,
RUN_BEHAVIOR,
STATE_IN,
STATE_OUT,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
@@ -44,16 +46,19 @@ import {
endDrag,
onDragChange,
pressCount,
type MenuItem,
type Size,
Theme,
textStyle,
truncate,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { bezier, EdgeLayer } from './EdgeLayer';
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
import { arrowShaft, type Point, transitionPreview } from './transitions';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
@@ -112,6 +117,10 @@ export class GraphCanvas extends Widget {
/** 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: { x: number; y: number } } | 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: { x: number; y: number }; 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. */
@@ -173,6 +182,8 @@ export class GraphCanvas extends Widget {
/** Switching refits the view, since the next graph is a new subject. */
show(assetId: string): void {
if (this.assetId === assetId) return;
// Its source node is in the graph being left, so the click that finished it would land nowhere.
this.cancelTransition();
this.assetId = assetId;
this.sync();
this.needsFit = true;
@@ -216,6 +227,7 @@ export class GraphCanvas extends Widget {
if (!asset) {
// An asset that was deleted (or undone away) must not linger as a graph.
this.assetId = '';
this.cancelTransition();
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
@@ -317,6 +329,11 @@ export class GraphCanvas extends Widget {
return;
}
if (e.target !== this) return;
// Away from every state, so there is nothing to transition to: the click drops the attempt.
if (this.armed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, false);
return;
}
// Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) {
startPan();
@@ -346,6 +363,12 @@ export class GraphCanvas extends Widget {
if (e.button === 1) return;
if (!this.assetId) return;
e.stopPropagation();
// An armed transition eats the next click whole: on another state it lands, anywhere else it
// is dropped. Selecting or dragging the node underneath would be a second meaning for one click.
if (this.armed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, e.button === 0);
return;
}
const local = view.toLocal(e.global);
const hit = view.portAt(local.x, local.y);
if (e.button === 2) {
@@ -356,7 +379,13 @@ export class GraphCanvas extends Widget {
} else {
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
const at = this.toLocal(e.global);
const graphPoint = this.screenToGraph(at.x, at.y);
const extras: MenuItem[] =
view.def.transitionEdges === true
? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }]
: [];
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
}
return;
}
@@ -446,6 +475,10 @@ export class GraphCanvas extends Widget {
this.connecting.pointer = g;
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
this.redraw();
} else if (this.armed) {
this.armed.pointer = g;
this.dropTarget = this.armedTarget(e.global.x, e.global.y);
this.redraw();
} else if (this.marquee) {
this.marquee.current = g;
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
@@ -607,6 +640,18 @@ export class GraphCanvas extends Widget {
return null;
}
/** 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 source = this.views.get(from.nodeId);
if (source?.def.transitionEdges !== true) return null;
const to = target ? this.views.get(target.nodeId) : undefined;
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
}
private nodeUnder(globalX: number, globalY: number): string | null {
for (const view of this.topDownViews()) {
const p = view.toLocal({ x: globalX, y: globalY });
@@ -666,6 +711,59 @@ export class GraphCanvas extends Widget {
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
}
/** Begin a transition out of this state; the next click on another one completes it. */
private armTransition(nodeId: string, pointer: { x: number; y: number }): void {
this.cancelTransition();
const handlers = ui().focus.globalHandlers;
const onKey = (event: KeyboardEvent): boolean => {
if (event.key !== 'Escape' || !this.armed) return false;
this.cancelTransition();
this.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.redraw();
}
private cancelTransition(): void {
this.armed?.release();
this.armed = null;
}
/**
* Where an armed transition would land: any other state under the pointer. A state cannot
* transition to itself, which is what `ConnectCommand` says about every node.
*/
private armedTarget(globalX: number, globalY: number): PortSide | null {
const from = this.armed?.from;
if (!from) return null;
const node = this.nodeUnder(globalX, globalY);
if (!node || node === from.nodeId) return null;
const view = this.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. */
private finishTransition(globalX: number, globalY: number, connect: boolean): boolean {
const armed = this.armed;
if (!armed) return false;
const target = connect ? this.armedTarget(globalX, globalY) : null;
this.cancelTransition();
this.dropTarget = null;
if (target) this.connect(armed.from, target);
this.redraw();
return true;
}
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
private setHint(text: string): void {
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
@@ -688,7 +786,15 @@ export class GraphCanvas extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.setHint(asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '');
this.setHint(
this.armed
? 'Click the state to transition to – anywhere else, or Escape, cancels'
: asset.graph.nodes.length === 0
? 'Right-click to add a node'
: playing
? 'live'
: '',
);
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -721,14 +827,21 @@ export class GraphCanvas extends Widget {
this.tempEdge.clear();
this.tempEdgeShown = false;
const drag = this.connecting ?? this.pendingDrop;
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
const a = drag && this.anchorPoint(drag.from);
if (drag && a) {
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
const b = this.anchorPoint(target) ?? drag.pointer;
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
// 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);
this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
} else bezier(this.tempEdge, from.x, from.y, to.x, to.y);
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
if (preview) arrowHead(this.tempEdge, from, to, c.textMuted);
this.tempEdgeShown = true;
}
@@ -799,6 +912,23 @@ export class GraphCanvas extends Widget {
return this.edgeLayer.wasStroked(edgeId);
}
/** The name drawn on a transition, or null when nothing is drawn on it. */
transitionLabelDrawn(edgeId: string): string | null {
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
}
/** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */
portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean {
const view = this.views.get(nodeId);
const p = view?.portPosition(port, isOutput);
return view !== undefined && p !== null && p !== undefined && view.portAt(p.x, p.y) !== null;
}
/** The line at the bottom of the canvas – what tells the user an armed transition is waiting. */
shownHint(): string {
return this.hint.text;
}
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
return this.edgeLayer
.drawnMarks()
+7 -2
View File
@@ -199,7 +199,9 @@ export class NodeView extends Widget {
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
}
/** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */
private rowCount(): number {
if (this.def.transitionEdges === true) return 0;
return Math.max(this.def.inputs.length, this.def.outputs.length);
}
@@ -275,9 +277,12 @@ export class NodeView extends Widget {
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Every port with its local centre – none while collapsed, so none can be grabbed. */
/**
* Every port with its local centre – none while collapsed, and none on a state, so neither can
* be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor.
*/
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
if (this.collapsed) return;
if (this.collapsed || this.def.transitionEdges === true) return;
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
+2
View File
@@ -13,6 +13,7 @@ export function openNodeMenu(
nodes: readonly GraphNode[],
x: number,
y: number,
extras: readonly MenuItem[] = [],
): void {
const ids = state.selectedNodeIds;
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
@@ -24,6 +25,7 @@ export function openNodeMenu(
const expanded = nodes.filter((n) => !n.collapsed);
const collapsed = nodes.filter((n) => n.collapsed);
new Menu([
...extras,
...fold(expanded, 'Collapse', true),
...fold(collapsed, 'Expand', false),
{
+134
View File
@@ -0,0 +1,134 @@
/**
* Geometry of a transition: the straight arrow drawn between two states. It runs border to border
* rather than pin to pin, because a diagonal line into a port would pass under the target node and
* take its arrowhead with it – the usual curve never does, since it swings in horizontally.
*/
export interface Point {
x: number;
y: number;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Length of an arrowhead along the line, and half its width across. */
export const ARROW = 10;
export const ARROW_HALF_WIDTH = 4.5;
/** Air between a node's border and the end of the arrow touching it. */
export const BORDER_GAP = 7;
/** How far apart two transitions between the same pair of states are pushed. */
export const PARALLEL_SEP = 14;
export function centreOf(box: Rect): Point {
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
}
/** Null when the two points coincide: there is no direction to draw along. */
export function direction(a: Point, b: Point): Point | null {
const length = Math.hypot(b.x - a.x, b.y - a.y);
return length === 0 ? null : { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
}
/** Where a ray leaving the box's centre crosses its border. */
export function boxExit(box: Rect, dir: Point): Point {
const centre = centreOf(box);
const tx = dir.x === 0 ? Infinity : box.w / 2 / Math.abs(dir.x);
const ty = dir.y === 0 ? Infinity : box.h / 2 / Math.abs(dir.y);
const t = Math.min(tx, ty);
return { x: centre.x + dir.x * t, y: centre.y + dir.y * t };
}
/** The normal of a direction, pointing to its right. */
function normal(dir: Point): Point {
return { x: -dir.y, y: dir.x };
}
function shift(point: Point, out: Point, along: number, n: Point, across: number): Point {
return { x: point.x + out.x * along + n.x * across, y: point.y + out.y * along + n.y * across };
}
/**
* The two points a transition between these nodes runs between: each a border crossing, pushed
* `BORDER_GAP` clear of the node and `offset` sideways. Null when the boxes share a centre.
*/
export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point; end: Point } | null {
const dir = direction(centreOf(from), centreOf(to));
if (!dir) return null;
const back = { x: -dir.x, y: -dir.y };
const n = normal(dir);
return {
start: shift(boxExit(from, dir), dir, BORDER_GAP, n, offset),
end: shift(boxExit(to, back), back, BORDER_GAP, n, offset),
};
}
/** One transition as the offsets care about it: which states it joins, under which edge id. */
export interface TransitionLink {
id: string;
from: string;
to: string;
}
/**
* How far each transition is pushed sideways, so several between the same two states lie next to
* each other instead of on top. The fan is computed in the canonical direction of the pair, and a
* transition running the other way takes the opposite sign – which is what puts a there-and-back
* pair on either side of the line rather than both on the same one.
*/
export function transitionOffsets(links: readonly TransitionLink[]): Map<string, number> {
const groups = new Map<string, TransitionLink[]>();
for (const link of links) {
const [a, b] = link.from < link.to ? [link.from, link.to] : [link.to, link.from];
const key = `${a}\u0000${b}`;
const group = groups.get(key);
if (group) group.push(link);
else groups.set(key, [link]);
}
const offsets = new Map<string, number>();
for (const group of groups.values()) {
for (const [i, link] of group.entries()) {
const canonical = (i - (group.length - 1) / 2) * PARALLEL_SEP;
offsets.set(link.id, link.from <= link.to ? canonical : -canonical);
}
}
return offsets;
}
/**
* The line drawn while a transition is being dragged: out of the source's border towards the
* pointer, or border to border once the drag has found another state.
*/
export function transitionPreview(
source: Rect,
target: Rect | null,
pointer: Point,
): {
start: Point;
end: Point;
} {
if (target) return transitionEnds(source, target) ?? { start: centreOf(source), end: pointer };
const dir = direction(centreOf(source), pointer);
if (!dir) return { start: centreOf(source), end: pointer };
return { start: shift(boxExit(source, dir), dir, BORDER_GAP, normal(dir), 0), end: pointer };
}
/** Where the line stops: at the head's base, so the stroke does not blunt its tip. */
export function arrowShaft(start: Point, end: Point): Point {
const u = direction(start, end);
return u ? { x: end.x - u.x * ARROW, y: end.y - u.y * ARROW } : end;
}
/** The three corners of the arrowhead at the target end, tip first. */
export function arrowCorners(start: Point, end: Point): [Point, Point, Point] | null {
const u = direction(start, end);
if (!u) return null;
const base = arrowShaft(start, end);
const nx = -u.y * ARROW_HALF_WIDTH;
const ny = u.x * ARROW_HALF_WIDTH;
return [end, { x: base.x + nx, y: base.y + ny }, { x: base.x - nx, y: base.y - ny }];
}