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>
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
/**
|
||
* The edits of a pixel art asset (#130) as plain functions from one state to the next. A state is
|
||
* names and numbers only – the pixels are in cel files the state names – so it is cheap to copy, and
|
||
* one command can take any of these edits back by putting the state before it in place again
|
||
* (see `EditPixelArtCommand`). Each function returns a new state and leaves the one it is given alone;
|
||
* one that cannot do what is asked throws a `PixelEditError` with the reason in words.
|
||
*/
|
||
import type { PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
|
||
import { copyName } from '../util/names';
|
||
import { PIXEL_CANVAS_MAX } from './pixelCel';
|
||
|
||
export interface PixelArtState {
|
||
width: number;
|
||
height: number;
|
||
layers: PixelLayer[];
|
||
frames: PixelFrame[];
|
||
}
|
||
|
||
export class PixelEditError extends Error {}
|
||
|
||
export function pixelArtState(asset: PixelArtAsset): PixelArtState {
|
||
return cloneState(asset);
|
||
}
|
||
|
||
function cloneState(state: PixelArtState): PixelArtState {
|
||
return {
|
||
width: state.width,
|
||
height: state.height,
|
||
layers: state.layers.map((layer) => ({ ...layer })),
|
||
frames: state.frames.map((frame) => ({ id: frame.id, cels: { ...frame.cels } })),
|
||
};
|
||
}
|
||
|
||
export function applyState(asset: PixelArtAsset, state: PixelArtState): void {
|
||
const copy = cloneState(state);
|
||
asset.width = copy.width;
|
||
asset.height = copy.height;
|
||
asset.layers = copy.layers;
|
||
asset.frames = copy.frames;
|
||
}
|
||
|
||
function layerIndex(state: PixelArtState, layerId: string): number {
|
||
const index = state.layers.findIndex((layer) => layer.id === layerId);
|
||
if (index < 0) throw new PixelEditError(`Layer ${layerId} not found`);
|
||
return index;
|
||
}
|
||
|
||
function frameIndex(state: PixelArtState, frameId: string): number {
|
||
const index = state.frames.findIndex((frame) => frame.id === frameId);
|
||
if (index < 0) throw new PixelEditError(`Frame ${frameId} not found`);
|
||
return index;
|
||
}
|
||
|
||
/** "Layer n" with the first number no layer uses yet. */
|
||
function nextLayerName(state: PixelArtState): string {
|
||
const taken = new Set(state.layers.map((layer) => layer.name));
|
||
for (let n = state.layers.length + 1; ; n++) if (!taken.has(`Layer ${n}`)) return `Layer ${n}`;
|
||
}
|
||
|
||
/** Cels of several layers and frames at once – what one stroke, fill or shape leaves behind. */
|
||
export interface CelChange {
|
||
frameId: string;
|
||
layerId: string;
|
||
/** The new cel file, `''` for an empty cel. */
|
||
file: string;
|
||
}
|
||
|
||
export function withCels(state: PixelArtState, changes: readonly CelChange[]): PixelArtState {
|
||
const next = cloneState(state);
|
||
for (const change of changes) {
|
||
layerIndex(next, change.layerId);
|
||
const frame = next.frames[frameIndex(next, change.frameId)]!;
|
||
if (change.file) frame.cels[change.layerId] = change.file;
|
||
else delete frame.cels[change.layerId];
|
||
}
|
||
return next;
|
||
}
|
||
|
||
/** A new empty layer right above `aboveId`, or on top when that is empty. */
|
||
export function withLayerAdded(state: PixelArtState, id: string, aboveId = ''): PixelArtState {
|
||
const next = cloneState(state);
|
||
const at = aboveId ? layerIndex(next, aboveId) + 1 : next.layers.length;
|
||
next.layers.splice(at, 0, { id, name: nextLayerName(state), opacity: 1 });
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* A copy of a layer right above it, named after it, with its cels on every frame. The cels are files
|
||
* named by their content, so the copy names the same files and costs no upload.
|
||
*/
|
||
export function withLayerDuplicated(state: PixelArtState, id: string, sourceId: string): PixelArtState {
|
||
const next = cloneState(state);
|
||
const index = layerIndex(next, sourceId);
|
||
const source = next.layers[index]!;
|
||
const name = copyName(
|
||
next.layers.map((layer) => layer.name),
|
||
source.name,
|
||
);
|
||
next.layers.splice(index + 1, 0, { ...source, id, name });
|
||
for (const frame of next.frames) {
|
||
const cel = frame.cels[sourceId];
|
||
if (cel) frame.cels[id] = cel;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
/** Takes a layer out, with its cels on every frame. The last layer stays: there would be nothing to paint on. */
|
||
export function withLayerRemoved(state: PixelArtState, layerId: string): PixelArtState {
|
||
const next = cloneState(state);
|
||
const index = layerIndex(next, layerId);
|
||
if (next.layers.length <= 1) throw new PixelEditError('Pixel art keeps at least one layer');
|
||
next.layers.splice(index, 1);
|
||
for (const frame of next.frames) delete frame.cels[layerId];
|
||
return next;
|
||
}
|
||
|
||
export function withLayerChanged(
|
||
state: PixelArtState,
|
||
layerId: string,
|
||
change: Partial<Pick<PixelLayer, 'name' | 'hidden' | 'opacity'>>,
|
||
): PixelArtState {
|
||
const next = cloneState(state);
|
||
const layer = next.layers[layerIndex(next, layerId)]!;
|
||
if (change.name !== undefined) {
|
||
const name = change.name.trim();
|
||
if (!name) throw new PixelEditError('A layer needs a name');
|
||
layer.name = name;
|
||
}
|
||
if (change.hidden !== undefined) {
|
||
if (change.hidden) layer.hidden = true;
|
||
else delete layer.hidden;
|
||
}
|
||
if (change.opacity !== undefined) {
|
||
if (!Number.isFinite(change.opacity)) throw new PixelEditError('Opacity is a number from 0 to 1');
|
||
layer.opacity = Math.max(0, Math.min(1, change.opacity));
|
||
}
|
||
return next;
|
||
}
|
||
|
||
/** Moves a layer to another place in the drawing order; the index counts from the bottom. */
|
||
export function withLayerMoved(state: PixelArtState, layerId: string, to: number): PixelArtState {
|
||
const next = cloneState(state);
|
||
const from = layerIndex(next, layerId);
|
||
const target = Math.max(0, Math.min(next.layers.length - 1, Math.round(to)));
|
||
if (target === from) throw new PixelEditError('The layer is already there');
|
||
const [layer] = next.layers.splice(from, 1);
|
||
next.layers.splice(target, 0, layer!);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Lays a layer onto the one below it and takes it out. `merged` names the cel the lower layer has on
|
||
* each frame afterwards – working the pixels out is the editor's part, it holds them.
|
||
*/
|
||
export function withLayerMergedDown(
|
||
state: PixelArtState,
|
||
layerId: string,
|
||
merged: Readonly<Record<string, string>>,
|
||
): PixelArtState {
|
||
const index = layerIndex(state, layerId);
|
||
if (index === 0) throw new PixelEditError('There is no layer below to merge into');
|
||
const lower = state.layers[index - 1]!;
|
||
const changes = state.frames.map((frame) => ({
|
||
frameId: frame.id,
|
||
layerId: lower.id,
|
||
file: merged[frame.id] ?? '',
|
||
}));
|
||
return withLayerRemoved(withCels(state, changes), layerId);
|
||
}
|
||
|
||
/** A new frame right after `afterId` (at the end when empty): empty, or a copy of the frame `copyOf`. */
|
||
export function withFrameAdded(state: PixelArtState, id: string, afterId = '', copyOf = ''): PixelArtState {
|
||
const next = cloneState(state);
|
||
const at = afterId ? frameIndex(next, afterId) + 1 : next.frames.length;
|
||
const cels = copyOf ? { ...next.frames[frameIndex(next, copyOf)]!.cels } : {};
|
||
next.frames.splice(at, 0, { id, cels });
|
||
return next;
|
||
}
|
||
|
||
export function withFrameRemoved(state: PixelArtState, frameId: string): PixelArtState {
|
||
const next = cloneState(state);
|
||
const index = frameIndex(next, frameId);
|
||
if (next.frames.length <= 1) throw new PixelEditError('Pixel art keeps at least one frame');
|
||
next.frames.splice(index, 1);
|
||
return next;
|
||
}
|
||
|
||
export function withFrameMoved(state: PixelArtState, frameId: string, to: number): PixelArtState {
|
||
const next = cloneState(state);
|
||
const from = frameIndex(next, frameId);
|
||
const target = Math.max(0, Math.min(next.frames.length - 1, Math.round(to)));
|
||
if (target === from) throw new PixelEditError('The frame is already there');
|
||
const [frame] = next.frames.splice(from, 1);
|
||
next.frames.splice(target, 0, frame!);
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* The canvas at another size. `cels` maps every old cel file to the one it becomes – worked out by
|
||
* the editor, which holds the pixels; a cel it maps to `''` is empty afterwards.
|
||
*/
|
||
export function withCanvasSize(
|
||
state: PixelArtState,
|
||
width: number,
|
||
height: number,
|
||
cels: Readonly<Record<string, string>>,
|
||
): PixelArtState {
|
||
if (!isCanvasSide(width) || !isCanvasSide(height))
|
||
throw new PixelEditError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`);
|
||
for (const frame of state.frames)
|
||
for (const file of Object.values(frame.cels))
|
||
if (cels[file] === undefined) throw new PixelEditError('A cel of the old canvas has no counterpart');
|
||
return { ...withCelsReplaced(state, cels), width, height };
|
||
}
|
||
|
||
/** Every cel `cels` names swapped for the file it maps to, `''` leaving it empty; the others stay. */
|
||
export function withCelsReplaced(
|
||
state: PixelArtState,
|
||
cels: Readonly<Record<string, string>>,
|
||
): PixelArtState {
|
||
const next = cloneState(state);
|
||
for (const frame of next.frames)
|
||
for (const [layerId, file] of Object.entries(frame.cels)) {
|
||
const replaced = cels[file];
|
||
if (replaced === undefined) continue;
|
||
if (replaced) frame.cels[layerId] = replaced;
|
||
else delete frame.cels[layerId];
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function isCanvasSide(side: number): boolean {
|
||
return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX;
|
||
}
|