1// Ring utilities: the one thing a renderer cannot work out for itself.2//3// A system's ring is continuous across the antimeridian (see the continuity4// rule in contract.ts), which means an honest ring may run off the surface's5// x-range by a whole world. Every renderer then faces the same question — how6// do I draw a loop that is partly somewhere else? — and the answer is the same7// for all of them: clip the ring as it lies, clip it again shifted a world8// each way, draw the pieces. The clip belongs here so no renderer owns a cell9// rule.1011import type { Coordinate, Ground } from "./ground.ts";12import { surfaceExtent } from "./ground.ts";13import type { Ring } from "./contract.ts";14import type { PlanCell } from "./plan.ts";15import { worldSurface } from "../semconv/geometry.ts";1617/**18 * Cut a closed ring against two vertical edges — the standard19 * Sutherland–Hodgman walk, one edge at a time.20 *21 * Returns a closed ring, or `[]` when nothing survives. The result may start22 * at a different vertex than the input: a clip has no opinion about where a23 * loop begins.24 */25export function clipRingX(ring: Ring, minimumX: number, maximumX: number): Coordinate[] {26 let points = openRing(ring);27 const edges: readonly [number, (point: Coordinate) => boolean][] = [28 [minimumX, (point) => point[0] >= minimumX],29 [maximumX, (point) => point[0] <= maximumX],30 ];31 for (const [edge, keep] of edges) {32 const output: Coordinate[] = [];33 for (let at = 0; at < points.length; at++) {34 const current = points[at];35 const previous = points[(at + points.length - 1) % points.length];36 if (!current || !previous) continue;37 const currentIn = keep(current);38 if (currentIn !== keep(previous)) {39 const t = (edge - previous[0]) / (current[0] - previous[0]);40 output.push([edge, previous[1] + t * (current[1] - previous[1])]);41 }42 if (currentIn) output.push(current);43 }44 points = output;45 if (points.length === 0) return [];46 }47 const first = points[0];48 return first ? [...points, first] : [];49}5051/** The ring without its closing repeat, so the walk visits each vertex once. */52function openRing(ring: Ring): Coordinate[] {53 const first = ring[0];54 const last = ring[ring.length - 1];55 if (first && last && samePoint(first, last)) return ring.slice(0, -1);56 return [...ring];57}5859/** Whether two points are the same point. */60export function samePoint(a: Coordinate, b: Coordinate): boolean {61 return a[0] === b[0] && a[1] === b[1];62}6364/**65 * One plan cell as the rings a renderer actually fills, clipped to the ground.66 *67 * This is the whole of what a renderer would otherwise have to work out for68 * itself, and it is three rules in one place:69 *70 * - Most rings lie within the surface and come back as a single piece,71 * untouched — every geohash ring does.72 * - A cell circling a pole has its loop closed along the picture's own top73 * or bottom edge, closing point included: a pole cell's walk ends a world74 * over from where it began, and that closure spans the last tessellation75 * step. Dropping it leaves a sliver of ground the fill never covers, one76 * step wide, at the walk's own longitude.77 * - A ring that stayed continuous across the antimeridian is drawn twice —78 * clipped as it lies and clipped shifted a world each way — so the one79 * cell appears as its two pieces.80 *81 * The answer is a list of outer rings: one for a plain cell, more for a cell82 * the seam cut.83 */84export function cellRings(ground: Ground, cell: PlanCell): Coordinate[][] {85 const surface = surfaceExtent(ground);86 let ring: Coordinate[] = [...cell.ring];87 const first = ring[0];88 const last = ring[ring.length - 1];89 if (cell.pole && first && last) {90 const poleY = cell.pole === "north" ? surface[3] : surface[1];91 ring = [...ring, [last[0], poleY], [first[0], poleY], first];92 }93 // Only a sphere has a seam. A plane's cell that runs past the surface's94 // x-range — a real geohash overhanging bend-or's window, a world-square95 // cell beside a windowed lens — is honestly overhanging, not wrapping, and96 // cutting it into shifted pieces would tile ghost cells over the map.97 if (worldSurface(ground.world) !== "sphere") return [ring];98 if (ring.every(([x]) => x >= surface[0] && x <= surface[2])) return [ring];99 const width = surface[2] - surface[0];100 const pieces: Coordinate[][] = [];101 for (const shift of [0, -width, width]) {102 const clipped = clipRingX(103 ring.map(([x, y]): Coordinate => [x + shift, y]),104 surface[0],105 surface[2],106 );107 if (clipped.length >= 4) pieces.push(clipped);108 }109 return pieces;110}111112/** Whether a ring's last point is exactly its first. */113export function ringIsClosed(ring: Ring): boolean {114 const first = ring[0];115 const last = ring[ring.length - 1];116 return ring.length >= 2 && first !== undefined && last !== undefined && samePoint(first, last);117}118119/**120 * Whether a ring closes **on this ground**: its last point is its first, some121 * whole number of worlds over in x, on the same row.122 *123 * This — not exact equality — is the closure rule a continuous ring keeps. A124 * cell that circles a pole accumulates a whole world of longitude on the way125 * round, and its loop deliberately closes in the frame the walk ended in;126 * closing back to the opening frame would hand a renderer a segment sweeping127 * the entire planet. Consumers that need the loop as drawn should ask128 * {@link cellRings}, which resolves exactly this.129 */130export function ringClosesOn(ground: Ground, ring: Ring): boolean {131 const first = ring[0];132 const last = ring[ring.length - 1];133 if (ring.length < 4 || first === undefined || last === undefined) return false;134 if (last[1] !== first[1]) return false;135 const [minimumX, , maximumX] = surfaceExtent(ground);136 const width = maximumX - minimumX;137 if (width === 0) return samePoint(first, last);138 const worlds = (last[0] - first[0]) / width;139 return Math.abs(worlds - Math.round(worlds)) < 1e-9;140}