/** * What the tools of the pixel art tab do to a cel (#130), as plain functions on the indices – so every * rule can be measured without a browser. They write into the cel they are given; the tab hands them a * copy and turns the result into a new cel file once the stroke ends. */ import { type CelPixels, emptyCel } from './pixelCel'; export interface PixelPoint { x: number; y: number; } /** Brush sizes the tools offer (answer 6). */ export const BRUSH_MIN = 1; export const BRUSH_MAX = 8; export function cloneCel(cel: CelPixels): CelPixels { return { width: cel.width, height: cel.height, data: cel.data.slice() }; } export function pixelAt(cel: CelPixels, x: number, y: number): number { return x >= 0 && y >= 0 && x < cel.width && y < cel.height ? cel.data[y * cel.width + x]! : 0; } function setPixel(cel: CelPixels, x: number, y: number, index: number): void { if (x >= 0 && y >= 0 && x < cel.width && y < cel.height) cel.data[y * cel.width + x] = index; } /** * A square brush of `size` pixels on the point. An even size has no centre pixel, so it reaches one * further right and down than left and up – the way Aseprite places it. */ export function stamp(cel: CelPixels, at: PixelPoint, size: number, index: number): void { const n = Math.max(BRUSH_MIN, Math.min(BRUSH_MAX, Math.round(size))); const x0 = at.x - Math.floor((n - 1) / 2); const y0 = at.y - Math.floor((n - 1) / 2); for (let y = y0; y < y0 + n; y++) for (let x = x0; x < x0 + n; x++) setPixel(cel, x, y, index); } /** The pixels of a straight line from one point to the other, both included (Bresenham). */ export function linePoints(from: PixelPoint, to: PixelPoint): PixelPoint[] { const points: PixelPoint[] = []; let x = from.x; let y = from.y; const dx = Math.abs(to.x - x); const dy = -Math.abs(to.y - y); const sx = x < to.x ? 1 : -1; const sy = y < to.y ? 1 : -1; let error = dx + dy; for (;;) { points.push({ x, y }); if (x === to.x && y === to.y) return points; const twice = 2 * error; if (twice >= dy) { error += dy; x += sx; } if (twice <= dx) { error += dx; y += sy; } } } function box(a: PixelPoint, b: PixelPoint): { x0: number; y0: number; x1: number; y1: number } { return { x0: Math.min(a.x, b.x), y0: Math.min(a.y, b.y), x1: Math.max(a.x, b.x), y1: Math.max(a.y, b.y) }; } /** The pixels of a rectangle between two corners, both included: its border, or all of it. */ export function rectPoints(a: PixelPoint, b: PixelPoint, filled: boolean): PixelPoint[] { const { x0, y0, x1, y1 } = box(a, b); const points: PixelPoint[] = []; for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (filled || x === x0 || x === x1 || y === y0 || y === y1) points.push({ x, y }); return points; } /** * The pixels of the ellipse that fits the box between two corners. Filled, it is every pixel whose * centre lies inside; the outline is the filled pixels with a neighbour outside, which gives a line * one pixel wide without gaps – what a pixel artist expects rather than a thin sampled curve. */ export function ellipsePoints(a: PixelPoint, b: PixelPoint, filled: boolean): PixelPoint[] { const { x0, y0, x1, y1 } = box(a, b); const cx = (x0 + x1 + 1) / 2; const cy = (y0 + y1 + 1) / 2; const rx = (x1 - x0 + 1) / 2; const ry = (y1 - y0 + 1) / 2; const inside = (x: number, y: number): boolean => { if (x < x0 || x > x1 || y < y0 || y > y1) return false; const nx = (x + 0.5 - cx) / rx; const ny = (y + 0.5 - cy) / ry; return nx * nx + ny * ny <= 1; }; const points: PixelPoint[] = []; for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { if (!inside(x, y)) continue; const edge = !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1); if (filled || edge) points.push({ x, y }); } return points; } /** Every point stamped with the brush – how a line, a rectangle or an ellipse outline gets its width. */ export function stampAll(cel: CelPixels, points: readonly PixelPoint[], size: number, index: number): void { for (const point of points) stamp(cel, point, size, index); } /** * Fills with `index`: the area of the same index the pixel lies in, joined along edges (not corners), * or – `contiguous` off – every pixel of that index on the cel. Outside the cel, nothing happens. */ export function floodFill(cel: CelPixels, at: PixelPoint, index: number, contiguous: boolean): void { const { width, height, data } = cel; if (at.x < 0 || at.y < 0 || at.x >= width || at.y >= height) return; const target = data[at.y * width + at.x]!; if (target === index) return; if (!contiguous) { for (let i = 0; i < data.length; i++) if (data[i] === target) data[i] = index; return; } const stack = [at.y * width + at.x]; data[at.y * width + at.x] = index; while (stack.length > 0) { const i = stack.pop() as number; const x = i % width; const neighbours = [x > 0 ? i - 1 : -1, x < width - 1 ? i + 1 : -1, i - width, i + width]; for (const n of neighbours) if (n >= 0 && n < data.length && data[n] === target) { data[n] = index; stack.push(n); } } } /** Where the old canvas sits in the new one: each axis at its start (0), middle (0.5) or end (1). */ export interface ResizeAnchor { x: 0 | 0.5 | 1; y: 0 | 0.5 | 1; } /** How far the old canvas is shifted in the new one; negative when a side is cut off. */ export function anchorOffset(from: number, to: number, anchor: 0 | 0.5 | 1): number { return Math.floor((to - from) * anchor); } /** The cel on a canvas of another size: pixels keep their place relative to the anchor, what sticks out is cut. */ export function resizeCel(cel: CelPixels, width: number, height: number, anchor: ResizeAnchor): CelPixels { const out = emptyCel(width, height); const dx = anchorOffset(cel.width, width, anchor.x); const dy = anchorOffset(cel.height, height, anchor.y); for (let y = 0; y < cel.height; y++) for (let x = 0; x < cel.width; x++) setPixel(out, x + dx, y + dy, cel.data[y * cel.width + x]!); return out; } /** * The cel once palette colour `index` is gone (#201): its pixels turn transparent and every colour * after it moves down one, so each remaining pixel keeps its colour. Null when nothing changes. */ export function withoutColorIndex(cel: CelPixels, index: number): CelPixels | null { if (!cel.data.some((i) => i >= index)) return null; const out = cloneCel(cel); for (let p = 0; p < out.data.length; p++) { const i = out.data[p]!; if (i === index) out.data[p] = 0; else if (i > index) out.data[p] = i - 1; } return out; } /** * The layer below with the one above laid over it: every pixel the upper layer paints covers the * lower one. Indexed colours have no in-between colour, so the upper layer's opacity cannot be kept – * the merged layer takes the lower one's. */ export function mergeCels(lower: CelPixels, upper: CelPixels): CelPixels { const out = cloneCel(lower); for (let y = 0; y < Math.min(lower.height, upper.height); y++) for (let x = 0; x < Math.min(lower.width, upper.width); x++) { const index = upper.data[y * upper.width + x]!; if (index !== 0) out.data[y * out.width + x] = index; } return out; }