import { clamp } from "@common/utils"; import { shadowCast } from "./fov"; interface Point { x: number; y: number; } interface BresenhamOptions { minX?: number; maxX?: number; minY?: number; maxY?: number; /** * Connectivity mode for the walk. * - `4` (default) — 4-connected; diagonal moves split into two axis steps (`dx+dy+1` points total). * - `8` — 8-connected; diagonal moves emitted as a single step (`max(dx,dy)+1` points). * Useful for LOS / lighting where 4-connectivity would unfairly block diagonals. */ directions?: 4 | 8; } export interface BresenhamLineOptions extends BresenhamOptions { } interface BresenhamCircleBaseOptions extends BresenhamOptions { /** * - `false` (default) — outline only. * - `true` — filled disc (span-from-outline scanline). * - `'fov'` — ray-casting field of view. */ fill?: T; } type BresenhamCircleFillOptions = BresenhamCircleBaseOptions; interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov' | 'shadow'> { /** * A callback that is called for each point on the ray casted from the start * point to the current point. If the callback returns `true`, the ray is * terminated and the generator skips to the next ray. */ breaker?: (x: number, y: number) => boolean; } type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions; // --------------------------------------------------------------------------- // Cohen-Sutherland outcodes // --------------------------------------------------------------------------- const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8; function outcode( x: number, y: number, minX: number, maxX: number, minY: number, maxY: number, ): number { let code = INSIDE; if (x < minX) code |= LEFT; else if (x > maxX) code |= RIGHT; if (y < minY) code |= BOTTOM; else if (y > maxY) code |= TOP; return code; } /** * Clip a floating-point segment to [minX, maxX] × [minY, maxY] using * Cohen-Sutherland. Returns the clipped endpoints as floats, or null if * the segment is entirely outside. * * We work in floats here so that the clipped endpoints land precisely on * the boundary; Bresenham then re-discretises from those clipped floats. */ function cohenSutherland( x0: number, y0: number, x1: number, y1: number, minX: number, maxX: number, minY: number, maxY: number, ): [number, number, number, number] | null { let code0 = outcode(x0, y0, minX, maxX, minY, maxY); let code1 = outcode(x1, y1, minX, maxX, minY, maxY); for (; ;) { if (!(code0 | code1)) return [x0, y0, x1, y1]; // trivially inside if (code0 & code1) return null; // trivially outside // Pick an outside point, clip it to the boundary it crosses. const codeOut = code0 || code1; let x = 0, y = 0; const dx = x1 - x0, dy = y1 - y0; if (codeOut & TOP) { x = x0 + dx * (maxY - y0) / dy; y = maxY; } else if (codeOut & BOTTOM) { x = x0 + dx * (minY - y0) / dy; y = minY; } else if (codeOut & RIGHT) { y = y0 + dy * (maxX - x0) / dx; x = maxX; } else { y = y0 + dy * (minX - x0) / dx; x = minX; } if (codeOut === code0) { x0 = x; y0 = y; code0 = outcode(x0, y0, minX, maxX, minY, maxY); } else { x1 = x; y1 = y; code1 = outcode(x1, y1, minX, maxX, minY, maxY); } } } // --------------------------------------------------------------------------- // Main function // --------------------------------------------------------------------------- /** * Bresenham's line algorithm on a 2-D integer grid. * * Produces the minimal set of grid cells that a line from `(fromX, fromY)` * to `(toX, toY)` passes through, in order from start to end. * * **Guarantees:** * - `result[0]` is `(fromX, fromY)` and `result[last]` is `(toX, toY)` (when inside bounds). * - `directions=4`: every consecutive pair is 4-connected (shares an edge). * - `directions=8`: every consecutive pair is 8-connected (shares an edge or corner). * - All returned points satisfy the optional axis-aligned clip bounds. * - Works for any direction and slope, including axis-aligned and perfectly diagonal. * * **Clipping:** * When bounds are supplied, the segment is first clipped to the rectangle using * Cohen-Sutherland, then only the clipped range is walked. Both clipped endpoints * are included in the output. Returns an empty array if the segment lies entirely * outside the bounds. * * @param fromX - Integer x of the start point. * @param fromY - Integer y of the start point. * @param toX - Integer x of the end point. * @param toY - Integer y of the end point. * @param options - Optional clip rectangle and connectivity mode. Non-integer bounds * are floored (min) / ceiled (max) so boundary cells are included * when the segment grazes them. * @returns Ordered array of integer grid points. Empty when the segment lies entirely * outside the supplied bounds. */ export function* bresenhamLineGen( fromX: number, fromY: number, toX: number, toY: number, options?: BresenhamLineOptions, ): Generator { // Round inputs to integers — the algorithm is defined on integer grids. let x0 = Math.round(fromX), y0 = Math.round(fromY); let x1 = Math.round(toX), y1 = Math.round(toY); // --- Clipping --- if (options) { const minX = Math.floor(options.minX ?? -Infinity); const maxX = Math.ceil(options.maxX ?? Infinity); const minY = Math.floor(options.minY ?? -Infinity); const maxY = Math.ceil(options.maxY ?? Infinity); const clipped = cohenSutherland(x0, y0, x1, y1, minX, maxX, minY, maxY); if (!clipped) return; // Re-snap clipped floats to the nearest integer cell that is inside bounds. x0 = Math.round(clipped[0]); y0 = Math.round(clipped[1]); x1 = Math.round(clipped[2]); y1 = Math.round(clipped[3]); // Clamp to ensure rounding didn't push us one cell outside. x0 = clamp(x0, minX, maxX); y0 = clamp(y0, minY, maxY); x1 = clamp(x1, minX, maxX); y1 = clamp(y1, minY, maxY); } // --- Bresenham walk --- const use8 = options?.directions === 8; const dx = Math.abs(x1 - x0); const dy = Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; // step direction on X const sy = y0 < y1 ? 1 : -1; // step direction on Y let x = x0, y = y0; if (dx === 0 && dy === 0) { // Single point. yield { x, y }; return; } if (dx >= dy) { // X is the driving axis. let err = 2 * dy - dx; for (let i = 0; i <= dx; i++) { yield { x, y }; if (i === dx) break; if (err >= 0) { if (!use8) yield { x, y: y + sy }; // 4-connected: emit y step before x y += sy; err -= 2 * dx; } err += 2 * dy; x += sx; } } else { // Y is the driving axis. let err = 2 * dx - dy; for (let i = 0; i <= dy; i++) { yield { x, y }; if (i === dy) break; if (err >= 0) { if (!use8) yield { x: x + sx, y }; // 4-connected: emit x step before y x += sx; err -= 2 * dy; } err += 2 * dx; y += sy; } } } /** * Array-returning wrapper for {@link bresenhamLineGen}. * @see bresenhamLineGen for full parameter and algorithm documentation. */ export const bresenhamLine = ( fromX: number, fromY: number, toX: number, toY: number, options?: BresenhamLineOptions, ): Point[] => Array.from(bresenhamLineGen(fromX, fromY, toX, toY, options)); ///////// /** * Yields integer grid points forming a circle outline or filled disc centred * on `(x, y)` with radius `r`. * * --- * * ## Outline mode (`fill: false`, default) — midpoint circle algorithm * * Tracks one point `(ox, oy)` in the first octant (`0 ≤ ox ≤ oy`) and uses * 8-fold symmetry to emit up to 8 points per step. A decision variable `d` * (initialised to `1 − r`) tracks which side of the true circle boundary the * next midpoint falls on: * * - `d < 0` → midpoint is inside → keep `oy`, update `d += 2·ox + 3` * - `d ≥ 0` → midpoint is outside → `oy--`, update `d += 2·(ox − oy) + 5` * * `ox` increments every step; the loop runs while `ox ≤ oy`. * * Duplicate-point guards: * - The `−ox` reflections are skipped when `ox === 0` (axis crossings). * - The swapped octant block is skipped when `ox === oy` (45° diagonal). * * ## Fill mode (`fill: true`) — span-from-outline scanline * * Runs the same midpoint algorithm to collect the horizontal half-width * (`spanRight`) for every row offset `|dy|`, then fills each row from * `cx − spanRight[|dy|]` to `cx + spanRight[|dy|]` inclusive. The fill * boundary is therefore **pixel-for-pixel identical** to the outline. * * ## FOV mode (`fill: 'fov'`) — ray-casting field of view * * For each outline point, casts an 4-connected Bresenham ray from the centre * to that point and yields every cell along the ray. You can pass `breaker` * to signal an obstacle — the rest of that ray is then * skipped and the next outline point's ray begins. The obstacle cell itself * is yielded (obstacle is visible). * * **Bounds:** The outline is always generated without clipping so that rays * extend to the full circle perimeter. The individual rays are clipped to * the supplied bounds, so only in-bounds cells are yielded. * * --- * * ## Clipping * * Points outside `[minX, maxX] × [minY, maxY]` are silently skipped. * Bounds are floored/ceiled to integer cells. * * @param x - Centre x (rounded to nearest integer). * @param y - Centre y (rounded to nearest integer). * @param r - Radius in grid cells (clamped to `≥ 0`, rounded). Radius 0 * yields only the centre point. * @param options - Optional `fill` flag and clip bounds. */ export function* bresenhamCircleGen( x: number, y: number, r: number, options?: BresenhamCircleOptions, ): Generator { if (options?.fill === 'fov') { const lineBounds: BresenhamLineOptions = { directions: options.directions, minX: options.minX, maxX: options.maxX, minY: options.minY, maxY: options.maxY, }; for (const { x: ox, y: oy } of bresenhamCircleGen(x, y, r, { fill: false })) { for (const linePoint of bresenhamLineGen(x, y, ox, oy, lineBounds)) { yield linePoint; if (options?.breaker?.(linePoint.x, linePoint.y)) break; } } return; } const cx = Math.round(x); const cy = Math.round(y); const radius = Math.max(0, Math.round(r)); const minX = options?.minX !== undefined ? Math.floor(options.minX) : -Infinity; const maxX = options?.maxX !== undefined ? Math.ceil(options.maxX) : Infinity; const minY = options?.minY !== undefined ? Math.floor(options.minY) : -Infinity; const maxY = options?.maxY !== undefined ? Math.ceil(options.maxY) : Infinity; const fill = options?.fill === true; const inBounds = (px: number, py: number) => px >= minX && px <= maxX && py >= minY && py <= maxY; if (radius === 0) { if (inBounds(cx, cy)) yield { x: cx, y: cy }; return; } if (options?.fill === 'shadow') { yield* shadowCast(x, y, r, (x, y) => { if (!inBounds(x, y)) return true; return options?.breaker?.(x, y) ?? false; }); return; } if (fill) { // Span-from-outline: use the midpoint algorithm to find the exact // horizontal extent for each row, then fill between those extents. // Guarantees the fill boundary matches the outline pixel-for-pixel. const spanRight = new Int32Array(radius + 1); // index = |dy|, value = max ox let ox = 0, oy = radius, d = 1 - radius; while (ox <= oy) { // oy is the half-width for rows ±ox; ox is the half-width for rows ±oy. if (spanRight[oy] < ox) spanRight[oy] = ox; if (spanRight[ox] < oy) spanRight[ox] = oy; if (d < 0) { d += 2 * ox + 3; } else { d += 2 * (ox - oy) + 5; oy--; } ox++; } for (let dy = -radius; dy <= radius; dy++) { const dxMax = spanRight[Math.abs(dy)]; for (let dx = -dxMax; dx <= dxMax; dx++) { const px = cx + dx, py = cy + dy; if (inBounds(px, py)) yield { x: px, y: py }; } } return; } // Midpoint circle algorithm (Bresenham) for outline. // Guards prevent duplicate points at axis crossings (ox === 0) and on the // 45° diagonal (ox === oy) where naïve 8-fold expansion collapses octants. let ox = 0; let oy = radius; let d = 1 - radius; while (ox <= oy) { // Primary octant and its y-reflection (top / bottom). if (inBounds(cx + ox, cy + oy)) yield { x: cx + ox, y: cy + oy }; if (ox > 0 && inBounds(cx - ox, cy + oy)) yield { x: cx - ox, y: cy + oy }; if (inBounds(cx + ox, cy - oy)) yield { x: cx + ox, y: cy - oy }; if (ox > 0 && inBounds(cx - ox, cy - oy)) yield { x: cx - ox, y: cy - oy }; // Swapped octant (left / right), skipped on the diagonal where it // would duplicate the primary octant points. if (ox < oy) { if (inBounds(cx + oy, cy + ox)) yield { x: cx + oy, y: cy + ox }; if (inBounds(cx - oy, cy + ox)) yield { x: cx - oy, y: cy + ox }; if (ox > 0 && inBounds(cx + oy, cy - ox)) yield { x: cx + oy, y: cy - ox }; if (ox > 0 && inBounds(cx - oy, cy - ox)) yield { x: cx - oy, y: cy - ox }; } if (d < 0) { d += 2 * ox + 3; } else { d += 2 * (ox - oy) + 5; oy--; } ox++; } } /** * Array-returning wrapper for {@link bresenhamCircleGen}. * @see bresenhamCircleGen for full parameter and algorithm documentation. */ export const bresenhamCircle = ( x: number, y: number, r: number, options?: BresenhamCircleFillOptions, ): Point[] => Array.from(bresenhamCircleGen(x, y, r, options));