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

+62 -9
View File
@@ -16,7 +16,7 @@ import {
RemoveVectorShapeCommand,
SetVectorShapeRingsCommand,
} from '../../core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorRing, VectorShape } from '../../core/model/Asset';
import type { VectorArtAsset, VectorPoint, VectorRing, VectorShape } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
@@ -35,12 +35,15 @@ import {
} from '../../ui';
import {
insideRing,
type PointKind,
pointKind,
type RingGrip,
ringGripAt,
ringMoved,
ringOutline,
withHandleMoved,
withPointMoved,
withPointKind,
withPointOnSegment,
withoutPoint,
withSmoothToggled,
@@ -99,6 +102,10 @@ export class VectorArtCanvas extends Widget {
private drag: ShapeDrag | null = null;
/** The ring the pen is drawing into; empty while no line is open. */
private pen = { shapeId: '', ring: -1 };
/** The point last pressed or put down – the one the header's point type is about (#195). */
private picked: { shapeId: string; ring: number; index: number } | null = null;
/** Told whenever `picked` changes, so the header can show that point's type. */
onPointPicked: () => void = () => {};
/** This canvas's slot in the frame queue: one pending preview at a time, see `preview()`. */
private readonly frameKey = `vectorart:${(instances += 1)}`;
@@ -118,10 +125,14 @@ export class VectorArtCanvas extends Widget {
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('selection', () => {
if (this.picked && this.picked.shapeId !== this.selectedShapeId()) this.pick(null);
this.redraw();
});
state.on('load', () => {
this.needsFit = true;
this.endPen();
this.pick(null);
this.redraw();
});
}
@@ -135,6 +146,7 @@ export class VectorArtCanvas extends Widget {
this.assetId = assetId;
this.needsFit = true;
this.endPen();
this.pick(null);
this.redraw();
}
@@ -184,6 +196,35 @@ export class VectorArtCanvas extends Widget {
return this.pen.ring >= 0;
}
/** The type of the picked point; null while no point is picked or it has gone since (an undo). */
get pickedKind(): PointKind | null {
const point = this.pickedPoint();
return point ? pointKind(point) : null;
}
/** Turns the picked point into `kind` – one undo step, as every other edit of a ring. */
setPickedKind(kind: PointKind): void {
const picked = this.picked;
const shape = picked && this.shape(picked.shapeId);
const ring = shape?.rings[picked?.ring ?? -1];
if (!picked || !shape || !ring?.points[picked.index]) return;
const rings = this.ringsWith(shape, picked.ring, withPointKind(ring, picked.index, kind));
if (SetVectorShapeRingsCommand.changes(shape, rings))
this.state.execute(new SetVectorShapeRingsCommand(this.assetId, shape.id, rings, 'Point type'));
}
private pickedPoint(): VectorPoint | undefined {
const picked = this.picked;
return picked ? this.shape(picked.shapeId)?.rings[picked.ring]?.points[picked.index] : undefined;
}
private pick(picked: { shapeId: string; ring: number; index: number } | null): void {
const was = this.picked;
this.picked = picked;
if (was?.shapeId !== picked?.shapeId || was?.ring !== picked?.ring || was?.index !== picked?.index)
this.onPointPicked();
}
/** Where a canvas pixel sits on screen; the check scripts aim their drags with it. */
canvasToScreen(x: number, y: number): Vec2 {
const local = imageToLocal(this.view, x, y);
@@ -280,6 +321,7 @@ export class VectorArtCanvas extends Widget {
const shape = this.shape(shapeId);
if (!shape) return;
const rings = ring ? this.ringsWith(shape, ringIndex, ring) : shape.rings.map(cloneVectorRing);
this.pick({ shapeId, ring: ringIndex, index });
this.drag = {
kind: 'pen',
shapeId,
@@ -322,6 +364,7 @@ export class VectorArtCanvas extends Widget {
for (let i = asset.shapes.length - 1; i >= 0; i--) {
const shape = asset.shapes[i]!;
if (!shape.rings.some((ring) => insideRing(ring, at))) continue;
this.pick(null);
if (shape.id !== selected?.id)
this.state.select({ kind: 'shape', assetId: asset.id, shapeId: shape.id });
this.drag = {
@@ -339,6 +382,7 @@ export class VectorArtCanvas extends Widget {
};
return;
}
this.pick(null);
this.state.select({ kind: 'asset', assetId: this.assetId });
this.redraw();
}
@@ -353,12 +397,15 @@ export class VectorArtCanvas extends Widget {
local: Vec2,
e: FederatedPointerEvent,
): void {
const index = grip.kind === 'segment' ? grip.index + 1 : grip.index;
this.pick({ shapeId: shape.id, ring: ringIndex, index });
if (grip.kind === 'point') {
if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) {
const next = withoutPoint(ring, grip.index);
const rings = next
? this.ringsWith(shape, ringIndex, next)
: shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing);
this.pick(null);
this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point'));
return;
}
@@ -370,7 +417,6 @@ export class VectorArtCanvas extends Widget {
}
const ringNow =
grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring);
const index = grip.kind === 'segment' ? grip.index + 1 : grip.index;
this.drag = {
kind: grip.kind === 'handle' ? 'handle' : 'point',
shapeId: shape.id,
@@ -416,8 +462,13 @@ export class VectorArtCanvas extends Widget {
} else if (ring && drag.kind === 'handle') {
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, drag.side, to));
} else if (ring && drag.kind === 'pen') {
// The pen pulls both handles at once: the one it drags towards, the other mirrored behind it.
drag.rings = this.replace(drag.rings, drag.ring, withHandleMoved(ring, drag.index, 'out', to));
// The pen pulls both handles at once: the one it drags towards, the other mirrored behind it –
// a symmetric point, which is what a drag with a pen draws everywhere else too (#195).
drag.rings = this.replace(
drag.rings,
drag.ring,
withHandleMoved(ring, drag.index, 'out', to, 'symmetric'),
);
} else if (ring) {
drag.rings = this.replace(drag.rings, drag.ring, withPointMoved(ring, drag.index, this.snapped(to)));
}
@@ -562,12 +613,14 @@ export class VectorArtCanvas extends Widget {
private drawShape(shape: VectorShape | undefined): void {
if (!shape) return;
const half = POINT_SIZE / 2;
for (const ring of shape.rings) {
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
for (const [ringIndex, ring] of shape.rings.entries()) {
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
if (screen.length >= 2)
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
for (const point of ring.points) {
for (const [index, point] of ring.points.entries()) {
const at = imageToLocal(this.view, point.x, point.y);
const isPicked = picked?.ring === ringIndex && picked.index === index;
for (const side of ['in', 'out'] as const) {
const handle = handleAt(point, side);
if (!handle) continue;
@@ -583,8 +636,8 @@ export class VectorArtCanvas extends Widget {
}
this.overlay
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color: Theme.colors.accent });
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
}
}
}