1
0
Fork 0

Bresehnam line

This commit is contained in:
Pabloader 2026-05-04 12:28:08 +00:00
parent 3f1be9a24c
commit 8a4d9dc13f
2 changed files with 359 additions and 0 deletions

View File

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

View File

@ -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]]));
});
});
});