Files
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

293 lines
11 KiB
TypeScript
Raw Permalink 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.
/**
* The editing rules of a vector art ring (#129, stage 3). They are the gestures of the Vector art
* tab without a view: what the pointer grabs, what a drag writes, and what putting a point on a
* line does to the line.
*/
import { describe, expect, it } from 'vitest';
import type { VectorPointMode, VectorRing } from '../../src/core/model/Asset';
import {
insideRing,
nearestOnSegment,
pointKind,
pointOnSegment,
ringGripAt,
ringMoved,
ringOutline,
ringSegments,
withHandleMoved,
withoutPoint,
withPointKind,
withPointMoved,
withPointOnSegment,
withSmoothToggled,
} from '../../src/editor/polygonEdit';
/** A square of 100, corners only. */
function square(): VectorRing {
return {
closed: true,
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
};
}
/** Two points with a handle each, bulging upwards between them. */
function arc(): VectorRing {
return {
closed: false,
points: [
{ x: 0, y: 0, out: { x: 0, y: -40 } },
{ x: 100, y: 0, in: { x: 0, y: -40 } },
],
};
}
/** One point at (50, 50) with a handle 10 to either side, in the given mode. */
function handled(mode: VectorPointMode): VectorRing {
return { closed: false, points: [{ x: 50, y: 50, in: { x: -10, y: 0 }, out: { x: 10, y: 0 }, mode }] };
}
describe('what the pointer grabs', () => {
it('takes the nearest point', () => {
expect(ringGripAt(square(), { x: 98, y: 3 }, 8)).toEqual({ kind: 'point', index: 1 });
});
it('offers nothing further away than the tolerance', () => {
expect(ringGripAt(square(), { x: 50, y: 50 }, 8)).toBeNull();
});
it('gives a handle before the point it belongs to', () => {
const ring = arc();
// The handle of point 0 sits at (0, −40); its own point is 40 away, so both are in reach at 50.
expect(ringGripAt(ring, { x: 0, y: -38 }, 50)).toEqual({ kind: 'handle', index: 0, side: 'out' });
});
it('only offers the handles that are drawn', () => {
const ring = arc();
expect(ringGripAt(ring, { x: 0, y: -38 }, 50, [1])).toEqual({ kind: 'point', index: 0 });
});
it('gives the segment under the pointer, with how far along it lies', () => {
const grip = ringGripAt(square(), { x: 50, y: 2 }, 8);
expect(grip?.kind).toBe('segment');
if (grip?.kind !== 'segment') throw new Error('no segment');
expect(grip.index).toBe(0);
expect(grip.at).toEqual({ x: 50, y: 0 });
expect(grip.t).toBeCloseTo(0.5, 6);
});
it('finds the curve of a curved segment, not its chord', () => {
// The chord runs along y = 0; the curve reaches up to about y = −30 in its middle.
const hit = nearestOnSegment(arc().points[0]!, arc().points[1]!, { x: 50, y: -30 });
expect(hit.distance).toBeLessThan(1);
expect(hit.t).toBeCloseTo(0.5, 1);
});
it('walks the way back to the first point only on a closed ring', () => {
expect(ringSegments(square()).length).toBe(4);
expect(ringSegments({ ...square(), closed: false }).length).toBe(3);
});
});
describe('dragging', () => {
it('moves a point and takes its handles along', () => {
const moved = withPointMoved(arc(), 0, { x: 10, y: 10 });
expect(moved.points[0]).toEqual({ x: 10, y: 10, out: { x: 0, y: -40 } });
});
it('writes a handle as an offset and mirrors the other one on a symmetric point', () => {
const bent = withHandleMoved(handled('symmetric'), 0, 'out', { x: 70, y: 60 });
expect(bent.points[0]!.out).toEqual({ x: 20, y: 10 });
expect(bent.points[0]!.in).toEqual({ x: -20, y: -10 });
});
it('leaves the other handle alone on a corner (#195)', () => {
const bent = withHandleMoved(handled('corner'), 0, 'out', { x: 50, y: 20 });
expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 });
expect(bent.points[0]!.in).toEqual({ x: -10, y: 0 });
});
it('turns the other handle round at its own length on a smooth point (#195)', () => {
// `in` is 10 long; `out` is pulled 30 up, so `in` has to point 10 down.
const bent = withHandleMoved(handled('smooth'), 0, 'out', { x: 50, y: 20 });
expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 });
expect(bent.points[0]!.in!.x).toBeCloseTo(0, 9);
expect(bent.points[0]!.in!.y).toBeCloseTo(10, 9);
});
it('grows the mirrored handle the pen asks for, so a pen curve has two (#195)', () => {
const fresh: VectorRing = { closed: false, points: [{ x: 50, y: 50 }] };
const pulled = withHandleMoved(fresh, 0, 'out', { x: 70, y: 50 }, 'symmetric');
const point = pulled.points[0]!;
expect(point.mode).toBe('symmetric');
expect([point.out!.x, point.out!.y, point.in!.x, point.in!.y].map((v) => v + 0)).toEqual([20, 0, -20, 0]);
});
it('takes a handle away when it is dropped on its own point', () => {
const straightened = withHandleMoved(arc(), 0, 'out', { x: 0, y: 0 });
expect(straightened.points[0]!.out).toBeUndefined();
});
it('takes the mirror along on a symmetric point, which leaves it straight', () => {
const straightened = withHandleMoved(handled('symmetric'), 0, 'out', { x: 50, y: 50 });
expect(straightened.points[0]).toEqual({ x: 50, y: 50 });
});
it('moves a whole ring', () => {
const moved = ringMoved(square(), 10, -5);
expect(moved.points.map((p) => [p.x, p.y])).toEqual([
[10, -5],
[110, -5],
[110, 95],
[10, 95],
]);
});
});
describe('adding and taking away', () => {
it('puts a corner on a straight segment', () => {
const next = withPointOnSegment(square(), 0, 0.5);
expect(next.points.length).toBe(5);
expect(next.points[1]).toEqual({ x: 50, y: 0 });
});
it('splits a curved segment without changing the curve', () => {
const before = arc();
const after = withPointOnSegment(before, 0, 0.5);
expect(after.points.length).toBe(3);
// The two halves together have to draw what the one segment drew: measured at five places.
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const was = pointOnSegment(before.points[0]!, before.points[1]!, t);
const half = t <= 0.5 ? 0 : 1;
const now = pointOnSegment(after.points[half]!, after.points[half + 1]!, (t - half * 0.5) * 2);
expect(now.x).toBeCloseTo(was.x, 6);
expect(now.y).toBeCloseTo(was.y, 6);
}
});
it('takes a point away, down to the last one', () => {
const three = withoutPoint(square(), 3);
expect(three?.points.length).toBe(3);
expect(withoutPoint({ closed: false, points: [{ x: 0, y: 0 }] }, 0)).toBeNull();
});
it('makes a corner round and a round point square again', () => {
// Corner 1 lies between (0, 0) and (100, 100), so its handles point along that line.
const round = withSmoothToggled(square(), 1);
expect(round.points[1]!.out).toEqual({ x: 100 / 3, y: 100 / 3 });
expect(round.points[1]!.in).toEqual({ x: -100 / 3, y: -100 / 3 });
expect(withSmoothToggled(round, 1).points[1]).toEqual({ x: 100, y: 0 });
});
});
describe('the four point types (#195)', () => {
it('reads a point without handles as straight and one without a mode as a corner', () => {
expect(pointKind({ x: 0, y: 0 })).toBe('straight');
expect(pointKind({ x: 0, y: 0, mode: 'smooth' })).toBe('straight');
expect(pointKind(arc().points[0]!)).toBe('corner');
expect(pointKind(handled('symmetric').points[0]!)).toBe('symmetric');
});
it('makes a point straight by taking both handles and the mode away', () => {
expect(withPointKind(handled('smooth'), 0, 'straight').points[0]).toEqual({ x: 50, y: 50 });
});
it('grows handles on a straight point that becomes smooth', () => {
const smooth = withPointKind(square(), 1, 'smooth').points[1]!;
expect(smooth.mode).toBe('smooth');
expect(smooth.out).toEqual({ x: 100 / 3, y: 100 / 3 });
expect(smooth.in).toEqual({ x: -100 / 3, y: -100 / 3 });
});
it('keeps a corner’s handles where they are', () => {
const bent: VectorRing = {
closed: false,
points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }],
};
expect(withPointKind(bent, 0, 'corner').points[0]).toEqual(bent.points[0]);
});
it('lines the handles up for smooth and keeps each length', () => {
// `in` points left (10 long), `out` up (20 long): the bisector runs up and to the right.
const bent: VectorRing = {
closed: false,
points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }],
};
const point = withPointKind(bent, 0, 'smooth').points[0]!;
const s = Math.SQRT1_2;
expect(point.out!.x).toBeCloseTo(20 * s, 9);
expect(point.out!.y).toBeCloseTo(-20 * s, 9);
expect(point.in!.x).toBeCloseTo(-10 * s, 9);
expect(point.in!.y).toBeCloseTo(10 * s, 9);
});
it('gives both handles their mean length for symmetric', () => {
const bent: VectorRing = {
closed: false,
points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 30, y: 0 } }],
};
const point = withPointKind(bent, 0, 'symmetric').points[0]!;
expect(point.mode).toBe('symmetric');
expect([point.in!.x, point.in!.y, point.out!.x, point.out!.y].map((v) => v + 0)).toEqual([-20, 0, 20, 0]);
});
it('grows the missing second handle opposite the first', () => {
const point = withPointKind(arc(), 0, 'smooth').points[0]!;
expect(point.out).toEqual({ x: 0, y: -40 });
expect(point.in!.x).toBeCloseTo(0, 9);
expect(point.in!.y).toBeCloseTo(40, 9);
});
it('does not reach round an open line for the neighbour of its end', () => {
// The first point of an open L has one neighbour; wrapping to the last would tilt its handles.
const line: VectorRing = {
closed: false,
points: [
{ x: 0, y: 0 },
{ x: 90, y: 0 },
{ x: 90, y: 90 },
],
};
expect(withPointKind(line, 0, 'smooth').points[0]!.out).toEqual({ x: 30, y: 0 });
});
it('splits a symmetric curve into smooth points – the halves are no longer the same length', () => {
const curve: VectorRing = {
closed: false,
points: [
{ x: 0, y: 0, in: { x: 0, y: 30 }, out: { x: 0, y: -30 }, mode: 'symmetric' },
{ x: 100, y: 0, in: { x: 0, y: -30 }, out: { x: 0, y: 30 }, mode: 'symmetric' },
],
};
const split = withPointOnSegment(curve, 0, 0.25);
expect(split.points.map((p) => p.mode)).toEqual(['smooth', 'smooth', 'smooth']);
});
it('keeps a straight point on a straight segment free of a mode', () => {
expect(withPointOnSegment(square(), 0, 0.5).points[1]).toEqual({ x: 50, y: 0 });
});
});
describe('the inside of a ring', () => {
it('holds what lies in it and not what lies beside it', () => {
expect(insideRing(square(), { x: 50, y: 50 })).toBe(true);
expect(insideRing(square(), { x: 150, y: 50 })).toBe(false);
});
it('is empty while the ring is open – a line encloses nothing', () => {
expect(insideRing({ ...square(), closed: false }, { x: 50, y: 50 })).toBe(false);
});
it('follows a curve rather than the chord it spans', () => {
// A closed arc bulging up to y = −30: a point above the chord is inside it, one below is not.
const bulge: VectorRing = { closed: true, points: arc().points };
expect(insideRing(bulge, { x: 50, y: -10 })).toBe(true);
expect(insideRing(bulge, { x: 50, y: 10 })).toBe(false);
expect(ringOutline(bulge).length).toBeGreaterThan(2);
});
});