// Geometry primitives used by the map renderer. // // All distances are in world units (TS numbers, float64). Functions // in this file are pure and side-effect-free; tests exercise them // directly. import type { Camera, Viewport } from "./world"; // clamp returns v constrained to [lo, hi]. If lo > hi the function // returns lo (callers are expected to keep the bounds well-formed). export function clamp(v: number, lo: number, hi: number): number { if (v < lo) return lo; if (v > hi) return hi; return v; } // torusShortestDelta returns the signed delta from a to b on a circle // of circumference `size`, picking the direction with the smaller // absolute distance. Result lies in (-size/2, size/2]. // // At exactly size/2 the function returns +size/2 (positive direction); // the lower bound is exclusive so a delta of -size/2 wraps to +size/2. // This deterministic tie-break keeps the function self-consistent // regardless of input order. The `+0` at the end normalises -0 (which // JavaScript produces for some modulo cases) to +0. export function torusShortestDelta(a: number, b: number, size: number): number { if (!(size > 0)) { throw new Error(`torusShortestDelta: size must be positive, got ${size}`); } let d = (b - a) % size; if (d > size / 2) d -= size; else if (d <= -size / 2) d += size; return d + 0; } // torusShortestDistance returns the wrap-aware Euclidean distance // between (ax, ay) and (bx, by) on a torus of size width × height. // Built on top of `torusShortestDelta` so the two axes share the // "shortest signed delta" semantics. Used by the Phase 29 reach // filter (hide planets beyond `FlightDistance` of every LOCAL // planet); both modes (torus / no-wrap) consume the same metric — in // no-wrap mode the wrapped distance is never shorter than the // straight-line one because the player cannot fly across the seam. export function torusShortestDistance( ax: number, ay: number, bx: number, by: number, width: number, height: number, ): number { const dx = torusShortestDelta(ax, bx, width); const dy = torusShortestDelta(ay, by, height); return Math.hypot(dx, dy); } // distSqPointToSegment returns the squared distance from point (px,py) // to the segment (ax,ay)–(bx,by). For zero-length segments it falls // back to point-to-point distance. export function distSqPointToSegment( px: number, py: number, ax: number, ay: number, bx: number, by: number, ): number { const dx = bx - ax; const dy = by - ay; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { const ex = px - ax; const ey = py - ay; return ex * ex + ey * ey; } let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; if (t < 0) t = 0; else if (t > 1) t = 1; const fx = ax + t * dx; const fy = ay + t * dy; const ex = px - fx; const ey = py - fy; return ex * ex + ey * ey; } // screenToWorld converts cursor pixel coordinates (relative to the // viewport top-left) to world coordinates under the given camera. export function screenToWorld( cursorPx: { x: number; y: number }, camera: Camera, viewport: Viewport, ): { x: number; y: number } { const offX = cursorPx.x - viewport.widthPx / 2; const offY = cursorPx.y - viewport.heightPx / 2; return { x: camera.centerX + offX / camera.scale, y: camera.centerY + offY / camera.scale, }; } // worldToScreen converts a world-space point to viewport pixel // coordinates under the given camera. export function worldToScreen( world: { x: number; y: number }, camera: Camera, viewport: Viewport, ): { x: number; y: number } { return { x: viewport.widthPx / 2 + (world.x - camera.centerX) * camera.scale, y: viewport.heightPx / 2 + (world.y - camera.centerY) * camera.scale, }; }