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:
adminandClaude Opus 5 committed 2026-09-20 20:29:35 +02:00
1 parent cb1804dcf2
commit bdb3f066cf
13 files changed
+975 -3

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
/**
* Editing the shapes of a vector art asset (#129). The canvas size and the filter are ordinary
* properties and go through `SetAssetPropertyCommand`; the shapes are intrinsic data, as a
* tilemap's layers are, so they have commands of their own.
*
* One gesture is one command: dragging a point writes the whole ring list once, because the pointer
* decides where a drag ends and an undo has to take the drag back, not its last pixel. Nothing here
* bumps a revision – the picture is fingerprinted from the shapes themselves (see
* `model/vectorart.ts`), so a command that edits a shape cannot forget to say that it did.
*/
import { newId } from '../ids';
import type { VectorRing, VectorShape, VectorStroke } from '../model/Asset';
import type { Project } from '../model/Project';
import { SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorShape } from './require';
export class AddVectorShapeCommand implements Command {
readonly label = 'Add shape';
readonly shapeId = newId();
/** `rings` are what the pen tool drew; a shape added from the list starts without a ring. */
constructor(
private readonly assetId: string,
private readonly rings: readonly VectorRing[] = [],
private readonly fill = SHAPE_FILL_DEFAULT,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const name = nextNumberedName(
asset.shapes.map((shape) => shape.name),
'Shape',
);
asset.shapes.push({
id: this.shapeId,
name,
rings: cloneRings(this.rings),
fill: this.fill,
opacity: 1,
});
}
undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
asset.shapes = removeById(asset.shapes, this.shapeId);
}
}
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */
export class RemoveVectorShapeCommand implements Command {
readonly label = 'Remove shape';
private removed: Removed<VectorShape> | undefined;
constructor(
private readonly assetId: string,
private readonly shapeId: string,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const removed = removeFirst(asset.shapes, (shape) => shape.id === this.shapeId);
if (!removed) throw new CommandError(`Shape ${this.shapeId} not found`);
this.removed = removed;
}
undo(project: Project): void {
if (this.removed) restoreAt(requireVectorArt(project, this.assetId).shapes, [this.removed]);
}
}
export class RenameVectorShapeCommand implements Command {
readonly label = 'Rename shape';
private previous = '';
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId);
const name = this.name.trim();
if (!name) throw new CommandError('A shape needs a name');
this.previous = shape.name;
shape.name = name;
}
undo(project: Project): void {
requireVectorShape(project, this.assetId, this.shapeId).name = this.previous;
}
}
/** Moves a shape in the drawing order; the last shape in the list is drawn on top. */
export class MoveVectorShapeCommand implements Command {
readonly label = 'Reorder shapes';
constructor(
private readonly assetId: string,
private readonly from: number,
private readonly to: number,
) {}
execute(project: Project): void {
this.move(project, this.from, this.to);
}
undo(project: Project): void {
this.move(project, this.to, this.from);
}
private move(project: Project, from: number, to: number): void {
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
throw new CommandError('No such shape');
}
}
/**
* Writes the outlines of one shape – a point dragged, a point inserted on an edge, a handle pulled,
* a ring closed. The rings are copied in and out, so the editor may hand over the list it was
* dragging on without the undo step pointing at live data.
*/
export class SetVectorShapeRingsCommand implements Command {
readonly label: string;
private previous: VectorRing[] = [];
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly rings: readonly VectorRing[],
label = 'Edit shape',
) {
this.label = label;
}
/** Nothing to do when the shape already reads exactly like this – no empty undo step. */
static changes(shape: VectorShape, rings: readonly VectorRing[]): boolean {
return JSON.stringify(shape.rings) !== JSON.stringify(rings);
}
execute(project: Project): void {
// Any outline may be written, a half-drawn one included: what is too short to draw is passed
// over by the rasteriser (see `isDrawableRing`) instead of being refused here.
const shape = requireVectorShape(project, this.assetId, this.shapeId);
this.previous = shape.rings;
shape.rings = cloneRings(this.rings);
}
undo(project: Project): void {
requireVectorShape(project, this.assetId, this.shapeId).rings = this.previous;
}
}
/** What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `stroke: null` removes it. */
export interface ShapeStyle {
fill?: number;
opacity?: number;
stroke?: VectorStroke | null;
}
/** Fill colour, opacity and outline of one shape – the rows the inspector shows for it (answer 8). */
export class SetVectorShapeStyleCommand implements Command {
readonly label: string;
private previous: ShapeStyle = {};
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly style: ShapeStyle,
label = 'Set shape style',
) {
this.label = label;
}
execute(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId);
if (this.style.opacity !== undefined && !Number.isFinite(this.style.opacity))
throw new CommandError('An opacity is a number');
if (this.style.stroke && !(this.style.stroke.width > 0))
throw new CommandError('An outline is wider than nothing');
this.previous = { fill: shape.fill, opacity: shape.opacity, stroke: shape.stroke ?? null };
applyStyle(shape, this.style);
}
undo(project: Project): void {
applyStyle(requireVectorShape(project, this.assetId, this.shapeId), this.previous);
}
}
function applyStyle(shape: VectorShape, style: ShapeStyle): void {
if (style.fill !== undefined) shape.fill = style.fill;
if (style.opacity !== undefined) shape.opacity = Math.max(0, Math.min(1, style.opacity));
if (style.stroke !== undefined) {
if (style.stroke) shape.stroke = { ...style.stroke };
else delete shape.stroke;
}
}
/** A deep copy: rings, their points and the handles on them are all plain data. */
function cloneRings(rings: readonly VectorRing[]): VectorRing[] {
return rings.map((ring) => ({
closed: ring.closed,
points: ring.points.map((point) => ({
x: point.x,
y: point.y,
...(point.in ? { in: { ...point.in } } : {}),
...(point.out ? { out: { ...point.out } } : {}),
})),
}));
}