feat: feel cargo doc
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user