Files
feuerwerk/src/core/commands/frameCommands.ts
T
adminandClaude Opus 5.5 3e05cbbed5 Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing
and sorting already read both off the TextureRef every picture hands
out, but the generated pictures always said "no slice, no line". So
the feature moves from the frame to the picture, without a second
copy of any rule.

- PictureCut (border, fill, sortLine) is one type. TextureFrame
  extends it, PixelArtAsset and VectorArtAsset carry it as a whole.
  The texture file format does not change, and no migration is needed.
- The rules in texture.ts take a cut and a size: cutSortLine,
  setCutSlice, rawCutSlice and the rest replace the frame* versions.
  serialize.ts reads all three through one readCut.
- cutTarget.ts resolves a target to cut and size: a texture frame, a
  pixel frame's size or the vector canvas. It lives apart from
  texture.ts because vectorart.ts reaches texture.ts itself.
- SetSliceCommand and SetSortLineCommand take that target instead of
  (assetId, frameId). They refuse an asset that draws no picture.
- generatedRef fills slice and sortLine from the drawing. The whole
  strip of a pixel art asset with several frames gets none, it is no
  picture of that size. With one frame asset and frame are the same.
- Nine-slice scale is one shared schema row, now also on pixel art and
  vector art.
- cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame
  inspector and the drawing inspector both build them.
- SheetCanvas draws a drawing's lines that are set, dashed as in the
  Texture tab. They are not dragged there, the painting tools own the
  canvas (as proposed in the issue).
- drawOrder falls back to the picture's height when a ref has no
  rect, or a sort line on vector art would have been ignored.

Tests:
- Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector
  art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a
  drawing, a cut on the whole strip, no height fallback.
- e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where
  12-pixel borders at scale 2 put them. Without borders both spots
  read green.
- Looked at: the Pixel art tab with the lines, and the sliced sprite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:53:51 +02:00

307 lines
11 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.
/**
* 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<PictureCut, 'border' | 'fill'> = {};
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<string, Vec2[][]>();
constructor(
private readonly assetId: string,
private readonly outlines: ReadonlyMap<string, readonly (readonly Vec2[])[]>,
) {}
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<TextureFrame>;
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;
}
}