/** * 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 | 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[] = []; 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 = { union: 'Union shapes', subtract: 'Subtract shapes', intersect: 'Intersect shapes', exclude: 'Exclude shapes', }; const NOTHING_LEFT: Record = { 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[] = []; 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); }