Files
feuerwerk/tests/core/vectorart.test.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

401 lines
15 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.
import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
AddVectorShapeCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RenameVectorShapeCommand,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated';
import { findAsset, type Project } from '../../src/core/model/Project';
import { resolvePicture } from '../../src/core/model/texture';
import {
handleAt,
isDrawableRing,
isDrawableShape,
SHAPE_FILL_DEFAULT,
shapeExtent,
shapeOrderTarget,
VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY,
VECTOR_WIDTH_KEY,
vectorArtPicture,
vectorArtRevision,
vectorCanvasSize,
} from '../../src/core/model/vectorart';
import { makeProject, roundTrip } from '../helpers';
/** A square, and the same square with one point bent into a curve. */
function square(): VectorRing {
return {
closed: true,
points: [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
{ x: 90, y: 90 },
{ x: 10, y: 90 },
],
};
}
function drawing() {
const fixture = makeProject();
const create = new CreateAssetCommand('vectorart', 'Logo');
fixture.stack.execute(create);
const assetId = create.asset.id;
const art = (project: Project = fixture.project): VectorArtAsset =>
findAsset(project, assetId) as VectorArtAsset;
return { ...fixture, assetId, art };
}
describe('the vector art asset', () => {
it('is a picture, not something placed in a scene', () => {
expect(isPlaceableType('vectorart')).toBe(false);
});
it('starts empty on a canvas of its own', () => {
const f = drawing();
expect(f.art().shapes).toEqual([]);
expect(vectorCanvasSize(f.art().props)).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
});
});
it('reads a canvas that is missing, broken or absurd as one it can draw on', () => {
expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 });
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({
width: 256,
height: 256,
});
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({
width: VECTOR_CANVAS_MAX,
height: 64,
});
});
});
describe('what is worth drawing', () => {
it('needs three points closed and two open', () => {
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false);
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true);
expect(isDrawableShape({ id: 's', name: 'S', rings: [], fill: 0, opacity: 1 })).toBe(false);
});
it('measures a shape including the handles pulled off its points', () => {
const shape = {
id: 's',
name: 'S',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: -30, y: 5 } },
{ x: 90, y: 90 },
],
},
],
fill: 0,
opacity: 1,
};
expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 });
expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull();
expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 });
expect(shapeExtent({ ...shape, rings: [] })).toBeNull();
});
});
describe('the revision', () => {
it('changes with every pixel and with nothing else', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const first = vectorArtRevision(f.art());
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
expect(vectorArtRevision(f.art())).toBe(first);
// A point's type only says what the next drag does to its handles (#195).
const typed = square();
typed.points[0] = { ...typed.points[0]!, mode: 'smooth' };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [typed]));
expect(vectorArtRevision(f.art())).toBe(first);
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
const coloured = vectorArtRevision(f.art());
expect(coloured).not.toBe(first);
const moved = square();
moved.points[0] = { x: 11, y: 10 };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved]));
expect(vectorArtRevision(f.art())).not.toBe(coloured);
});
it('changes when the canvas is resized, because the picture is a different one', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
const before = vectorArtRevision(f.art());
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512));
expect(vectorArtRevision(f.art())).not.toBe(before);
});
it('is the same for the same drawing, however the object was built', () => {
const asset = (ring: VectorRing): VectorArtAsset => ({
id: 'a',
type: 'vectorart',
name: 'A',
props: {},
shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }],
});
// The same ring with its two keys set the other way round – what a project file gives back.
const built = { closed: true, points: square().points };
const loaded = { points: square().points, closed: true } as VectorRing;
expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built)));
});
it('survives a save and load: the same drawing is the same picture', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art()));
});
});
describe('the way into the picture path', () => {
let unregister: (() => void) | null = null;
afterEach(() => {
unregister?.();
unregister = null;
});
it('hands over the canvas and the revision, and only for vector art', () => {
const f = drawing();
expect(vectorArtPicture(f.art())).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
revision: vectorArtRevision(f.art()),
});
const other = new CreateAssetCommand('sprite', 'Box');
f.stack.execute(other);
expect(vectorArtPicture(other.asset)).toBeNull();
});
it('resolves to a generated picture of the canvas size once it is registered (#185)', () => {
const f = drawing();
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128));
expect(generatedPictureOf(f.art())).toBeNull();
unregister = registerPictureSource('vectorart', vectorArtPicture);
const ref = resolvePicture(f.project, f.assetId);
expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`);
expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT);
expect(ref?.sourceHeight).toBe(128);
});
});
describe('editing the shapes', () => {
it('numbers new shapes and takes them back out again', () => {
const f = drawing();
const first = new AddVectorShapeCommand(f.assetId);
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
f.stack.execute(first);
f.stack.execute(second);
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
f.stack.undo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']);
f.stack.redo();
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
});
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
const f = drawing();
const ids = [0, 1, 2].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]);
f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
// The last one may go: an empty drawing is a drawing.
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
expect(f.art().shapes).toEqual([]);
});
it('keeps the drawing order apart from the list order in no way at all', () => {
const f = drawing();
const ids = ['a', 'b', 'c'].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
// Last is on top, so "forward" is one step towards the end and "front" is the end.
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2);
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1);
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0);
// Already there, and not there at all.
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1);
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1);
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2));
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
f.stack.undo();
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
});
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const rings = [square()];
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true);
f.stack.execute(edit);
rings[0]!.points[1] = { x: -999, y: -999 };
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
f.stack.undo();
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false);
});
it('writes fill, opacity and outline one field at a time and takes each back', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x00ff00,
opacity: 4,
stroke: { color: 0, width: 2 },
}),
);
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
expect(f.art().shapes[0]!.stroke).toBeUndefined();
f.stack.undo();
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.undo();
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(f.art().shapes[0]!.stroke).toBeUndefined();
});
it('refuses an outline of no width and a shape of no name', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
expect(() =>
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }),
),
).toThrow(/wider than nothing/);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow(
/needs a name/,
);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow(
/not found/,
);
});
});
describe('what a project file may say', () => {
it('keeps a whole drawing across save and load', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: 4, y: 0 } },
{ x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' },
{ x: 50, y: 80, in: { x: -2, y: 1 }, out: { x: 2, y: -1 }, mode: 'symmetric' },
],
},
{
closed: false,
points: [
{ x: 20, y: 20 },
{ x: 30, y: 30 },
],
},
]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x334455,
opacity: 0.5,
stroke: { color: 0xffffff, width: 3 },
}),
);
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes);
});
it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
art.shapes = [
{
id: 'one',
name: 'One',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 }, mode: 'wobbly' },
{ x: 'far', y: 10 },
{ x: 30, y: 30 },
{ x: 50 },
],
},
{ points: [{ x: 0, y: 0 }] },
],
fill: 0x123456,
opacity: 7,
stroke: { color: 0xffffff, width: 0 },
},
// A doubled id would make one reference mean two shapes, as a doubled layer id does.
{ id: 'one', name: 'Twin', rings: [] },
{ name: 'Nameless', rings: [] },
'not a shape',
];
const loaded = f.art(roundTrip(f.project));
expect(loaded.shapes).toHaveLength(1);
const shape = loaded.shapes[0]!;
// The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195);
// the broken points fall away entirely.
expect(shape.rings[0]!.points).toEqual([
{ x: 10, y: 10, out: { x: 1, y: 2 } },
{ x: 30, y: 30 },
]);
// A ring of one point is kept – it is an outline someone is still drawing – and closed by default.
expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] });
expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 });
// An outline of no width is no outline.
expect(shape.stroke).toBeUndefined();
});
it('falls back to a fill and a name where the file says nothing usable', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
const shape = f.art(roundTrip(f.project)).shapes[0]!;
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
});
});