feat: feel cargo doc

This commit is contained in:
2026-04-23 16:58:11 +07:00
parent 3e36cfcb39
commit df04cb1d09
27 changed files with 2500 additions and 2 deletions
+159
View File
@@ -0,0 +1,159 @@
import sharp from 'sharp';
import { resolve, basename, join } from 'path';
import { existsSync, mkdirSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../../..');
const DEFAULT_INPUT = resolve(PROJECT_ROOT, 'assets/icons/pack3-asteroids');
const DEFAULT_OUTPUT = resolve(PROJECT_ROOT, 'assets/items/asteroid-shapes');
// Tunable constants
const OUTLINE_WIDTH = 5; // px thickness of boundary outline
const OUTLINE_COLOR = 40; // darkness of outline (0=black, 255=white)
const EDGE_THRESHOLD = 30; // min gradient magnitude to count as an edge
const EDGE_DARKNESS = 180; // how dark internal edge lines are (0=black, 255=white)
const ALPHA_THRESHOLD = 128; // min alpha to consider a pixel "opaque"
async function createAsteroidShape(filePath, outputDir) {
// Step 1: Read source raw RGBA
const { data: srcData, info } = await sharp(filePath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
// Step 2: Gradient-based edge detection on greyscale
const greyResult = await sharp(filePath)
.greyscale()
.blur(1.0)
.raw()
.toBuffer({ resolveWithObject: true });
const grey = greyResult.data;
// Compute Sobel gradient magnitude per pixel
const gradients = new Float32Array(width * height);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Only compute for opaque pixels
if (srcData[idx * 4 + 3] < ALPHA_THRESHOLD) continue;
// Sobel X
const gx =
-grey[(y - 1) * width + (x - 1)] + grey[(y - 1) * width + (x + 1)]
- 2 * grey[y * width + (x - 1)] + 2 * grey[y * width + (x + 1)]
- grey[(y + 1) * width + (x - 1)] + grey[(y + 1) * width + (x + 1)];
// Sobel Y
const gy =
-grey[(y - 1) * width + (x - 1)] - 2 * grey[(y - 1) * width + x] - grey[(y - 1) * width + (x + 1)]
+ grey[(y + 1) * width + (x - 1)] + 2 * grey[(y + 1) * width + x] + grey[(y + 1) * width + (x + 1)];
gradients[idx] = Math.sqrt(gx * gx + gy * gy);
}
}
// Build base layer: white with faint gray edge lines where gradient is strong
const baseLayer = Buffer.alloc(width * height * 4);
for (let i = 0; i < width * height; i++) {
const alpha = srcData[i * 4 + 3];
if (alpha < ALPHA_THRESHOLD) {
// Transparent pixel — keep transparent
baseLayer[i * 4 + 3] = 0;
continue;
}
const grad = gradients[i];
let v = 255; // default white
if (grad > EDGE_THRESHOLD) {
// Map gradient above threshold to gray line darkness
const intensity = Math.min(1, (grad - EDGE_THRESHOLD) / 200);
v = 255 - Math.round(intensity * (255 - EDGE_DARKNESS));
}
baseLayer[i * 4 + 0] = v;
baseLayer[i * 4 + 1] = v;
baseLayer[i * 4 + 2] = v;
baseLayer[i * 4 + 3] = alpha;
}
// Step 3: Outline layer — boundary detection via neighbor check
const outlineLayer = Buffer.alloc(width * height * 4, 0);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (srcData[idx * 4 + 3] < ALPHA_THRESHOLD) continue;
let isBorder = false;
outer:
for (let dy = -OUTLINE_WIDTH; dy <= OUTLINE_WIDTH; dy++) {
for (let dx = -OUTLINE_WIDTH; dx <= OUTLINE_WIDTH; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) {
isBorder = true;
break outer;
}
const nIdx = ny * width + nx;
if (srcData[nIdx * 4 + 3] < ALPHA_THRESHOLD) {
isBorder = true;
break outer;
}
}
}
if (isBorder) {
outlineLayer[idx * 4 + 0] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 1] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 2] = OUTLINE_COLOR;
outlineLayer[idx * 4 + 3] = 255;
}
}
}
// Step 4: Composite — base (white + faint edges) + outline on top
const raw = { width, height, channels: 4 };
await sharp(baseLayer, { raw })
.composite([
{
input: outlineLayer,
raw,
blend: 'over',
},
])
.png()
.toFile(join(outputDir, basename(filePath)));
}
async function main() {
const inputDir = resolve(process.argv[2] || DEFAULT_INPUT);
const outputDir = resolve(process.argv[3] || DEFAULT_OUTPUT);
if (!existsSync(inputDir)) {
console.error(`Not found: ${inputDir}`);
process.exit(1);
}
mkdirSync(outputDir, { recursive: true });
const files = readdirSync(inputDir)
.filter(f => /\.png$/i.test(f))
.sort()
.map(f => join(inputDir, f));
if (files.length === 0) {
console.error(`No PNG files found in: ${inputDir}`);
process.exit(1);
}
console.log(`Creating ${files.length} asteroid shape(s)...`);
await Promise.all(
files.map(async (filePath) => {
await createAsteroidShape(filePath, outputDir);
console.log(` ${basename(filePath)} -> done`);
})
);
console.log(`Done. ${files.length} shapes saved to ${outputDir}`);
}
main();