Compare commits
No commits in common. "f955dc6d059942dc41fa174fabdb3c57782639f2" and "3f1be9a24c16edb757f9b60ac0e12eebe6884015" have entirely different histories.
f955dc6d05
...
3f1be9a24c
|
|
@ -1,414 +0,0 @@
|
||||||
/**
|
|
||||||
* A* pathfinding on an arbitrary graph.
|
|
||||||
*
|
|
||||||
* The graph is defined entirely through two callbacks you provide:
|
|
||||||
* - neighbours(node) → { node, cost }[] edge traversal
|
|
||||||
* - heuristic(a, b) → number admissible estimate of remaining cost
|
|
||||||
*
|
|
||||||
* Nodes are identified by a key function (default: String(node)).
|
|
||||||
* Works for any domain: grid cells, navmesh polygons, waypoint graphs,
|
|
||||||
* hex tiles, 3-D voxels, abstract state machines — anything.
|
|
||||||
*
|
|
||||||
* Guarantees:
|
|
||||||
* - Optimal path when heuristic is admissible (never over-estimates).
|
|
||||||
* - Complete: always finds a path if one exists.
|
|
||||||
* - O((V + E) log V) time via a binary min-heap open set.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const nav = new AStar<Vec2>({
|
|
||||||
* neighbours: (n) => grid.passableNeighbours(n).map(nb => ({ node: nb, cost: 1 })),
|
|
||||||
* heuristic: (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y),
|
|
||||||
* key: (n) => `${n.x},${n.y}`,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const result = nav.find(start, goal);
|
|
||||||
* if (result) console.log(result.path, result.cost);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Edge<N> {
|
|
||||||
node: N;
|
|
||||||
/** Traversal cost — must be > 0 for A* to be optimal. */
|
|
||||||
cost: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AStarOptions<N> {
|
|
||||||
/** Return the reachable neighbours of a node, with edge costs. */
|
|
||||||
neighbours: (node: N) => Iterable<Edge<N>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admissible heuristic — estimated cost from `a` to `b`.
|
|
||||||
* Must never exceed the true shortest cost (can under-estimate freely).
|
|
||||||
* Use `() => 0` for Dijkstra's algorithm.
|
|
||||||
*/
|
|
||||||
heuristic: (from: N, to: N) => number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique string key for a node.
|
|
||||||
* Default: String(node) — override whenever two distinct nodes could
|
|
||||||
* serialise the same way (objects, arrays, tuples…).
|
|
||||||
*/
|
|
||||||
key?: (node: N) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum number of nodes to expand before giving up.
|
|
||||||
* Useful as a safety valve in large or infinite graphs.
|
|
||||||
* Default: Infinity.
|
|
||||||
*/
|
|
||||||
maxExpansions?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AStarResult<N> {
|
|
||||||
/** Ordered list of nodes from start (inclusive) to goal (inclusive). */
|
|
||||||
path: N[];
|
|
||||||
/** Total accumulated edge cost along the path. */
|
|
||||||
cost: number;
|
|
||||||
/** Number of nodes popped from the open set during this search. */
|
|
||||||
expansions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Binary min-heap (open set)
|
|
||||||
// Keyed on f = g + h. Ties broken by higher g (closer to goal) — tends to
|
|
||||||
// reduce expansions on uniform grids and navmeshes.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface HeapItem<N> {
|
|
||||||
node: N;
|
|
||||||
nodeKey: string;
|
|
||||||
g: number; // cost from start
|
|
||||||
f: number; // g + h
|
|
||||||
}
|
|
||||||
|
|
||||||
class MinHeap<N> {
|
|
||||||
private data: HeapItem<N>[] = [];
|
|
||||||
|
|
||||||
get size(): number { return this.data.length; }
|
|
||||||
|
|
||||||
push(item: HeapItem<N>): void {
|
|
||||||
this.data.push(item);
|
|
||||||
this._bubbleUp(this.data.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(): HeapItem<N> | undefined {
|
|
||||||
if (this.data.length === 0) return undefined;
|
|
||||||
const top = this.data[0];
|
|
||||||
const last = this.data.pop()!;
|
|
||||||
if (this.data.length > 0) {
|
|
||||||
this.data[0] = last;
|
|
||||||
this._sinkDown(0);
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _bubbleUp(i: number): void {
|
|
||||||
while (i > 0) {
|
|
||||||
const parent = (i - 1) >> 1;
|
|
||||||
if (this._less(i, parent)) {
|
|
||||||
this._swap(i, parent);
|
|
||||||
i = parent;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sinkDown(i: number): void {
|
|
||||||
const n = this.data.length;
|
|
||||||
for (; ;) {
|
|
||||||
let best = i;
|
|
||||||
const l = 2 * i + 1, r = 2 * i + 2;
|
|
||||||
if (l < n && this._less(l, best)) best = l;
|
|
||||||
if (r < n && this._less(r, best)) best = r;
|
|
||||||
if (best === i) break;
|
|
||||||
this._swap(i, best);
|
|
||||||
i = best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lower f wins; equal f → higher g wins (prefer nodes closer to goal).
|
|
||||||
private _less(a: number, b: number): boolean {
|
|
||||||
const da = this.data[a], db = this.data[b];
|
|
||||||
return da.f < db.f || (da.f === db.f && da.g > db.g);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _swap(a: number, b: number): void {
|
|
||||||
[this.data[a], this.data[b]] = [this.data[b], this.data[a]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// A* class
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class AStar<N> {
|
|
||||||
private readonly neighbours: (node: N) => Iterable<Edge<N>>;
|
|
||||||
private readonly heuristic: (from: N, to: N) => number;
|
|
||||||
private readonly key: (node: N) => string;
|
|
||||||
private readonly maxExpansions: number;
|
|
||||||
|
|
||||||
constructor(options: AStarOptions<N>) {
|
|
||||||
this.neighbours = options.neighbours;
|
|
||||||
this.heuristic = options.heuristic;
|
|
||||||
this.key = options.key ?? ((n) => String(n));
|
|
||||||
this.maxExpansions = options.maxExpansions ?? Infinity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the shortest path from `start` to `goal`.
|
|
||||||
*
|
|
||||||
* @returns AStarResult if a path exists, null if unreachable or
|
|
||||||
* maxExpansions was exceeded.
|
|
||||||
*/
|
|
||||||
find(start: N, goal: N): AStarResult<N> | null {
|
|
||||||
const startKey = this.key(start);
|
|
||||||
const goalKey = this.key(goal);
|
|
||||||
|
|
||||||
// Early exit — already there.
|
|
||||||
if (startKey === goalKey) {
|
|
||||||
return { path: [start], cost: 0, expansions: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// g[k] = best known cost from start to node k
|
|
||||||
// cameFrom = for path reconstruction
|
|
||||||
const g = new Map<string, number>();
|
|
||||||
const cameFrom = new Map<string, { node: N; key: string }>();
|
|
||||||
|
|
||||||
g.set(startKey, 0);
|
|
||||||
|
|
||||||
const open = new MinHeap<N>();
|
|
||||||
open.push({
|
|
||||||
node: start,
|
|
||||||
nodeKey: startKey,
|
|
||||||
g: 0,
|
|
||||||
f: this.heuristic(start, goal),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nodes fully expanded (closed set).
|
|
||||||
const closed = new Set<string>();
|
|
||||||
|
|
||||||
let expansions = 0;
|
|
||||||
|
|
||||||
while (open.size > 0) {
|
|
||||||
if (expansions >= this.maxExpansions) return null;
|
|
||||||
|
|
||||||
const current = open.pop()!;
|
|
||||||
const { node, nodeKey, g: gCurrent } = current;
|
|
||||||
|
|
||||||
// Stale entry — a shorter path to this node was already processed.
|
|
||||||
if (closed.has(nodeKey)) continue;
|
|
||||||
closed.add(nodeKey);
|
|
||||||
expansions++;
|
|
||||||
|
|
||||||
if (nodeKey === goalKey) {
|
|
||||||
return {
|
|
||||||
path: this._reconstructPath(cameFrom, node, nodeKey, start, startKey),
|
|
||||||
cost: gCurrent,
|
|
||||||
expansions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const edge of this.neighbours(node)) {
|
|
||||||
if (edge.cost <= 0) {
|
|
||||||
throw new RangeError(
|
|
||||||
`A*: edge cost must be > 0, got ${edge.cost} from node "${nodeKey}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const neighbourKey = this.key(edge.node);
|
|
||||||
if (closed.has(neighbourKey)) continue;
|
|
||||||
|
|
||||||
const gNext = gCurrent + edge.cost;
|
|
||||||
const gBest = g.get(neighbourKey);
|
|
||||||
|
|
||||||
if (gBest === undefined || gNext < gBest) {
|
|
||||||
g.set(neighbourKey, gNext);
|
|
||||||
cameFrom.set(neighbourKey, { node, key: nodeKey });
|
|
||||||
open.push({
|
|
||||||
node: edge.node,
|
|
||||||
nodeKey: neighbourKey,
|
|
||||||
g: gNext,
|
|
||||||
f: gNext + this.heuristic(edge.node, goal),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // No path found.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconstruct the path by walking cameFrom back from goal to start.
|
|
||||||
*/
|
|
||||||
private _reconstructPath(
|
|
||||||
cameFrom: Map<string, { node: N; key: string }>,
|
|
||||||
goalNode: N,
|
|
||||||
goalKey: string,
|
|
||||||
startNode: N,
|
|
||||||
startKey: string,
|
|
||||||
): N[] {
|
|
||||||
const path: N[] = [];
|
|
||||||
let currentKey = goalKey;
|
|
||||||
let currentNode = goalNode;
|
|
||||||
|
|
||||||
while (currentKey !== startKey) {
|
|
||||||
path.push(currentNode);
|
|
||||||
const prev = cameFrom.get(currentKey)!;
|
|
||||||
currentKey = prev.key;
|
|
||||||
currentNode = prev.node;
|
|
||||||
}
|
|
||||||
|
|
||||||
path.push(startNode);
|
|
||||||
path.reverse();
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: check reachability without allocating a full path.
|
|
||||||
*/
|
|
||||||
isReachable(start: N, goal: N): boolean {
|
|
||||||
return this.find(start, goal) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-goal search — find the nearest reachable goal from a set.
|
|
||||||
*
|
|
||||||
* Runs a single A* from `start` using a combined heuristic:
|
|
||||||
* min h(node, goal) over all goals. Stops at the first goal expanded.
|
|
||||||
*
|
|
||||||
* @param goals Non-empty iterable of candidate goals.
|
|
||||||
*/
|
|
||||||
findNearest(start: N, goals: Iterable<N>): AStarResult<N> | null {
|
|
||||||
const goalList = [...goals];
|
|
||||||
if (goalList.length === 0) throw new RangeError('findNearest: goals is empty');
|
|
||||||
|
|
||||||
const goalKeys = new Set(goalList.map(g => this.key(g)));
|
|
||||||
const goalNodes = new Map(goalList.map(g => [this.key(g), g]));
|
|
||||||
|
|
||||||
const startKey = this.key(start);
|
|
||||||
|
|
||||||
if (goalKeys.has(startKey)) {
|
|
||||||
return { path: [start], cost: 0, expansions: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const heuristic = (node: N) =>
|
|
||||||
Math.min(...goalList.map(g => this.heuristic(node, g)));
|
|
||||||
|
|
||||||
const g = new Map<string, number>();
|
|
||||||
const cameFrom = new Map<string, { node: N; key: string }>();
|
|
||||||
|
|
||||||
g.set(startKey, 0);
|
|
||||||
|
|
||||||
const open = new MinHeap<N>();
|
|
||||||
open.push({ node: start, nodeKey: startKey, g: 0, f: heuristic(start) });
|
|
||||||
|
|
||||||
const closed = new Set<string>();
|
|
||||||
let expansions = 0;
|
|
||||||
|
|
||||||
while (open.size > 0) {
|
|
||||||
if (expansions >= this.maxExpansions) return null;
|
|
||||||
|
|
||||||
const current = open.pop()!;
|
|
||||||
const { node, nodeKey, g: gCurrent } = current;
|
|
||||||
|
|
||||||
if (closed.has(nodeKey)) continue;
|
|
||||||
closed.add(nodeKey);
|
|
||||||
expansions++;
|
|
||||||
|
|
||||||
if (goalKeys.has(nodeKey)) {
|
|
||||||
const goalNode = goalNodes.get(nodeKey)!;
|
|
||||||
return {
|
|
||||||
path: this._reconstructPath(cameFrom, goalNode, nodeKey, start, startKey),
|
|
||||||
cost: gCurrent,
|
|
||||||
expansions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const edge of this.neighbours(node)) {
|
|
||||||
const neighbourKey = this.key(edge.node);
|
|
||||||
if (closed.has(neighbourKey)) continue;
|
|
||||||
|
|
||||||
const gNext = gCurrent + edge.cost;
|
|
||||||
if (!g.has(neighbourKey) || gNext < g.get(neighbourKey)!) {
|
|
||||||
g.set(neighbourKey, gNext);
|
|
||||||
cameFrom.set(neighbourKey, { node, key: nodeKey });
|
|
||||||
open.push({
|
|
||||||
node: edge.node,
|
|
||||||
nodeKey: neighbourKey,
|
|
||||||
g: gNext,
|
|
||||||
f: gNext + heuristic(edge.node),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AStar2D — convenience subclass for 2-D grids
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type Node2D = [number, number];
|
|
||||||
|
|
||||||
export interface AStar2DOptions {
|
|
||||||
/**
|
|
||||||
* Return true if the cell (toX, toY) can be entered from (fromX, fromY).
|
|
||||||
* The from-coordinates let you model direction-dependent rules such as
|
|
||||||
* one-way ramps, wall-hugging costs, or diagonal corner-cutting checks.
|
|
||||||
*/
|
|
||||||
passable: (toX: number, toY: number, fromX: number, fromY: number) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cost to move from one cell to a neighbour.
|
|
||||||
* Receives the same arguments as `passable` after the passability check.
|
|
||||||
* Default: 1 for cardinal moves, √2 for diagonal moves.
|
|
||||||
*/
|
|
||||||
cost?: (toX: number, toY: number, fromX: number, fromY: number) => number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which neighbour directions to consider.
|
|
||||||
* - 4-directional (N S E W, default)
|
|
||||||
* - 8-directional
|
|
||||||
*/
|
|
||||||
directions?: 4 | 8;
|
|
||||||
|
|
||||||
/** Forwarded to AStar. */
|
|
||||||
maxExpansions?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRS_4: Node2D[] = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
|
||||||
const DIRS_8: Node2D[] = [...DIRS_4, [-1, -1], [-1, 1], [1, -1], [1, 1]];
|
|
||||||
|
|
||||||
export class AStar2D extends AStar<Node2D> {
|
|
||||||
constructor(options: AStar2DOptions) {
|
|
||||||
const dirs = options.directions === 8 ? DIRS_8 : DIRS_4;
|
|
||||||
const costFn = options.cost
|
|
||||||
?? ((tx, ty, fx, fy) => (tx !== fx && ty !== fy ? Math.SQRT2 : 1));
|
|
||||||
|
|
||||||
super({
|
|
||||||
neighbours: ([x, y]) => {
|
|
||||||
const edges: Edge<Node2D>[] = [];
|
|
||||||
for (const [dx, dy] of dirs) {
|
|
||||||
const nx = x + dx, ny = y + dy;
|
|
||||||
if (options.passable(nx, ny, x, y)) {
|
|
||||||
edges.push({ node: [nx, ny], cost: costFn(nx, ny, x, y) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return edges;
|
|
||||||
},
|
|
||||||
// Octile heuristic — consistent for 8-directional grids;
|
|
||||||
// falls back to Manhattan for cardinal-only (diagonal term is 0).
|
|
||||||
heuristic: ([ax, ay], [bx, by]) => {
|
|
||||||
const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
|
|
||||||
return dirs === DIRS_8
|
|
||||||
? (dx + dy) + (Math.SQRT2 - 2) * Math.min(dx, dy) // octile
|
|
||||||
: dx + dy; // manhattan
|
|
||||||
},
|
|
||||||
key: ([x, y]) => `${x},${y}`,
|
|
||||||
maxExpansions: options.maxExpansions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
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]]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue