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; }