Files
feuerwerk/src/core/model/pixelDraw.ts
T
adminandClaude Opus 5 fe894c630d
game-maker / gate (push) Successful in 1m42s
game-maker / suite (push) Successful in 8m15s
Pixel art tools show only useful options and remove any colour (#201)
The pixel art side bar showed every tool option all the time, the
palette used only part of the column width and "Remove" could only
drop the last colour. Now each tool shows what it needs, the palette
fills the column and any colour can be removed.

- Options per tool live in toolOptions.ts. Brush shows for pencil,
  eraser, line and unfilled shapes. Filled shapes shows for rectangle
  and ellipse. Contiguous shows for fill. Picker has none.
- All options share one row with a fixed height, so nothing below
  jumps when the tool changes.
- The palette picks its column count from the width and stretches the
  swatches until the row ends flush. At 1280 px that is 11 per row
  instead of 8.
- The colour field takes the space the two buttons leave, so "Remove"
  is no longer cut off.
- "Remove" deletes the selected colour. Pixels of that colour turn
  transparent, later indices move down by one in every image that uses
  the palette, and palette plus images change in one undo step.
  Transparent itself cannot be removed.

Decisions:
- Pixels of a removed colour become transparent, like erasing
  (chosen by the user before the build).

Findings:
- The Filled checkbox slid away under the pointer. The brush button
  sat to its left and vanished when Filled turned on, so the second
  click missed. The brush button now sits to the right, and a check
  clicks the same spot twice.
- One start-up FAIL "debug hook present" came back never in three
  later runs. It is the known start flake.

Tests:
- ./check.sh: 141 files, 2080 tests, exit 0 (before 140 / 2070)
- 10 new unit tests in tests/editor/pixel-palette.test.ts
- GM_STEPS=48 (fresh seed): ALL PASSED, 40 checks, 4 of them new
- Counter-check: indices not shifting and brush always visible make
  4 unit tests and exactly the 2 matching e2e checks fail
- Check total 1282 (1278 + 4) in README and wiki, computed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:58:31 +02:00

188 lines
7.2 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.
/**
* 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;
}