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>
134 lines
5.2 KiB
TypeScript
134 lines
5.2 KiB
TypeScript
/**
|
||
* The pixels of one layer on one frame of pixel art (#130), as they are stored on the server.
|
||
*
|
||
* A cel is a file of its own, `data/pixels/<sha256>.px`, content-addressed like a texture: the project
|
||
* only holds the name, so a big canvas with many layers and frames stays far below the 1 MB nginx lets
|
||
* through per request (answer 1). The name is worked out here, synchronously, from the bytes – so a
|
||
* stroke can put its cel into the model at once and undo is a swap of two names. The server hashes
|
||
* the bytes again and refuses a name that does not match.
|
||
*
|
||
* The bytes: `GMPX`, a version byte (1), a reserved byte, width and height as little-endian u16, then
|
||
* one palette index per pixel, row by row. 0 is transparent.
|
||
*/
|
||
|
||
const MAGIC = [0x47, 0x4d, 0x50, 0x58];
|
||
const VERSION = 1;
|
||
const CEL_HEADER_BYTES = 10;
|
||
/** The largest side a canvas may have (answer 3). */
|
||
export const PIXEL_CANVAS_MAX = 512;
|
||
|
||
const FILE_PATTERN = /^[a-f0-9]{64}\.px$/;
|
||
|
||
export function isCelFile(name: string): boolean {
|
||
return FILE_PATTERN.test(name);
|
||
}
|
||
|
||
/** The pixels of a cel as indices, `width * height` of them. */
|
||
export interface CelPixels {
|
||
width: number;
|
||
height: number;
|
||
data: Uint8Array;
|
||
}
|
||
|
||
export function emptyCel(width: number, height: number): CelPixels {
|
||
return { width, height, data: new Uint8Array(width * height) };
|
||
}
|
||
|
||
export function isEmptyCel(cel: CelPixels): boolean {
|
||
return cel.data.every((index) => index === 0);
|
||
}
|
||
|
||
export function encodeCel(cel: CelPixels): Uint8Array {
|
||
const bytes = new Uint8Array(CEL_HEADER_BYTES + cel.width * cel.height);
|
||
bytes.set(MAGIC, 0);
|
||
bytes[4] = VERSION;
|
||
bytes[6] = cel.width & 0xff;
|
||
bytes[7] = cel.width >> 8;
|
||
bytes[8] = cel.height & 0xff;
|
||
bytes[9] = cel.height >> 8;
|
||
bytes.set(cel.data.subarray(0, cel.width * cel.height), CEL_HEADER_BYTES);
|
||
return bytes;
|
||
}
|
||
|
||
/** Null for anything that is not a cel of a size a canvas may have. */
|
||
export function decodeCel(bytes: Uint8Array): CelPixels | null {
|
||
if (bytes.length < CEL_HEADER_BYTES) return null;
|
||
if (MAGIC.some((byte, i) => bytes[i] !== byte) || bytes[4] !== VERSION) return null;
|
||
const width = bytes[6]! | (bytes[7]! << 8);
|
||
const height = bytes[8]! | (bytes[9]! << 8);
|
||
if (width < 1 || height < 1 || width > PIXEL_CANVAS_MAX || height > PIXEL_CANVAS_MAX) return null;
|
||
if (bytes.length !== CEL_HEADER_BYTES + width * height) return null;
|
||
return { width, height, data: bytes.slice(CEL_HEADER_BYTES) };
|
||
}
|
||
|
||
export function celFileName(bytes: Uint8Array): string {
|
||
return `${sha256Hex(bytes)}.px`;
|
||
}
|
||
|
||
const K = new Uint32Array([
|
||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98,
|
||
0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
||
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8,
|
||
0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819,
|
||
0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
||
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
|
||
0xc67178f2,
|
||
]);
|
||
|
||
/**
|
||
* SHA-256 as hex. `crypto.subtle` would do it, but only asynchronously – and a command has to know
|
||
* the name of the cel it puts into the model when it runs.
|
||
*/
|
||
export function sha256Hex(message: Uint8Array): string {
|
||
const length = message.length;
|
||
const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
|
||
padded.set(message);
|
||
padded[length] = 0x80;
|
||
const bits = length * 8;
|
||
const view = new DataView(padded.buffer);
|
||
view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
|
||
view.setUint32(padded.length - 4, bits >>> 0);
|
||
const h = new Uint32Array([
|
||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||
]);
|
||
const w = new Uint32Array(64);
|
||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
||
for (let i = 16; i < 64; i++) {
|
||
const x = w[i - 15]!;
|
||
const y = w[i - 2]!;
|
||
const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
|
||
const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
|
||
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
|
||
}
|
||
let a = h[0]!;
|
||
let b = h[1]!;
|
||
let c = h[2]!;
|
||
let d = h[3]!;
|
||
let e = h[4]!;
|
||
let f = h[5]!;
|
||
let g = h[6]!;
|
||
let hh = h[7]!;
|
||
for (let i = 0; i < 64; i++) {
|
||
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i]! + w[i]!) >>> 0;
|
||
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||
hh = g;
|
||
g = f;
|
||
f = e;
|
||
e = (d + t1) >>> 0;
|
||
d = c;
|
||
c = b;
|
||
b = a;
|
||
a = (t1 + t2) >>> 0;
|
||
}
|
||
const sums = [a, b, c, d, e, f, g, hh];
|
||
for (let i = 0; i < 8; i++) h[i] = (h[i]! + sums[i]!) >>> 0;
|
||
}
|
||
return Array.from(h, (word) => word.toString(16).padStart(8, '0')).join('');
|
||
}
|
||
|
||
function rotr(x: number, n: number): number {
|
||
return (x >>> n) | (x << (32 - n));
|
||
}
|