The asset type "vector art" exists as a model, which is stage 1 of #129 and shows nothing in the browser yet on purpose: a vectorart asset is not placeable, sits in the + menu and in the asset panel with its own icon (a curve between two points) and has no tab, so it behaves like a font - selectable, its rows in the inspector, a double click leading nowhere - because the tab is stage 3 and half an asset would only be in the way; the data model in Asset.ts is a shape built from rings (even-odd, so a shape may have holes, answer 6 in the issue), a ring being points with optional bezier handles in/out stored as an offset from the point and a closed flag (an open path is stroke only, answer 4), plus fill colour, opacity and an optional stroke (answer 5) - the two handles are kept apart instead of mirrored, because what "smooth" means is the editor's business and not the model's; canvas width, height and the filter are properties, so they get an inspector row and undo like any other, while the shapes are intrinsic data like the frames of a texture; the revision that #185 hands to the picture path is a fingerprint of what is drawn, not a counter, which is exactly why that key took a string token - no command can forget to bump it - and the name of a shape is deliberately left out of it, renaming changing no pixel; six undo commands in the new vectorArtCommands.ts (add a shape numbered in order, remove one which comes back to its old place - unlike a tilemap layer the last one may go too -, rename, move, write the rings as one step per gesture with the list copied, and set the style where stroke: null takes it away) and the pure function shapeOrderTarget for the four buttons of the planned inspector list (one forward, to front, one back, to back), which also serves dragging in that list; loading from a project file is strict - a point without numbers falls away, a broken handle leaves a corner behind, duplicate shape ids fall away like duplicate layer ids, a stroke of width 0 is no stroke - while a ring with a single point, which is what someone is drawing right now, stays; three findings on the way: JSON.stringify as the fingerprint was wrong because the same drawing came back from a project file as {points, closed} and was built as {closed, points}, so a reload would have rasterized the same picture under a new key (the fingerprint writes its fields in a fixed order now and a test measures that case), toNumber in serialize.ts turns any nonsense into 0 so the Number.isFinite checks ran into nothing and {x: 'far'} became the left edge instead of vanishing (shapes read numbers strictly via asFinite, 0 being a place on the canvas here), and "Vector art" would have been both a menu entry and the panel heading, which an existing test catches because a click by text would hit the heading - the group is called "Vector drawings" now while the menu entry stays "Vector art"; 20 new unit tests in tests/core/vectorart.test.ts and three counter-checks hold: the fingerprint back on JSON.stringify fails 2, reading the numbers loosely again fails 4, and not copying the ring list fails 1; no e2e check and the check sum stays 1157, only module 04 counting 19 asset types instead of 18 (#129)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb1804dcf2
commit
bdb3f066cf
13 files changed
+975
-3
No files matched your search
@@ -0,0 +1,393 @@
|
||||
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);
|
||||
|
||||
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 },
|
||||
{ x: 50, y: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 } },
|
||||
{ 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; 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: [] }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user