forked from mirror/crumbs-original
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
7.2 KiB
TypeScript
312 lines
7.2 KiB
TypeScript
import { deflateSync } from 'zlib';
|
|
|
|
// Colors: [R, G, B, A]
|
|
const TRANSPARENT = [0, 0, 0, 0];
|
|
const GOLD = [0xc8, 0x86, 0x0a, 255]; // #C8860A — bretzel body
|
|
const DARK = [0x8b, 0x69, 0x14, 255]; // #8B6914 — shading
|
|
const SALT = [0xf0, 0xe6, 0xd3, 255]; // #F0E6D3 — salt dots
|
|
const HIGHLIGHT = [0xe8, 0xa0, 0x20, 255]; // #E8A020 — highlight
|
|
|
|
// Salt shaker colors
|
|
const SHAKER_BODY = [0xfa, 0xf5, 0xeb, 255]; // #FAF5EB — white body
|
|
const SHAKER_CAP = [0x6b, 0x62, 0x72, 255]; // #6B6272 — gray cap
|
|
const SHAKER_DOTS = [0xd4, 0xca, 0xbb, 255]; // #D4CABB — holes
|
|
|
|
function encodePng(width: number, height: number, pixels: number[][]): Buffer {
|
|
// Build raw RGBA image data with filter byte per row
|
|
const rawData: number[] = [];
|
|
for (let y = 0; y < height; y++) {
|
|
rawData.push(0); // filter: None
|
|
for (let x = 0; x < width; x++) {
|
|
const color = pixels[y][x];
|
|
rawData.push(color[0], color[1], color[2], color[3]);
|
|
}
|
|
}
|
|
|
|
const compressed = deflateSync(Buffer.from(rawData));
|
|
|
|
// PNG signature
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
|
// IHDR chunk
|
|
const ihdr = createChunk('IHDR', () => {
|
|
const buf = Buffer.alloc(13);
|
|
buf.writeUInt32BE(width, 0);
|
|
buf.writeUInt32BE(height, 4);
|
|
buf[8] = 8; // bit depth
|
|
buf[9] = 6; // color type: RGBA
|
|
buf[10] = 0; // compression
|
|
buf[11] = 0; // filter
|
|
buf[12] = 0; // interlace
|
|
return buf;
|
|
});
|
|
|
|
// IDAT chunk
|
|
const idat = createChunk('IDAT', () => compressed);
|
|
|
|
// IEND chunk
|
|
const iend = createChunk('IEND', () => Buffer.alloc(0));
|
|
|
|
return Buffer.concat([signature, ihdr, idat, iend]);
|
|
}
|
|
|
|
function createChunk(type: string, dataFn: () => Buffer): Buffer {
|
|
const data = dataFn();
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length, 0);
|
|
|
|
const typeBuffer = Buffer.from(type, 'ascii');
|
|
const crcInput = Buffer.concat([typeBuffer, data]);
|
|
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(crcInput), 0);
|
|
|
|
return Buffer.concat([length, typeBuffer, data, crc]);
|
|
}
|
|
|
|
function crc32(buf: Buffer): number {
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) {
|
|
crc ^= buf[i];
|
|
for (let j = 0; j < 8; j++) {
|
|
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
/**
|
|
* 64x64 pixel-art bretzel in warm browns/golds.
|
|
* Shape: classic pretzel knot with salt dots.
|
|
*/
|
|
export function generateBretzelPng(): Buffer {
|
|
const S = 64;
|
|
const pixels: number[][][] = Array.from({ length: S }, () =>
|
|
Array.from({ length: S }, () => TRANSPARENT)
|
|
);
|
|
|
|
// Draw a pretzel shape using circles and arcs
|
|
// The bretzel is two loops connected by a twist in the center
|
|
|
|
function setPixel(x: number, y: number, color: number[]) {
|
|
if (x >= 0 && x < S && y >= 0 && y < S) {
|
|
pixels[y][x] = color;
|
|
}
|
|
}
|
|
|
|
function fillCircle(cx: number, cy: number, r: number, color: number[]) {
|
|
for (let y = -r; y <= r; y++) {
|
|
for (let x = -r; x <= r; x++) {
|
|
if (x * x + y * y <= r * r) {
|
|
setPixel(cx + x, cy + y, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawRing(cx: number, cy: number, outerR: number, innerR: number, color: number[]) {
|
|
for (let y = -outerR; y <= outerR; y++) {
|
|
for (let x = -outerR; x <= outerR; x++) {
|
|
const d = x * x + y * y;
|
|
if (d <= outerR * outerR && d >= innerR * innerR) {
|
|
setPixel(cx + x, cy + y, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main pretzel shape: two overlapping loops
|
|
// Left loop
|
|
drawRing(22, 28, 16, 11, GOLD);
|
|
// Right loop
|
|
drawRing(42, 28, 16, 11, GOLD);
|
|
|
|
// Top arm going up to center crossing
|
|
fillCircle(32, 14, 5, GOLD);
|
|
// Connect top to loops with thicker arms
|
|
for (let y = 12; y <= 22; y++) {
|
|
const spread = Math.floor((22 - y) * 0.6);
|
|
for (let x = -3; x <= 3; x++) {
|
|
setPixel(22 + spread + x, y, GOLD);
|
|
setPixel(42 - spread + x, y, GOLD);
|
|
}
|
|
}
|
|
|
|
// Cross-over: two diagonal arms crossing at center
|
|
for (let i = -8; i <= 8; i++) {
|
|
for (let w = -2; w <= 2; w++) {
|
|
// Left arm crosses to right bottom
|
|
setPixel(32 + i, 24 + Math.floor(i * 0.5) + w, GOLD);
|
|
// Right arm crosses to left bottom
|
|
setPixel(32 + i, 24 - Math.floor(i * 0.5) + w, GOLD);
|
|
}
|
|
}
|
|
|
|
// Bottom connection
|
|
for (let x = 18; x <= 46; x++) {
|
|
for (let y = 40; y <= 44; y++) {
|
|
const dx = x - 32;
|
|
const distFromCenter = Math.abs(dx);
|
|
if (distFromCenter <= 14) {
|
|
setPixel(x, y, GOLD);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add shading (darker on bottom-right edges)
|
|
for (let y = 0; y < S; y++) {
|
|
for (let x = 0; x < S; x++) {
|
|
if (pixels[y][x] === GOLD) {
|
|
// Check if near edge
|
|
let nearEdge = false;
|
|
for (const [dx, dy] of [
|
|
[1, 0],
|
|
[0, 1],
|
|
[1, 1]
|
|
]) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (nx >= S || ny >= S || pixels[ny][nx] === TRANSPARENT) {
|
|
nearEdge = true;
|
|
break;
|
|
}
|
|
}
|
|
if (nearEdge) {
|
|
setPixel(x, y, DARK);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add highlight on top-left edges
|
|
for (let y = 0; y < S; y++) {
|
|
for (let x = 0; x < S; x++) {
|
|
if (pixels[y][x] === GOLD) {
|
|
let nearTopEdge = false;
|
|
for (const [dx, dy] of [
|
|
[-1, 0],
|
|
[0, -1]
|
|
]) {
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
if (nx < 0 || ny < 0 || pixels[ny][nx] === TRANSPARENT) {
|
|
nearTopEdge = true;
|
|
break;
|
|
}
|
|
}
|
|
if (nearTopEdge) {
|
|
setPixel(x, y, HIGHLIGHT);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Salt dots scattered on the bretzel
|
|
const saltPositions = [
|
|
[20, 24],
|
|
[24, 20],
|
|
[28, 16],
|
|
[36, 16],
|
|
[40, 20],
|
|
[44, 24],
|
|
[24, 32],
|
|
[40, 32],
|
|
[30, 28],
|
|
[34, 28],
|
|
[26, 40],
|
|
[32, 42],
|
|
[38, 40]
|
|
];
|
|
for (const [sx, sy] of saltPositions) {
|
|
if (pixels[sy]?.[sx] && pixels[sy][sx] !== TRANSPARENT) {
|
|
setPixel(sx, sy, SALT);
|
|
setPixel(sx + 1, sy, SALT);
|
|
setPixel(sx, sy + 1, SALT);
|
|
}
|
|
}
|
|
|
|
return encodePng(S, S, pixels);
|
|
}
|
|
|
|
/**
|
|
* 32x32 pixel-art salt shaker.
|
|
*/
|
|
export function generateSaltShakerPng(): Buffer {
|
|
const S = 32;
|
|
const pixels: number[][][] = Array.from({ length: S }, () =>
|
|
Array.from({ length: S }, () => TRANSPARENT)
|
|
);
|
|
|
|
function setPixel(x: number, y: number, color: number[]) {
|
|
if (x >= 0 && x < S && y >= 0 && y < S) {
|
|
pixels[y][x] = color;
|
|
}
|
|
}
|
|
|
|
function fillRect(x1: number, y1: number, x2: number, y2: number, color: number[]) {
|
|
for (let y = y1; y <= y2; y++) {
|
|
for (let x = x1; x <= x2; x++) {
|
|
setPixel(x, y, color);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cap (top section)
|
|
fillRect(11, 4, 20, 8, SHAKER_CAP);
|
|
|
|
// Cap holes (salt comes out)
|
|
setPixel(13, 5, SHAKER_DOTS);
|
|
setPixel(16, 5, SHAKER_DOTS);
|
|
setPixel(19, 5, SHAKER_DOTS);
|
|
setPixel(14, 7, SHAKER_DOTS);
|
|
setPixel(17, 7, SHAKER_DOTS);
|
|
|
|
// Neck
|
|
fillRect(12, 9, 19, 10, SHAKER_BODY);
|
|
|
|
// Body (wider, tapered)
|
|
fillRect(10, 11, 21, 26, SHAKER_BODY);
|
|
|
|
// Body shading (right edge darker)
|
|
for (let y = 11; y <= 26; y++) {
|
|
setPixel(21, y, SHAKER_DOTS);
|
|
}
|
|
|
|
// Body outline
|
|
for (let y = 11; y <= 26; y++) {
|
|
setPixel(9, y, DARK);
|
|
setPixel(22, y, DARK);
|
|
}
|
|
for (let x = 10; x <= 21; x++) {
|
|
setPixel(x, 27, DARK);
|
|
}
|
|
|
|
// Bottom rim
|
|
fillRect(9, 27, 22, 28, SHAKER_CAP);
|
|
|
|
// "S" label on the body
|
|
const sPattern = [
|
|
[14, 16],
|
|
[15, 16],
|
|
[16, 16],
|
|
[13, 17],
|
|
[14, 18],
|
|
[15, 18],
|
|
[16, 19],
|
|
[13, 20],
|
|
[14, 20],
|
|
[15, 20]
|
|
];
|
|
for (const [x, y] of sPattern) {
|
|
setPixel(x, y, SHAKER_CAP);
|
|
}
|
|
|
|
// Falling salt particles
|
|
setPixel(14, 2, SALT);
|
|
setPixel(17, 1, SALT);
|
|
setPixel(19, 3, SALT);
|
|
setPixel(12, 1, SALT);
|
|
|
|
return encodePng(S, S, pixels);
|
|
}
|