Files
edgeever/scripts/trace-brand-icon.mjs
T

225 lines
8.6 KiB
JavaScript

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const conceptPath = path.join(projectRoot, "assets/brand/edgeever-concept-open-cat.png");
const iconPath = path.join(projectRoot, "assets/brand/edgeever-icon.svg");
const markPath = path.join(projectRoot, "assets/brand/edgeever-mark.svg");
const adaptiveMarkPath = path.join(projectRoot, "apps/mobile/assets/adaptive-icon-foreground.svg");
const source = await readFile(conceptPath);
const { data, info } = await sharp(source)
.removeAlpha()
.blur(0.8)
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height, channels } = info;
const pixelCount = width * height;
const mask = new Uint8Array(pixelCount);
for (let index = 0; index < pixelCount; index += 1) {
const offset = index * channels;
const red = data[offset];
const green = data[offset + 1];
const blue = data[offset + 2];
// The approved concept uses a near-black mark over a green field. Trace only
// genuinely dark pixels so the generated backdrop variation cannot leak into
// the production vector.
if (red < 72 && green < 72 && blue < 72) {
mask[index] = 1;
}
}
// Remove isolated antialiasing noise while preserving every meaningful contour.
const visited = new Uint8Array(pixelCount);
const queue = new Int32Array(pixelCount);
for (let start = 0; start < pixelCount; start += 1) {
if (!mask[start] || visited[start]) continue;
let head = 0;
let tail = 0;
queue[tail++] = start;
visited[start] = 1;
const members = [];
while (head < tail) {
const index = queue[head++];
members.push(index);
const x = index % width;
const y = Math.floor(index / width);
const neighbors = [];
if (x > 0) neighbors.push(index - 1);
if (x + 1 < width) neighbors.push(index + 1);
if (y > 0) neighbors.push(index - width);
if (y + 1 < height) neighbors.push(index + width);
for (const neighbor of neighbors) {
if (mask[neighbor] && !visited[neighbor]) {
visited[neighbor] = 1;
queue[tail++] = neighbor;
}
}
}
if (members.length < 80) {
for (const index of members) mask[index] = 0;
}
}
const stride = width + 1;
const edges = new Map();
let edgeCount = 0;
const addEdge = (fromX, fromY, toX, toY) => {
const from = fromY * stride + fromX;
const to = toY * stride + toX;
const destinations = edges.get(from);
if (destinations) destinations.push(to);
else edges.set(from, [to]);
edgeCount += 1;
};
const isFilled = (x, y) => x >= 0 && x < width && y >= 0 && y < height && mask[y * width + x];
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (!isFilled(x, y)) continue;
if (!isFilled(x, y - 1)) addEdge(x, y, x + 1, y);
if (!isFilled(x + 1, y)) addEdge(x + 1, y, x + 1, y + 1);
if (!isFilled(x, y + 1)) addEdge(x + 1, y + 1, x, y + 1);
if (!isFilled(x - 1, y)) addEdge(x, y + 1, x, y);
}
}
const takeEdge = (from) => {
const destinations = edges.get(from);
if (!destinations?.length) return null;
const to = destinations.pop();
if (!destinations.length) edges.delete(from);
edgeCount -= 1;
return to;
};
const point = (value) => [value % stride, Math.floor(value / stride)];
const simplifyCollinear = (points) => {
const simplified = [];
for (const current of points) {
while (simplified.length >= 2) {
const a = simplified[simplified.length - 2];
const b = simplified[simplified.length - 1];
if ((b[0] - a[0]) * (current[1] - b[1]) !== (b[1] - a[1]) * (current[0] - b[0])) break;
simplified.pop();
}
simplified.push(current);
}
return simplified;
};
const perpendicularDistanceSquared = (point, start, end) => {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (dx === 0 && dy === 0) {
return (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2;
}
const numerator = dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0];
return (numerator * numerator) / (dx * dx + dy * dy);
};
const simplifyOpen = (points, epsilonSquared) => {
if (points.length <= 2) return points;
let furthestIndex = 0;
let furthestDistance = 0;
for (let index = 1; index < points.length - 1; index += 1) {
const distance = perpendicularDistanceSquared(points[index], points[0], points[points.length - 1]);
if (distance > furthestDistance) {
furthestDistance = distance;
furthestIndex = index;
}
}
if (furthestDistance <= epsilonSquared) return [points[0], points[points.length - 1]];
return [
...simplifyOpen(points.slice(0, furthestIndex + 1), epsilonSquared).slice(0, -1),
...simplifyOpen(points.slice(furthestIndex), epsilonSquared),
];
};
const simplifyClosed = (points, epsilon = 1.5) => {
const ring = points.slice(0, -1);
let anchorIndex = 0;
for (let index = 1; index < ring.length; index += 1) {
if (ring[index][0] < ring[anchorIndex][0]) anchorIndex = index;
}
let oppositeIndex = anchorIndex;
let furthestDistance = -1;
for (let index = 0; index < ring.length; index += 1) {
const dx = ring[index][0] - ring[anchorIndex][0];
const dy = ring[index][1] - ring[anchorIndex][1];
const distance = dx * dx + dy * dy;
if (distance > furthestDistance) {
furthestDistance = distance;
oppositeIndex = index;
}
}
const rotate = (index) => ring[(index + ring.length) % ring.length];
const first = [];
for (let index = anchorIndex; index !== oppositeIndex; index = (index + 1) % ring.length) first.push(ring[index]);
first.push(ring[oppositeIndex]);
const second = [];
for (let index = oppositeIndex; index !== anchorIndex; index = (index + 1) % ring.length) second.push(ring[index]);
second.push(rotate(anchorIndex));
const epsilonSquared = epsilon * epsilon;
return [
...simplifyOpen(first, epsilonSquared).slice(0, -1),
...simplifyOpen(second, epsilonSquared).slice(0, -1),
];
};
const polygonArea = (points) => {
let area = 0;
for (let index = 0; index < points.length - 1; index += 1) {
area += points[index][0] * points[index + 1][1] - points[index + 1][0] * points[index][1];
}
return area / 2;
};
const loops = [];
while (edgeCount > 0) {
const start = edges.keys().next().value;
const points = [point(start)];
let current = start;
let guard = 0;
do {
const next = takeEdge(current);
if (next === null) throw new Error(`Open contour at edge ${current}`);
current = next;
points.push(point(current));
guard += 1;
if (guard > width * height * 2) throw new Error("Contour tracing exceeded safety limit");
} while (current !== start);
const collinear = simplifyCollinear(points);
if (collinear.length >= 4 && Math.abs(polygonArea(collinear)) >= 300) {
loops.push(simplifyClosed(collinear));
}
}
const midpoint = (a, b) => [Number(((a[0] + b[0]) / 2).toFixed(2)), Number(((a[1] + b[1]) / 2).toFixed(2))];
const pathData = loops
.map((loop) => {
const start = midpoint(loop[loop.length - 1], loop[0]);
const curves = loop.map((current, index) => {
const end = midpoint(current, loop[(index + 1) % loop.length]);
return `Q${current[0]} ${current[1]} ${end[0]} ${end[1]}`;
});
return `M${start[0]} ${start[1]}${curves.join("")}Z`;
})
.join("");
const iconSize = 1024;
const tileInset = 100;
const tileSize = 824;
const tileRadius = 188;
const sourceCropInset = 72;
const sourceCropSize = width - sourceCropInset * 2;
const scale = Number((tileSize / sourceCropSize).toFixed(8));
const transform = `translate(${tileInset} ${tileInset}) scale(${scale}) translate(-${sourceCropInset} -${sourceCropInset})`;
const title = " <title id=\"title\">EdgeEver</title>\n <desc id=\"desc\">An open cat face in near-black on the EdgeEver green rounded tile.</desc>\n";
const sharedPath = ` <path fill=\"#07130b\" fill-rule=\"evenodd\" d=\"${pathData}\" />\n`;
const transformedMark = ` <g transform=\"${transform}\">\n${sharedPath} </g>\n`;
const iconSvg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${iconSize} ${iconSize}\" role=\"img\" aria-labelledby=\"title desc\">\n${title} <rect x=\"${tileInset}\" y=\"${tileInset}\" width=\"${tileSize}\" height=\"${tileSize}\" rx=\"${tileRadius}\" fill=\"#16a06e\" />\n${transformedMark}</svg>\n`;
const markSvg = `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${iconSize} ${iconSize}\" role=\"img\" aria-label=\"EdgeEver\">\n${transformedMark}</svg>\n`;
await writeFile(iconPath, iconSvg);
await writeFile(markPath, markSvg);
await writeFile(adaptiveMarkPath, markSvg);
console.log(`[trace-brand-icon] traced ${loops.length} contours from ${width}x${height} open-cat concept`);