Files
feuerwerk/src/editor/graph/edgeHit.ts
T
adminandClaude Opus 5.5 c5ba21533d
game-maker / gate (push) Successful in 2m37s
game-maker / suite (push) Successful in 13m10s
Pick a link on its line and delete it from there (#289)
A link could only be removed through the menu of its port, and the
line itself took no input. A transition of a state machine could only
be deleted in the list of the inspector. Now the line is the handle,
in a behavior's graph and between states alike.

- A click on a line selects that link and draws a halo behind it. A
  press counts within 6 screen pixels of the line, and where two cross
  the one drawn last wins.
- Ctrl-click or Shift-click adds a link or takes it away. The rubber
  band takes every line it crosses, into the same selection as the
  nodes and boxes it caught.
- Delete or Backspace removes what is picked as one undo step. A
  right-click on a line offers Hide link and Delete link.
- The inspector shows a picked link: the node and port at each end and
  the type it carries, with Hide link and Delete link below. For
  several it says how many.
- The arrow of a transition is picked the same way, on its name too.
  Delete or a right-click with Delete transition removes it.
- The menu of a port is unchanged, Disconnect included, as answered in
  the issue.
- editor/graph/edgeHit.ts holds the hit test as pure functions, and
  editor/inspector/edgeSection.ts builds the section. The selection
  has a new kind edge, and node and annotation carry edgeIds.

Decided here:

- A hidden link has no line and is never picked this way, also while
  it is shown on loan. Its mark at the port stays its handle.
- The menu of a line speaks about the whole selection, like the menu
  of a box. With nodes or boxes held alongside it reads Delete N
  items.
- Hiding picked links lets go of them, since no line is left.
- A link wins over the box it crosses, and a mark wins over a link.
- A transition has no Hide entry.
- With nodes or boxes in the selection the inspector keeps showing
  the node or box, not the link.

Tests:
- Gate: 201 files, 2982 tests (2967 + 15), exit 0
- e2e, full run: 1635 checks passed (1629 + 6), exit 0
- Smokes: 24 / 19 / 21, exit 0
- The full run was done for exactly this state.
- 15 unit tests in tests/editor/edge-selection.test.ts
- Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7
  in module 26
- 12 unit counter-probes all fail tests, run on stein2
- Three browser counter-probes fail, the build went through each
  time: no hit on the line, no halo, a band that skips lines
- Screenshots of a picked link and a picked transition looked at
  before the last build, none after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:51:15 +02:00

104 lines
3.8 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.
/** Which link a click or a rubber band means, kept out of the canvas so tests can reach it (#289). */
import type { Band } from '../framing';
import { curvePoint } from './beads';
import { type Loop, loopPoint, type Point, type Rect } from './transitions';
/** One link as the last draw laid it out, in graph units. */
export type EdgePath = { edgeId: string } & (
| { kind: 'curve'; start: Point; end: Point }
| { kind: 'line'; start: Point; end: Point }
| { kind: 'loop'; loop: Loop }
);
/** A transition's name plate: clicking the name means its arrow. */
export type EdgePlate = Rect & { edgeId: string };
/** Straight pieces a curve is tested as; at 24 the error stays far below a pixel of tolerance. */
const CURVE_STEPS = 24;
export function pathPoints(path: EdgePath): Point[] {
if (path.kind === 'line') return [path.start, path.end];
const at = (t: number) =>
path.kind === 'loop'
? loopPoint(path.loop, t)
: curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, t);
return Array.from({ length: CURVE_STEPS + 1 }, (_, i) => at(i / CURVE_STEPS));
}
/** The point halfway along a link – where a check clicks it. */
export function pathMiddle(path: EdgePath): Point {
if (path.kind === 'line') return { x: (path.start.x + path.end.x) / 2, y: (path.start.y + path.end.y) / 2 };
return path.kind === 'loop'
? loopPoint(path.loop, 0.5)
: curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, 0.5);
}
function distanceToSegment(p: Point, a: Point, b: Point): number {
const [dx, dy] = [b.x - a.x, b.y - a.y];
const length = dx * dx + dy * dy;
const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length));
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
}
export function distanceToPath(path: EdgePath, point: Point): number {
const points = pathPoints(path);
let nearest = Infinity;
for (let i = 1; i < points.length; i++)
nearest = Math.min(nearest, distanceToSegment(point, points[i - 1]!, points[i]!));
return nearest;
}
/**
* The link under a point, or null. A name plate wins over any line, and of several lines within
* `tolerance` the one drawn last does – it is the one on top.
*/
export function edgeAt(
paths: readonly EdgePath[],
point: Point,
tolerance: number,
plates: readonly EdgePlate[] = [],
): string | null {
for (let i = plates.length - 1; i >= 0; i--) {
const p = plates[i]!;
if (point.x >= p.x && point.x <= p.x + p.w && point.y >= p.y && point.y <= p.y + p.h) return p.edgeId;
}
for (let i = paths.length - 1; i >= 0; i--)
if (distanceToPath(paths[i]!, point) <= tolerance) return paths[i]!.edgeId;
return null;
}
/** Whether the segment a-b has any point inside the rectangle (Liang-Barsky). */
function segmentInRect(a: Point, b: Point, minX: number, minY: number, maxX: number, maxY: number): boolean {
let [t0, t1] = [0, 1];
const [dx, dy] = [b.x - a.x, b.y - a.y];
for (const [p, q] of [
[-dx, a.x - minX],
[dx, maxX - a.x],
[-dy, a.y - minY],
[dy, maxY - a.y],
] as const) {
if (p === 0) {
if (q < 0) return false;
continue;
}
const r = q / p;
if (p < 0) t0 = Math.max(t0, r);
else t1 = Math.min(t1, r);
if (t0 > t1) return false;
}
return true;
}
/** Ids of every link the band touches, in drawing order: a line is thin, so crossing it counts. */
export function edgesInBand(paths: readonly EdgePath[], band: Band): string[] {
const [minX, maxX] = [Math.min(band.x1, band.x2), Math.max(band.x1, band.x2)];
const [minY, maxY] = [Math.min(band.y1, band.y2), Math.max(band.y1, band.y2)];
return paths
.filter((path) => {
const points = pathPoints(path);
return points.some((p, i) => i > 0 && segmentInRect(points[i - 1]!, p, minX, minY, maxX, maxY));
})
.map((path) => path.edgeId);
}