diff --git a/src/common/navigation/bresenham.ts b/src/common/navigation/bresenham.ts new file mode 100644 index 0000000..be3c6e3 --- /dev/null +++ b/src/common/navigation/bresenham.ts @@ -0,0 +1,194 @@ +import { clamp } from "@common/utils"; + +export interface Point { + x: number; + y: number; +} + +export 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; +} + +// --------------------------------------------------------------------------- +// 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 bresenham( + fromX: number, + fromY: number, + toX: number, + toY: number, + options?: BresenhamOptions, +): Point[] { + // 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 points: Point[] = []; + 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. + points.push({ x, y }); + return points; + } + + if (dx >= dy) { + // X is the driving axis. + let err = 2 * dy - dx; + for (let i = 0; i <= dx; i++) { + points.push({ x, y }); + if (i === dx) break; + if (err >= 0) { + if (!use8) points.push({ 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++) { + points.push({ x, y }); + if (i === dy) break; + if (err >= 0) { + if (!use8) points.push({ x: x + sx, y }); // 4-connected: emit x step before y + x += sx; + err -= 2 * dy; + } + err += 2 * dx; + y += sy; + } + } + + return points; +} diff --git a/test/common/navigation/bresenham.test.ts b/test/common/navigation/bresenham.test.ts new file mode 100644 index 0000000..06c574a --- /dev/null +++ b/test/common/navigation/bresenham.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "bun:test"; +import { bresenham } from "@common/navigation/bresenham"; + +// Helpers +const pts = (coords: [number, number][]) => + coords.map(([x, y]) => ({ x, y })); + +function is4Connected(points: { x: number; y: number }[]): boolean { + for (let i = 1; i < points.length; i++) { + const dx = Math.abs(points[i].x - points[i - 1].x); + const dy = Math.abs(points[i].y - points[i - 1].y); + if (dx + dy !== 1) return false; + } + return true; +} + +function is8Connected(points: { x: number; y: number }[]): boolean { + for (let i = 1; i < points.length; i++) { + const dx = Math.abs(points[i].x - points[i - 1].x); + const dy = Math.abs(points[i].y - points[i - 1].y); + if (dx > 1 || dy > 1) return false; + } + return true; +} + +describe("bresenham", () => { + describe("single point", () => { + it("returns one point when start === end", () => { + expect(bresenham(3, 3, 3, 3)).toEqual(pts([[3, 3]])); + }); + }); + + describe("axis-aligned lines", () => { + it("horizontal right", () => { + expect(bresenham(0, 0, 3, 0)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); + }); + + it("horizontal left", () => { + expect(bresenham(3, 0, 0, 0)).toEqual(pts([[3, 0], [2, 0], [1, 0], [0, 0]])); + }); + + it("vertical down", () => { + expect(bresenham(0, 0, 0, 3)).toEqual(pts([[0, 0], [0, 1], [0, 2], [0, 3]])); + }); + + it("vertical up", () => { + expect(bresenham(0, 3, 0, 0)).toEqual(pts([[0, 3], [0, 2], [0, 1], [0, 0]])); + }); + }); + + describe("diagonal lines", () => { + it("perfect diagonal — directions=4 splits into axis steps", () => { + const result = bresenham(0, 0, 2, 2); + expect(result[0]).toEqual({ x: 0, y: 0 }); + expect(result[result.length - 1]).toEqual({ x: 2, y: 2 }); + expect(is4Connected(result)).toBe(true); + // dx+dy+1 = 2+2+1 = 5 points + expect(result.length).toBe(5); + }); + + it("perfect diagonal — directions=8 emits single diagonal steps", () => { + const result = bresenham(0, 0, 2, 2, { directions: 8 }); + expect(result).toEqual(pts([[0, 0], [1, 1], [2, 2]])); + }); + + it("diagonal all quadrants produce correct endpoints", () => { + const cases: [[number, number, number, number]] = [ + [0, 0, 3, 3], + [0, 0, -3, 3], + [0, 0, 3, -3], + [0, 0, -3, -3], + ] as any; + for (const [fx, fy, tx, ty] of cases) { + const r = bresenham(fx, fy, tx, ty, { directions: 8 }); + expect(r[0]).toEqual({ x: fx, y: fy }); + expect(r[r.length - 1]).toEqual({ x: tx, y: ty }); + expect(is8Connected(r)).toBe(true); + } + }); + }); + + describe("connectivity guarantees", () => { + it("directions=4 (default) is always 4-connected", () => { + // steep slope + expect(is4Connected(bresenham(0, 0, 3, 7))).toBe(true); + // shallow slope + expect(is4Connected(bresenham(0, 0, 7, 3))).toBe(true); + // negative direction + expect(is4Connected(bresenham(5, 5, -2, 1))).toBe(true); + }); + + it("directions=8 is always 8-connected", () => { + expect(is8Connected(bresenham(0, 0, 3, 7, { directions: 8 }))).toBe(true); + expect(is8Connected(bresenham(0, 0, 7, 3, { directions: 8 }))).toBe(true); + expect(is8Connected(bresenham(5, 5, -2, 1, { directions: 8 }))).toBe(true); + }); + + it("directions=4 output length is dx+dy+1", () => { + const r = bresenham(0, 0, 4, 3); + expect(r.length).toBe(4 + 3 + 1); + }); + + it("directions=8 output length is max(dx,dy)+1", () => { + const r = bresenham(0, 0, 4, 3, { directions: 8 }); + expect(r.length).toBe(Math.max(4, 3) + 1); + }); + + it("start and end are always first and last points", () => { + const r = bresenham(1, 2, 5, 8); + expect(r[0]).toEqual({ x: 1, y: 2 }); + expect(r[r.length - 1]).toEqual({ x: 5, y: 8 }); + }); + }); + + describe("clipping", () => { + it("returns empty array when segment is entirely outside bounds", () => { + expect(bresenham(10, 10, 20, 20, { minX: 0, maxX: 5, minY: 0, maxY: 5 })).toEqual([]); + }); + + it("clips start when it lies outside bounds", () => { + const r = bresenham(-5, 0, 5, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); + expect(r[0].x).toBeGreaterThanOrEqual(0); + expect(r[r.length - 1]).toEqual({ x: 5, y: 0 }); + }); + + it("clips end when it lies outside bounds", () => { + const r = bresenham(0, 0, 15, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); + expect(r[0]).toEqual({ x: 0, y: 0 }); + expect(r[r.length - 1].x).toBeLessThanOrEqual(10); + }); + + it("all returned points are within bounds", () => { + const bounds = { minX: 1, maxX: 8, minY: 1, maxY: 8 }; + const r = bresenham(0, 0, 10, 10, bounds); + for (const p of r) { + expect(p.x).toBeGreaterThanOrEqual(bounds.minX); + expect(p.x).toBeLessThanOrEqual(bounds.maxX); + expect(p.y).toBeGreaterThanOrEqual(bounds.minY); + expect(p.y).toBeLessThanOrEqual(bounds.maxY); + } + }); + + it("segment touching only a corner of bounds returns at least one point", () => { + // line passes through (0,0) exactly, bounds include only (0,0) + const r = bresenham(-2, -2, 2, 2, { minX: 0, maxX: 0, minY: 0, maxY: 0 }); + expect(r.length).toBeGreaterThan(0); + }); + + it("clipping preserves 4-connectivity within bounds", () => { + const r = bresenham(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6 }); + expect(is4Connected(r)).toBe(true); + }); + + it("clipping preserves 8-connectivity within bounds", () => { + const r = bresenham(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6, directions: 8 }); + expect(is8Connected(r)).toBe(true); + }); + }); + + describe("non-integer inputs", () => { + it("rounds float inputs to nearest integer", () => { + expect(bresenham(0.4, 0.4, 2.6, 0.4)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); + }); + }); +});