Files
feuerwerk/src/core/commands/vectorArtCommands.ts
T
adminandClaude Opus 5.5 056a622524
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s
Refactoring round 6: shared sheet canvas, fewer copies (#85)
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>
2026-09-23 06:57:43 +02:00

370 lines
13 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.
/**
* 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 { VectorPaint, VectorRing, VectorShape, VectorStroke } from '../model/Asset';
import type { Project } from '../model/Project';
import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { copyName } from '../util/names';
import { type Command, CommandError, MoveCommand } 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);
}
}
/** A copy of a shape right above it, on the same spot, with its outlines, paints and opacity. */
export class DuplicateVectorShapeCommand implements Command {
readonly label = 'Duplicate shape';
readonly shapeId = newId();
constructor(
private readonly assetId: string,
private readonly sourceId: string,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const index = asset.shapes.findIndex((shape) => shape.id === this.sourceId);
const source = asset.shapes[index];
if (!source) throw new CommandError(`Shape ${this.sourceId} not found`);
const copy: VectorShape = {
...source,
id: this.shapeId,
name: copyName(
asset.shapes.map((shape) => shape.name),
source.name,
),
rings: cloneRings(source.rings),
};
if (source.paint) copy.paint = clonePaint(source.paint);
if (source.stroke) copy.stroke = cloneStroke(source.stroke);
asset.shapes.splice(index + 1, 0, copy);
}
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]);
}
}
/** Takes several shapes out in one step – Delete on a selection of more than one (#187). */
export class RemoveVectorShapesCommand implements Command {
readonly label = 'Remove shapes';
private removed: Removed<VectorShape>[] = [];
constructor(
private readonly assetId: string,
private readonly shapeIds: readonly string[],
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
this.removed = removeWhere(asset.shapes, (shape) => this.shapeIds.includes(shape.id));
if (this.removed.length === 0) throw new CommandError('No such shape');
}
undo(project: Project): void {
restoreAt(requireVectorArt(project, this.assetId).shapes, this.removed);
}
}
const COMBINE_LABELS: Record<ShapeBoolean, string> = {
union: 'Union shapes',
subtract: 'Subtract shapes',
intersect: 'Intersect shapes',
exclude: 'Exclude shapes',
};
const NOTHING_LEFT: Record<ShapeBoolean, string> = {
union: 'The shapes cover nothing',
subtract: 'Nothing is left – the shapes above cover the lowest one completely',
intersect: 'The shapes have nothing in common',
exclude: 'The shapes cancel each other out completely',
};
/**
* Combines shapes into one (#187). The result takes the place, name and look of the lowest of them –
* the one the others are cut out of when subtracting – and the others go. It is an ordinary shape of
* corners afterwards; an undo is the way back to the shapes it was made of.
*/
export class CombineVectorShapesCommand implements Command {
readonly label: string;
private removed: Removed<VectorShape>[] = [];
private previous: VectorRing[] = [];
private resultId = '';
constructor(
private readonly assetId: string,
private readonly shapeIds: readonly string[],
private readonly op: ShapeBoolean,
) {
this.label = COMBINE_LABELS[op];
}
/** The shape that holds the result: the lowest of the combined ones. Empty until executed. */
get shapeId(): string {
return this.resultId;
}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const shapes = asset.shapes.filter((shape) => this.shapeIds.includes(shape.id));
if (shapes.length !== new Set(this.shapeIds).size) throw new CommandError('No such shape');
const blocked = combineBlocker(shapes);
if (blocked) throw new CommandError(blocked);
const rings = combinedRings(shapes, this.op);
if (rings.length === 0) throw new CommandError(NOTHING_LEFT[this.op]);
const base = shapes[0]!;
this.resultId = base.id;
this.previous = base.rings;
base.rings = rings;
this.removed = removeWhere(asset.shapes, (shape) => shape !== base && this.shapeIds.includes(shape.id));
}
undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
restoreAt(asset.shapes, this.removed);
requireVectorShape(project, this.assetId, this.resultId).rings = this.previous;
}
}
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 extends MoveCommand {
constructor(
private readonly assetId: string,
from: number,
to: number,
) {
super('Reorder shapes', from, to);
}
protected move(project: Project, from: number, to: number): void {
if (!moveItem(requireVectorArt(project, this.assetId).shapes, from, to))
throw new CommandError('No such shape');
}
}
/** Where the fill and the outline of a shape are painted from – what moves when the shape is moved. */
export interface ShapePaints {
paint?: VectorPaint;
strokePaint?: VectorStroke['paint'];
}
/**
* 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. A whole shape dragged hands over its
* paints too (#186), so a gradient travels with the shape instead of staying where it was.
*/
export class SetVectorShapeRingsCommand implements Command {
readonly label: string;
private previous: VectorRing[] = [];
private previousPaints: ShapePaints = {};
constructor(
private readonly assetId: string,
private readonly shapeId: string,
private readonly rings: readonly VectorRing[],
label = 'Edit shape',
private readonly paints: ShapePaints | null = null,
) {
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);
if (this.paints) {
this.previousPaints = paintsOf(shape);
applyPaints(shape, this.paints);
}
}
undo(project: Project): void {
const shape = requireVectorShape(project, this.assetId, this.shapeId);
shape.rings = this.previous;
if (this.paints) applyPaints(shape, this.previousPaints);
}
}
export function paintsOf(shape: VectorShape): ShapePaints {
return {
...(shape.paint ? { paint: clonePaint(shape.paint) } : {}),
...(shape.stroke?.paint ? { strokePaint: cloneGradient(shape.stroke.paint) } : {}),
};
}
function applyPaints(shape: VectorShape, paints: ShapePaints): void {
if (paints.paint) shape.paint = clonePaint(paints.paint);
else delete shape.paint;
if (!shape.stroke) return;
if (paints.strokePaint) shape.stroke.paint = cloneGradient(paints.strokePaint);
else delete shape.stroke.paint;
}
/**
* What `SetVectorShapeStyleCommand` writes; a field left out stays as it is, `null` removes it. A
* stroke brings its own paint along, as a whole.
*/
export interface ShapeStyle {
fill?: number;
opacity?: number;
paint?: VectorPaint | null;
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');
for (const paint of [this.style.paint, this.style.stroke?.paint])
if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS)
throw new CommandError('A gradient needs two colours');
this.previous = {
fill: shape.fill,
opacity: shape.opacity,
paint: shape.paint ? clonePaint(shape.paint) : null,
stroke: shape.stroke ? cloneStroke(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.paint !== undefined) {
if (style.paint) shape.paint = clonePaint(style.paint);
else delete shape.paint;
}
if (style.stroke !== undefined) {
if (style.stroke) shape.stroke = cloneStroke(style.stroke);
else delete shape.stroke;
}
}
function cloneStroke(stroke: VectorStroke): VectorStroke {
return {
color: stroke.color,
width: stroke.width,
...(stroke.paint ? { paint: cloneGradient(stroke.paint) } : {}),
};
}
function cloneRings(rings: readonly VectorRing[]): VectorRing[] {
return rings.map(cloneVectorRing);
}