Files
feuerwerk/src/editor/polygonEdit.ts
T
adminandClaude Opus 5.5 0f85476b93
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s
Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.

- A double-click on a line, edge or segment puts a point there, in the
  Curve tab on free space too. The point is picked and stays in place.
  It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
  on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
  point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
  point. In the Curve and Vector art tab the four point types stand
  below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
  is greyed out then.
- The pick ends with a click beside the points, with another object,
  shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
  editor/pointMenu.ts builds the menu for all four.

Decided here:

- A single click on a line no longer adds anything. In Vector art it
  drags the shape, on a polygon it drops the pick, and in the Texture
  tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
  and that corner is picked. Delete takes the last corner first. To
  remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
  not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
  focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
  an empty shape stayed in the list.

Findings:

- Fixed: the new hint line in the Texture tab was cut off at the edge.
  The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
  corner that sits near the centre. That was so before.

Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
  For that final state the gate and e2e 42,47,53 (105 checks) ran
  again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
  and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
  47 and 53. The existing checks for adding and removing a point use
  the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
  Without the Delete wiring the key removes the collider. Without the
  right-click hook the menu is missing, and that run stops in module
  42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
  the Vector art tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:13 +02:00

367 lines
17 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.
/**
* Editing an outline by its corners (#141, #151), the same in the Scene view (a polygon collider)
* and in the Texture tab (a frame's collision outline): drag a corner, put one on an edge, take one
* away. Pure, so the rules are testable without a view; which press does which is `pointGestures.ts`.
*
* The lower half of the file says the same about a ring of a vector art shape (#129, stage 3). It is
* the same set of gestures on a richer point: one that may carry Bezier handles. They are kept here
* rather than in a module of their own so the two cannot drift apart – a corner is grabbed by the
* same rule in the Texture tab and in the Vector art tab.
*/
import type { VectorPoint, VectorPointMode, VectorRing } from '../core/model/Asset';
import type { Vec2 } from '../core/model/Properties';
import { closestOnSegment, cubicAt, splitCubic } from '../core/util/vec';
// Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once.
import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart';
/** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */
export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 };
/** Fewer corners than this are no polygon; the last three cannot be taken away. */
export const MIN_CORNERS = 3;
/**
* The corner or edge within `tolerance` of `p`, the nearest one; a corner wins over an edge, since
* every corner lies on two edges. An outline of one or two corners has no edge to press on yet.
*/
export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): PolygonGrip | null {
let best: PolygonGrip | null = null;
let nearest = tolerance;
for (const [index, corner] of points.entries()) {
const distance = Math.hypot(corner.x - p.x, corner.y - p.y);
if (distance > nearest) continue;
best = { kind: 'corner', index };
nearest = distance;
}
if (best || points.length < MIN_CORNERS) return best;
for (const [index, a] of points.entries()) {
const at = closestOnSegment(p, a, points[(index + 1) % points.length]!);
const distance = Math.hypot(at.x - p.x, at.y - p.y);
if (distance > nearest) continue;
best = { kind: 'edge', index, at };
nearest = distance;
}
return best;
}
/** Whether `p` lies inside the outline (even-odd); fewer than three corners enclose nothing. */
export function insideOutline(points: readonly Vec2[], p: Vec2): boolean {
if (points.length < MIN_CORNERS) return false;
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[i]!;
const b = points[j]!;
if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
}
/** A corner put at `at` on the edge after corner `index`; it becomes corner `index + 1`. */
export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] {
return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)];
}
export function withCornerMoved(points: readonly Vec2[], index: number, to: Vec2): Vec2[] {
return points.map((p, i) => (i === index ? { ...to } : { ...p }));
}
/** The outline without corner `index`; null when that would leave it fewer than `min` corners. */
export function withoutCorner(points: readonly Vec2[], index: number, min = MIN_CORNERS): Vec2[] | null {
if (points.length - 1 < min) return null;
return points.filter((_, i) => i !== index).map((p) => ({ ...p }));
}
/** How finely a curved segment is walked when the pointer asks what it is near. */
const CURVE_STEPS = 16;
/**
* What lies under the pointer on a ring: a point, one of its two handles, or the segment that
* starts at point `index` – `at` is the place on it and `t` how far along it lies, which is where a
* new point goes. For a closed ring the last segment is the way back to the first point.
*/
export type RingGrip =
| { kind: 'point'; index: number }
| { kind: 'handle'; index: number; side: 'in' | 'out' }
| { kind: 'segment'; index: number; at: Vec2; t: number };
/**
* The grip within `tolerance` of `p`, the nearest one. A handle wins over a point and a point over a
* segment: a handle pulled onto its own point must stay grabbable, and every point lies on two
* segments. Only the handles of `shown` are offered, because only those are drawn – a handle one
* cannot see is one nobody means to grab.
*/
export function ringGripAt(
ring: VectorRing,
p: Vec2,
tolerance: number,
shown: readonly number[] = ring.points.map((_, i) => i),
): RingGrip | null {
let best: RingGrip | null = null;
let nearest = tolerance;
for (const index of shown) {
const point = ring.points[index];
if (!point) continue;
for (const side of ['in', 'out'] as const) {
const at = handleAt(point, side);
const distance = at ? Math.hypot(at.x - p.x, at.y - p.y) : Infinity;
if (distance > nearest) continue;
best = { kind: 'handle', index, side };
nearest = distance;
}
}
if (best) return best;
for (const [index, point] of ring.points.entries()) {
const distance = Math.hypot(point.x - p.x, point.y - p.y);
if (distance > nearest) continue;
best = { kind: 'point', index };
nearest = distance;
}
if (best) return best;
for (const [index, from] of ringSegments(ring)) {
const to = ring.points[(index + 1) % ring.points.length]!;
const hit = nearestOnSegment(from, to, p);
if (hit.distance > nearest) continue;
best = { kind: 'segment', index, at: hit.at, t: hit.t };
nearest = hit.distance;
}
return best;
}
/** The segments of a ring as `[index, from]`; a closed ring has the way back from its last point. */
export function ringSegments(ring: VectorRing): [number, VectorPoint][] {
const segments: [number, VectorPoint][] = [];
const last = ring.points.length - (ring.closed ? 0 : 1);
for (let i = 0; i < last; i++) segments.push([i, ring.points[i]!]);
return segments;
}
/** The point on the segment nearest to `p`, straight or curved, and how far along it lies. */
export function nearestOnSegment(
from: VectorPoint,
to: VectorPoint,
p: Vec2,
): { at: Vec2; t: number; distance: number } {
if (!from.out && !to.in) {
const at = closestOnSegment(p, from, to);
const span = Math.hypot(to.x - from.x, to.y - from.y);
const t = span > 0 ? Math.hypot(at.x - from.x, at.y - from.y) / span : 0;
return { at, t, distance: Math.hypot(at.x - p.x, at.y - p.y) };
}
// A curve is walked rather than solved: a handful of samples is as near as a pointer can aim.
let best = { at: { x: from.x, y: from.y }, t: 0, distance: Infinity };
for (let step = 0; step <= CURVE_STEPS; step++) {
const t = step / CURVE_STEPS;
const at = pointOnSegment(from, to, t);
const distance = Math.hypot(at.x - p.x, at.y - p.y);
if (distance < best.distance) best = { at, t, distance };
}
return best;
}
/** Where the segment is at `t` (0 at `from`, 1 at `to`), curve or line. */
export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 {
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
return cubicAt(from, c1, c2, to, t);
}
/** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */
export function ringOutline(ring: VectorRing): Vec2[] {
const points: Vec2[] = ring.points.map((p) => ({ x: p.x, y: p.y }));
if (ring.points.length < 2) return points;
const walked: Vec2[] = [];
for (const [index, from] of ringSegments(ring)) {
const to = ring.points[(index + 1) % ring.points.length]!;
walked.push({ x: from.x, y: from.y });
if (!from.out && !to.in) continue;
for (let step = 1; step < CURVE_STEPS; step++) walked.push(pointOnSegment(from, to, step / CURVE_STEPS));
}
if (!ring.closed)
walked.push({ x: ring.points[ring.points.length - 1]!.x, y: ring.points[ring.points.length - 1]!.y });
return walked;
}
/** Whether `p` lies inside the ring; an open ring encloses nothing one can click into. */
export function insideRing(ring: VectorRing, p: Vec2): boolean {
return ring.closed && insideOutline(ringOutline(ring), p);
}
/** The point moved; its handles ride along, being offsets from it. */
export function withPointMoved(ring: VectorRing, index: number, to: Vec2): VectorRing {
return mapPoints(ring, (point, i) => (i === index ? { ...point, x: to.x, y: to.y } : point));
}
/**
* What a point is to the user (#195): straight has no handle at all, the other three are the mode
* its handles move in. A point with handles and no mode is a corner.
*/
export type PointKind = 'straight' | VectorPointMode;
export const POINT_KINDS: readonly PointKind[] = ['straight', 'corner', 'smooth', 'symmetric'];
export function pointKind(point: VectorPoint): PointKind {
return point.in || point.out ? (point.mode ?? 'corner') : 'straight';
}
/**
* A handle pulled to `to`, in canvas pixels; the other handle follows the point's mode. A corner
* leaves it where it is, a smooth point turns it to the opposite direction at its own length, a
* symmetric point mirrors it – and grows it, if it was missing, which is how the pen draws a curve.
* A handle dropped back onto its point is taken away; on a symmetric point its mirror goes with it.
* `mode` overrides the point's own and is written onto it.
*/
export function withHandleMoved(
ring: VectorRing,
index: number,
side: 'in' | 'out',
to: Vec2,
mode?: VectorPointMode,
): VectorRing {
return mapPoints(ring, (point, i) => {
if (i !== index) return point;
const offset = { x: to.x - point.x, y: to.y - point.y };
const length = Math.hypot(offset.x, offset.y);
const next: VectorPoint = { ...point };
const other = side === 'in' ? 'out' : 'in';
const how = mode ?? point.mode ?? 'corner';
if (mode) next.mode = mode;
if (length < 1e-6) {
delete next[side];
if (how === 'symmetric') delete next[other];
return next;
}
next[side] = offset;
const across = next[other];
if (how === 'symmetric') next[other] = { x: -offset.x, y: -offset.y };
else if (how === 'smooth' && across) {
const keep = Math.hypot(across.x, across.y);
next[other] = { x: (-offset.x / length) * keep, y: (-offset.y / length) * keep };
}
return next;
});
}
/**
* The point turned into `kind` (#195). Straight takes both handles away. A point without handles
* that becomes one of the other three grows them first, along the line between its neighbours at a
* third of the way to each – the curve one expects, without pulling two handles out by hand. Smooth
* and symmetric then line the handles up on the bisector of the two, so the curve keeps its general
* direction: smooth keeps each length, symmetric gives both their mean. A missing second handle is
* grown opposite the first at the same length.
*/
export function withPointKind(ring: VectorRing, index: number, kind: PointKind): VectorRing {
const point = ring.points[index];
if (!point) return cloneVectorRing(ring);
if (kind === 'straight') return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p));
const had = Boolean(point.in || point.out);
// Grown handles are in line and of one length already; lining them up again would only add rounding.
let handles = had ? { in: point.in, out: point.out } : grownHandles(ring, index);
if (!handles) return cloneVectorRing(ring);
if (had && kind !== 'corner') handles = linedUp(handles, kind);
const next: VectorPoint = { x: point.x, y: point.y, mode: kind };
if (handles.in) next.in = handles.in;
if (handles.out) next.out = handles.out;
return mapPoints(ring, (p, i) => (i === index ? next : p));
}
function grownHandles(ring: VectorRing, index: number): { in: Vec2; out: Vec2 } | null {
const point = ring.points[index]!;
const count = ring.points.length;
// An open line's ends have one neighbour: the missing one stands on the point itself.
const before = ring.closed || index > 0 ? ring.points[(index - 1 + count) % count]! : point;
const after = ring.closed || index < count - 1 ? ring.points[(index + 1) % count]! : point;
const dx = (after.x - before.x) / 3;
const dy = (after.y - before.y) / 3;
if (Math.hypot(dx, dy) < 1e-6) return null;
return { in: { x: -dx, y: -dy }, out: { x: dx, y: dy } };
}
function linedUp(handles: { in?: Vec2; out?: Vec2 }, kind: 'smooth' | 'symmetric'): { in: Vec2; out: Vec2 } {
const outLength = handles.out ? Math.hypot(handles.out.x, handles.out.y) : 0;
const inLength = handles.in ? Math.hypot(handles.in.x, handles.in.y) : 0;
const ahead = handles.out ? unit(handles.out) : { x: 0, y: 0 };
const behind = handles.in ? unit(handles.in) : { x: 0, y: 0 };
// The way out, averaged with the way in turned round; handles folded onto each other keep `out`'s.
let direction = unit({ x: ahead.x - behind.x, y: ahead.y - behind.y });
if (direction.x === 0 && direction.y === 0)
direction = handles.out ? ahead : { x: -behind.x, y: -behind.y };
let lengthIn = inLength || outLength;
let lengthOut = outLength || inLength;
if (kind === 'symmetric') lengthIn = lengthOut = (lengthIn + lengthOut) / 2;
return {
in: { x: -direction.x * lengthIn, y: -direction.y * lengthIn },
out: { x: direction.x * lengthOut, y: direction.y * lengthOut },
};
}
function unit(v: Vec2): Vec2 {
const length = Math.hypot(v.x, v.y);
return length < 1e-9 ? { x: 0, y: 0 } : { x: v.x / length, y: v.y / length };
}
/**
* A point put on the segment after `index`, at `t` along it. A curved segment is split rather than
* cut: the two halves together draw exactly the curve that was there (de Casteljau), so a point put
* on a round side does not flatten it. A straight one gives a plain corner.
*/
export function withPointOnSegment(ring: VectorRing, index: number, t: number): VectorRing {
const from = ring.points[index];
const to = ring.points[(index + 1) % ring.points.length];
if (!from || !to) return cloneVectorRing(ring);
const points = ring.points.map(clonePoint);
const at = pointOnSegment(from, to, t);
let inserted: VectorPoint = { x: at.x, y: at.y };
if (from.out || to.in) {
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
const { left, right } = splitCubic([from, c1, c2, to], t);
const next = points[(index + 1) % ring.points.length]!;
setHandle(points[index]!, 'out', left[1]!);
setHandle(next, 'in', right[2]!);
// The split keeps the directions but shortens one handle of each end: no longer symmetric,
// still smooth. The new point sits on a curve, so its handles are in line – smooth as well.
for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth';
inserted = { x: at.x, y: at.y, mode: 'smooth' };
setHandle(inserted, 'in', left[2]!);
setHandle(inserted, 'out', right[1]!);
if (!inserted.in && !inserted.out) delete inserted.mode;
}
points.splice(index + 1, 0, inserted);
return { closed: ring.closed, points };
}
/** The ring without point `index`; null when nothing would be left of it. */
export function withoutPoint(ring: VectorRing, index: number): VectorRing | null {
if (ring.points.length <= 1) return null;
return { closed: ring.closed, points: ring.points.filter((_, i) => i !== index).map(clonePoint) };
}
/** Alt-click: a straight point made round (symmetric), and one with handles made straight again. */
export function withSmoothToggled(ring: VectorRing, index: number): VectorRing {
const point = ring.points[index];
if (!point) return cloneVectorRing(ring);
return withPointKind(ring, index, pointKind(point) === 'straight' ? 'symmetric' : 'straight');
}
/** The whole ring shifted – what dragging a shape does to each of its rings. */
export function ringMoved(ring: VectorRing, dx: number, dy: number): VectorRing {
return mapPoints(ring, (point) => ({ ...point, x: point.x + dx, y: point.y + dy }));
}
function mapPoints(ring: VectorRing, fn: (point: VectorPoint, index: number) => VectorPoint): VectorRing {
return { closed: ring.closed, points: ring.points.map((point, i) => clonePoint(fn(clonePoint(point), i))) };
}
/** A mode without a handle to steer is dropped, so a straight point reads the same however it got there. */
function clonePoint(point: VectorPoint): VectorPoint {
const copy = cloneVectorPoint(point);
if (!copy.in && !copy.out) delete copy.mode;
return copy;
}
function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {
const offset = { x: at.x - point.x, y: at.y - point.y };
if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side];
else point[side] = offset;
}