402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
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<T extends boolean | 'fov' | 'shadow'> extends BresenhamOptions {
|
||
/**
|
||
* - `false` (default) — outline only.
|
||
* - `true` — filled disc (span-from-outline scanline).
|
||
* - `'fov'` — ray-casting field of view.
|
||
*/
|
||
fill?: T;
|
||
}
|
||
|
||
type BresenhamCircleFillOptions = BresenhamCircleBaseOptions<boolean>;
|
||
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<Point, void, void> {
|
||
// 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<Point, void, void> {
|
||
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));
|