Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
214 lines
5.9 KiB
TypeScript
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;
|
|
}
|