/** * Every rectangle passes `normalizeFrameRect` here rather than at the call site: frames come from * dragging in a zoomed view, so callers produce fractional and inverted rectangles routinely. */ import { newId } from '../ids'; import type { PictureCut, TextureAsset, TextureFrame } from '../model/Asset'; import { type CutTarget, findCut, type FoundCut } from '../model/cutTarget'; import type { Project } from '../model/Project'; import type { Vec2 } from '../model/Properties'; import { type CutSlice, frameRect, type FrameRect, nextFrameNames, normalizeFrameRect, rawCutSlice, frameCollision, restoreCutSlice, setCutSlice, setCutSortLine, setFrameCollision, setFrameRect, sliceFrames, type SliceOptions, uniqueFrameName, } from '../model/texture'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireAsset } from './require'; const DEFAULT_NAME = 'Frame'; function requireTexture(project: Project, assetId: string): TextureAsset { const asset = requireAsset(project, assetId); if (asset.type !== 'texture') throw new CommandError(`${asset.name} is not a texture`); return asset; } function requireFrame(asset: TextureAsset, frameId: string): TextureFrame { const frame = asset.frames.find((f) => f.id === frameId); if (!frame) throw new CommandError(`Frame ${frameId} not found in ${asset.name}`); return frame; } /** `frameId` is known before execution, so the caller can select the new frame right after executing. */ export class AddFrameCommand implements Command { readonly label = 'Add frame'; readonly frameId = newId(); constructor( private readonly assetId: string, private readonly rect: FrameRect, private readonly name = DEFAULT_NAME, ) {} execute(project: Project): void { const asset = requireTexture(project, this.assetId); asset.frames.push({ id: this.frameId, // Resolved per execution: a redo has to fit the names that exist when it runs. name: uniqueFrameName(asset, this.name), ...normalizeFrameRect(this.rect, asset.width, asset.height), }); } undo(project: Project): void { const asset = requireTexture(project, this.assetId); asset.frames = removeById(asset.frames, this.frameId); } } /** * One command per drag, not per pointer move. The rectangle to revert to is read from the model at * execution time, so a caller that previews a drag in place must restore the start rect before executing. */ export class SetFrameRectCommand implements Command { readonly label = 'Move frame'; private previous: FrameRect = { x: 0, y: 0, w: 0, h: 0 }; constructor( private readonly assetId: string, private readonly frameId: string, private readonly rect: FrameRect, ) {} execute(project: Project): void { const asset = requireTexture(project, this.assetId); const frame = requireFrame(asset, this.frameId); this.previous = frameRect(frame); setFrameRect(frame, normalizeFrameRect(this.rect, asset.width, asset.height)); } undo(project: Project): void { const asset = requireTexture(project, this.assetId); // Restored verbatim rather than normalized again: undo owes the state that was there. setFrameRect(requireFrame(asset, this.frameId), this.previous); } } /** A texture frame or a drawing as a whole (#308), with the reason when it is neither. */ function requireCut(project: Project, target: CutTarget): FoundCut { if (target.frameId !== undefined) requireFrame(requireTexture(project, target.assetId), target.frameId); const found = findCut(project, target); if (!found) throw new CommandError(`${requireAsset(project, target.assetId).name} cannot be sliced`); return found; } /** * The four borders and the fill of one picture (#41), in one command: dragging a border line and * picking a fill are the same edit to the reader of an undo list, and splitting them would make * "undo" take back half of what the eye saw change. */ export class SetSliceCommand implements Command { readonly label = 'Nine-slice'; private previous: Pick = {}; constructor( private readonly target: CutTarget, private readonly slice: CutSlice, ) {} execute(project: Project): void { const { cut, size } = requireCut(project, this.target); this.previous = rawCutSlice(cut); setCutSlice(cut, size, this.slice); } undo(project: Project): void { restoreCutSlice(requireCut(project, this.target).cut, this.previous); } } /** Where a sprite wearing the picture stands when sorted by Y (#228). */ export class SetSortLineCommand implements Command { readonly label = 'Sort line'; private previous: number | undefined; constructor( private readonly target: CutTarget, private readonly line: number, ) {} execute(project: Project): void { const { cut, size } = requireCut(project, this.target); if (!Number.isFinite(this.line)) throw new CommandError('The sort line is not a number'); this.previous = cut.sortLine; setCutSortLine(cut, size, this.line); } undo(project: Project): void { const { cut } = requireCut(project, this.target); if (this.previous === undefined) delete cut.sortLine; else cut.sortLine = this.previous; } } /** * The collision outlines of one frame (#141), all of them: adding, moving and removing a corner, * starting or removing an outline are each one edit, and the outlines to revert to are read at * execution time – a caller that previews a drag in place restores the start before executing, as * with `SetFrameRectCommand`. */ export class SetFrameCollisionCommand implements Command { readonly label = 'Collision outline'; private previous: Vec2[][] = []; constructor( private readonly assetId: string, private readonly frameId: string, private readonly outlines: readonly (readonly Vec2[])[], ) {} execute(project: Project): void { const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); if (this.outlines.some((outline) => outline.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))) throw new CommandError('A corner of the outline is not a number'); this.previous = frameCollision(frame); setFrameCollision(frame, this.outlines); } undo(project: Project): void { setFrameCollision(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous); } } /** * Replaces the outlines of several frames at once – what "Generate collision…" writes (#161), one * undo step whether it covered one frame or the whole sheet. */ export class SetFramesCollisionCommand implements Command { readonly label = 'Generate collision'; private previous = new Map(); constructor( private readonly assetId: string, private readonly outlines: ReadonlyMap, ) {} execute(project: Project): void { const asset = requireTexture(project, this.assetId); if (this.outlines.size === 0) throw new CommandError('No frame to generate a collision outline for'); const frames = [...this.outlines.keys()].map((id) => requireFrame(asset, id)); this.previous = new Map(frames.map((frame) => [frame.id, frameCollision(frame)])); for (const frame of frames) setFrameCollision(frame, this.outlines.get(frame.id)!); } undo(project: Project): void { const asset = requireTexture(project, this.assetId); for (const [id, outlines] of this.previous) setFrameCollision(requireFrame(asset, id), outlines); } } export class RenameFrameCommand implements Command { readonly label = 'Rename frame'; private previous = ''; constructor( private readonly assetId: string, private readonly frameId: string, private readonly name: string, ) {} execute(project: Project): void { const asset = requireTexture(project, this.assetId); const frame = requireFrame(asset, this.frameId); const name = this.name.trim(); if (!name) throw new CommandError('A frame needs a name'); this.previous = frame.name; frame.name = uniqueFrameName(asset, name, this.frameId); } undo(project: Project): void { requireFrame(requireTexture(project, this.assetId), this.frameId).name = this.previous; } } /** * Undo restores the former index, because frame order is sheet order. Sprites are left pointing at * the frame: a dangling reference reads as "no picture" and comes back by itself on undo. */ export class RemoveFrameCommand implements Command { readonly label = 'Remove frame'; private removed?: Removed; constructor( private readonly assetId: string, private readonly frameId: string, ) {} execute(project: Project): void { const asset = requireTexture(project, this.assetId); const removed = removeFirst(asset.frames, (f) => f.id === this.frameId); if (!removed) throw new CommandError(`Frame ${this.frameId} not found in ${asset.name}`); this.removed = removed; } undo(project: Project): void { if (!this.removed) return; restoreAt(requireTexture(project, this.assetId).frames, [this.removed]); } } export class SliceFramesCommand implements Command { readonly label: string; private created: TextureFrame[] = []; private previous: TextureFrame[] = []; constructor( private readonly assetId: string, private readonly options: SliceOptions, private readonly replace: boolean, ) { this.label = replace ? 'Re-slice frames' : 'Slice frames'; } execute(project: Project): void { const asset = requireTexture(project, this.assetId); if (asset.width <= 0 || asset.height <= 0) throw new CommandError(`The size of ${asset.name} is not known, so it cannot be sliced`); const rects = sliceFrames(asset.width, asset.height, this.options); if (rects.length === 0) throw new CommandError(`${this.options.columns}×${this.options.rows} does not fit ${asset.name}`); this.previous = [...asset.frames]; if (this.replace) asset.frames = []; // A redo reuses the frames the first run created; a sprite may point at one, and fresh ids // would break that. if (this.created.length === 0) this.created = this.buildFrames(asset, rects); else asset.frames.push(...this.created); } undo(project: Project): void { requireTexture(project, this.assetId).frames = this.previous; } private buildFrames(asset: TextureAsset, rects: readonly FrameRect[]): TextureFrame[] { const names = nextFrameNames(asset, rects.length, DEFAULT_NAME); const created = rects.map((rect, index) => ({ id: newId(), name: names[index]!, ...normalizeFrameRect(rect, asset.width, asset.height), })); asset.frames.push(...created); return created; } }