GRV-Summit-Site/lib/voronoi-mesh.ts
Kirubel-Kibru-Yaltopia d261148b35
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
Enhance footer and hero with brand backgrounds
2026-05-21 20:35:59 +03:00

214 lines
5.9 KiB
TypeScript

export type Vec2 = [number, number];
export type VoronoiCell = {
d: string;
points: Vec2[];
};
function distSq(a: Vec2, b: Vec2) {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
return dx * dx + dy * dy;
}
function intersectEdge(a: Vec2, b: Vec2, keep: Vec2, other: Vec2): Vec2 {
const da = distSq(a, keep) - distSq(a, other);
const db = distSq(b, keep) - distSq(b, other);
const denom = da - db;
const t = Math.abs(denom) < 1e-9 ? 0.5 : da / denom;
return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
}
function clipPolygonByHalfPlane(poly: Vec2[], keep: Vec2, other: Vec2): Vec2[] {
if (poly.length === 0) return [];
const inside = (p: Vec2) => distSq(p, keep) <= distSq(p, other) + 1e-6;
const out: Vec2[] = [];
for (let i = 0; i < poly.length; i++) {
const curr = poly[i];
const prev = poly[(i - 1 + poly.length) % poly.length];
const currIn = inside(curr);
const prevIn = inside(prev);
if (prevIn && currIn) out.push(curr);
else if (prevIn && !currIn) out.push(intersectEdge(prev, curr, keep, other));
else if (!prevIn && currIn) {
out.push(intersectEdge(prev, curr, keep, other));
out.push(curr);
}
}
return out;
}
function voronoiPolygon(site: Vec2, sites: Vec2[], bounds: Vec2[]): Vec2[] {
let poly = bounds;
for (const other of sites) {
if (other === site) continue;
poly = clipPolygonByHalfPlane(poly, site, other);
if (poly.length < 3) return [];
}
return poly;
}
export function polygonPath(points: Vec2[]): string {
if (points.length < 3) return "";
return (
points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0].toFixed(2)} ${p[1].toFixed(2)}`)
.join(" ") + " Z"
);
}
function len(a: Vec2, b: Vec2) {
return Math.hypot(b[0] - a[0], b[1] - a[1]);
}
/** Soft pebble edges — rounded corners on each Voronoi cell. */
export function roundedPolygonPath(points: Vec2[], radius = 1.35): string {
const n = points.length;
if (n < 3) return polygonPath(points);
const parts: string[] = [];
for (let i = 0; i < n; i++) {
const prev = points[(i - 1 + n) % n];
const curr = points[i];
const next = points[(i + 1) % n];
const e1: Vec2 = [prev[0] - curr[0], prev[1] - curr[1]];
const e2: Vec2 = [next[0] - curr[0], next[1] - curr[1]];
const l1 = Math.hypot(e1[0], e1[1]) || 1;
const l2 = Math.hypot(e2[0], e2[1]) || 1;
const cut = Math.min(radius, l1 * 0.38, l2 * 0.38);
const p1: Vec2 = [curr[0] + (e1[0] / l1) * cut, curr[1] + (e1[1] / l1) * cut];
const p2: Vec2 = [curr[0] + (e2[0] / l2) * cut, curr[1] + (e2[1] / l2) * cut];
if (i === 0) parts.push(`M ${p1[0].toFixed(2)} ${p1[1].toFixed(2)}`);
else parts.push(`L ${p1[0].toFixed(2)} ${p1[1].toFixed(2)}`);
parts.push(`Q ${curr[0].toFixed(2)} ${curr[1].toFixed(2)} ${p2[0].toFixed(2)} ${p2[1].toFixed(2)}`);
}
return parts.join(" ") + " Z";
}
/** Radial pebble field — smaller cells near center, larger toward edges (rock mosaic). */
export function generateRadialRockSites(
count: number,
width: number,
height: number,
seed: number
): Vec2[] {
const rng = createRng(seed);
const cx = width / 2;
const cy = height / 2;
const sites: Vec2[] = [];
for (let i = 0; i < count; i++) {
const angle = rng() * Math.PI * 2;
const r = Math.pow(rng(), 0.55) * 0.46;
sites.push([cx + Math.cos(angle) * r * width, cy + Math.sin(angle) * r * height * 0.92]);
}
return sites;
}
export function createRng(seed: number) {
let s = seed % 2147483646 || 1;
return () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
export function generatePoissonSites(
count: number,
width: number,
height: number,
seed: number,
minDist: number
): Vec2[] {
const rng = createRng(seed);
const sites: Vec2[] = [];
let attempts = 0;
while (sites.length < count && attempts < count * 120) {
const p: Vec2 = [rng() * width, rng() * height];
if (sites.every((s) => distSq(s, p) >= minDist * minDist)) sites.push(p);
attempts++;
}
return sites;
}
/** Ghost sites pull tessellation to the edges (stained-glass border). */
export function borderGhostSites(width: number, height: number, pad = 18): Vec2[] {
const ghosts: Vec2[] = [];
const steps = 5;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
ghosts.push([-pad, height * t]);
ghosts.push([width + pad, height * t]);
ghosts.push([width * t, -pad]);
ghosts.push([width * t, height + pad]);
}
return ghosts;
}
export type VoronoiMeshOptions = {
siteGenerator?: "poisson" | "radial";
shape?: "rounded" | "sharp";
/** Poisson minimum spacing — lower = finer cells */
minDist?: number;
cornerRadiusMax?: number;
cornerRadiusFactor?: number;
};
export function buildVoronoiMesh(
siteCount: number,
width: number,
height: number,
seed: number,
options: VoronoiMeshOptions = {}
): VoronoiCell[] {
const {
siteGenerator = "poisson",
shape = "rounded",
minDist = 8,
cornerRadiusMax = 2.2,
cornerRadiusFactor = 0.22,
} = options;
const sites =
siteGenerator === "radial"
? generateRadialRockSites(siteCount, width, height, seed)
: generatePoissonSites(siteCount, width, height, seed, minDist);
const allSites = [...sites, ...borderGhostSites(width, height)];
const bounds: Vec2[] = [
[0, 0],
[width, 0],
[width, height],
[0, height],
];
const cells: VoronoiCell[] = [];
for (const site of sites) {
const points = voronoiPolygon(site, allSites, bounds);
if (points.length < 3) continue;
const avgEdge =
points.reduce((sum, p, i) => sum + len(p, points[(i + 1) % points.length]), 0) /
points.length;
const d =
shape === "sharp"
? polygonPath(points)
: roundedPolygonPath(
points,
Math.min(cornerRadiusMax, avgEdge * cornerRadiusFactor)
);
if (d) cells.push({ d, points });
}
return cells;
}