Vector art points have four types and the pen draws whole curves (#195)
game-maker / gate (push) Successful in 2m10s
game-maker / suite (push) Successful in 7m29s

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>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 06:51:35 +02:00
1 parent 90627ccf47
commit 3ff7e91dbf
11 files changed
+487 -65

No files matched your search

+106 -32
View File
@@ -8,11 +8,11 @@
* 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, VectorRing } from '../core/model/Asset';
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 { cloneVectorRing, handleAt } from '../core/model/vectorart';
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 };
@@ -196,29 +196,113 @@ export function withPointMoved(ring: VectorRing, index: number, to: Vec2): Vecto
}
/**
* A handle pulled to `to`, in canvas pixels. The other handle of the point follows mirrored while it
* exists – that is what makes a point smooth – and a handle dropped back onto its point is taken
* away, which turns the point back into a corner.
* 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,
mirror = true,
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';
if (Math.hypot(offset.x, offset.y) < 1e-6) delete next[side];
else next[side] = offset;
if (mirror && next[other]) next[other] = { x: -offset.x, y: -offset.y };
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
@@ -239,11 +323,16 @@ export function withPointOnSegment(ring: VectorRing, index: number, t: number):
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(points[(index + 1) % ring.points.length]!, 'in', c);
inserted = { x: at.x, y: at.y };
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 };
@@ -255,24 +344,11 @@ export function withoutPoint(ring: VectorRing, index: number): VectorRing | null
return { closed: ring.closed, points: ring.points.filter((_, i) => i !== index).map(clonePoint) };
}
/**
* A corner made round and a round point made a corner again (Alt-click). The handles a corner gets
* point along the line between its neighbours, at a third of the way to each – the shape one expects
* from a smooth point, without asking the user to pull two handles first.
*/
/** 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);
if (point.in || point.out) return mapPoints(ring, (p, i) => (i === index ? { x: p.x, y: p.y } : p));
const count = ring.points.length;
const before = ring.points[(index - 1 + count) % count] ?? point;
const after = 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 cloneVectorRing(ring);
return mapPoints(ring, (p, i) =>
i === index ? { ...p, in: { x: -dx, y: -dy }, out: { x: dx, y: dy } } : p,
);
return withPointKind(ring, index, pointKind(point) === 'straight' ? 'symmetric' : 'straight');
}
/** The whole ring shifted – what dragging a shape does to each of its rings. */
@@ -284,13 +360,11 @@ function mapPoints(ring: VectorRing, fn: (point: VectorPoint, index: number) =>
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 {
return {
x: point.x,
y: point.y,
...(point.in ? { in: { ...point.in } } : {}),
...(point.out ? { out: { ...point.out } } : {}),
};
const copy = cloneVectorPoint(point);
if (!copy.in && !copy.out) delete copy.mode;
return copy;
}
function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {