Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
16 KiB
TypeScript
423 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
DuplicateVectorShapeCommand,
|
||
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,
|
||
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);
|
||
});
|
||
|
||
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('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
|
||
const top = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(top);
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 }));
|
||
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
|
||
f.stack.execute(copy);
|
||
f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId));
|
||
const shapes = f.art().shapes;
|
||
expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']);
|
||
expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
|
||
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
|
||
// Its own outline: a point dragged on the copy leaves the original where it is.
|
||
expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]);
|
||
|
||
f.stack.undo();
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
|
||
});
|
||
|
||
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: [] }]);
|
||
});
|
||
});
|