Files
feuerwerk/src/editor/polygonEdit.ts
T
adminandClaude Opus 5 3ff7e91dbf
game-maker / gate (push) Successful in 2m10s
game-maker / suite (push) Successful in 7m29s
Vector art points have four types and the pen draws whole curves (#195)
The pen only pulled out the outgoing handle of a new point, because
the opposite handle was mirrored only if it already existed. The
first segment of every dragged point was therefore half a curve.
Beyond that bug, a point had no way to say how its handles relate.

- A vector point has an optional mode: straight, corner, smooth or
  symmetric. Missing means corner. The mode is not part of the
  fingerprint, because it changes no pixel, only the next drag.
- Dragging a handle follows the mode. Corner leaves the other handle
  alone, smooth turns it along and keeps its length, symmetric
  mirrors it exactly.
- The pen pulls out both handles mirrored and marks the point
  symmetric. A plain click still sets a point without handles.
- The tab header has a "Point: ..." menu for the last clicked or set
  point. That point is drawn filled. Changing its type is one undo
  step. Text instead of four icons, because the UI kit has no
  tooltips.
- Switching a point without handles to a curved type first grows
  handles along its neighbours. Smooth and symmetric line the
  handles up and keep the rough direction of the curve.
- A point inserted on a curve is smooth. Symmetric neighbours become
  smooth, because the split shortens one of their handles.
- Alt-click still toggles between straight and symmetric.

Found on the way: the old Alt rounding took the point at the other
end of an open line as a neighbour, so its handles came out skewed.
Fixed and held by a unit test.

Existing points with handles now count as corner. Before, every drag
mirrored. No pixel changes, only the next drag behaves differently.

Tests:
- ./check.sh: 129 files, 1917 tests, exit 0 (before 1903)
- GM_STEPS=47, freshly seeded: ALL PASSED, 39 checks (before 32)
- Check sum 1195 in README and wiki is computed (1188 + 7), CI
  measures it
- Counter-checks: pen without 'symmetric' fails 3 checks, no smooth
  branch fails 1 unit test and 1 e2e check

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 06:51:35 +02:00

379 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, press on an edge to put a
* corner there, double-click a corner to take it away. Pure, so the rules are testable without a view.
*
* 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 } 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 };
const u = 1 - t;
return {
x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
};
}
/** 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 a = lerp(from, c1, t);
const b = lerp(c1, c2, t);
const c = lerp(c2, to, t);
const d = lerp(a, b, t);
const e = lerp(b, c, t);
const next = points[(index + 1) % ring.points.length]!;
setHandle(points[index]!, 'out', a);
setHandle(next, 'in', c);
// 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', d);
setHandle(inserted, 'out', e);
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;
}
function lerp(a: Vec2, b: Vec2, t: number): Vec2 {
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}