Skip to content

Commit ccdd225

Browse files
committed
Add tcod.path.path2d function
The library really needed a simple A to B pathfinding function
1 parent 76f8731 commit ccdd225

File tree

2 files changed

+154
-7
lines changed

2 files changed

+154
-7
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- `tcod.path.path2d` computes a path for the most basic cases.
12+
913
### Fixed
1014

1115
- `tcod.noise.grid` would raise `TypeError` when given a plain integer for scale.

tcod/path.py

+150-7
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import functools
2222
import itertools
2323
import warnings
24-
from typing import TYPE_CHECKING, Any, Callable, Final
24+
from typing import TYPE_CHECKING, Any, Callable, Final, Sequence
2525

2626
import numpy as np
2727
from typing_extensions import Literal, Self
@@ -324,7 +324,7 @@ def _export_dict(array: NDArray[Any]) -> dict[str, Any]:
324324
}
325325

326326

327-
def _export(array: NDArray[np.number]) -> Any: # noqa: ANN401
327+
def _export(array: NDArray[np.integer]) -> Any: # noqa: ANN401
328328
"""Convert a NumPy array into a cffi object."""
329329
return ffi.new("struct NArray*", _export_dict(array))
330330

@@ -355,8 +355,8 @@ def dijkstra2d( # noqa: PLR0913
355355
diagonal: int | None = None,
356356
*,
357357
edge_map: ArrayLike | None = None,
358-
out: NDArray[np.number] | None = ..., # type: ignore[assignment, unused-ignore]
359-
) -> NDArray[Any]:
358+
out: NDArray[np.integer] | None = ..., # type: ignore[assignment, unused-ignore]
359+
) -> NDArray[np.integer]:
360360
"""Return the computed distance of all nodes on a 2D Dijkstra grid.
361361
362362
`distance` is an input array of node distances. Is this often an
@@ -528,7 +528,7 @@ def hillclimb2d(
528528
diagonal: bool | None = None,
529529
*,
530530
edge_map: ArrayLike | None = None,
531-
) -> NDArray[Any]:
531+
) -> NDArray[np.intc]:
532532
"""Return a path on a grid from `start` to the lowest point.
533533
534534
`distance` should be a fully computed distance array. This kind of array
@@ -1289,7 +1289,7 @@ def resolve(self, goal: tuple[int, ...] | None = None) -> None:
12891289
self._update_heuristic(goal)
12901290
self._graph._resolve(self)
12911291

1292-
def path_from(self, index: tuple[int, ...]) -> NDArray[Any]:
1292+
def path_from(self, index: tuple[int, ...]) -> NDArray[np.intc]:
12931293
"""Return the shortest path from `index` to the nearest root.
12941294
12951295
The returned array is of shape `(length, ndim)` where `length` is the
@@ -1343,7 +1343,7 @@ def path_from(self, index: tuple[int, ...]) -> NDArray[Any]:
13431343
)
13441344
return path[:, ::-1] if self._order == "F" else path
13451345

1346-
def path_to(self, index: tuple[int, ...]) -> NDArray[Any]:
1346+
def path_to(self, index: tuple[int, ...]) -> NDArray[np.intc]:
13471347
"""Return the shortest path from the nearest root to `index`.
13481348
13491349
See :any:`path_from`.
@@ -1370,3 +1370,146 @@ def path_to(self, index: tuple[int, ...]) -> NDArray[Any]:
13701370
[]
13711371
"""
13721372
return self.path_from(index)[::-1]
1373+
1374+
1375+
def path2d( # noqa: C901, PLR0912, PLR0913
1376+
cost: ArrayLike,
1377+
*,
1378+
start_points: Sequence[tuple[int, int]],
1379+
end_points: Sequence[tuple[int, int]],
1380+
cardinal: int,
1381+
diagonal: int | None = None,
1382+
check_bounds: bool = True,
1383+
) -> NDArray[np.intc]:
1384+
"""Return a path between `start_points` and `end_points`.
1385+
1386+
If `start_points` or `end_points` has only one item then this is equivalent to A*.
1387+
Otherwise it is equivalent to Dijkstra.
1388+
1389+
If multiple `start_points` or `end_points` are given then the single shortest path between them is returned.
1390+
1391+
Points placed on nodes with a cost of 0 are treated as always reachable from adjacent nodes.
1392+
1393+
Args:
1394+
cost: A 2D array of integers with the cost of each node.
1395+
start_points: A sequence of one or more starting points indexing `cost`.
1396+
end_points: A sequence of one or more ending points indexing `cost`.
1397+
cardinal: The relative cost to move a cardinal direction.
1398+
diagonal: The relative cost to move a diagonal direction.
1399+
`None` or `0` will disable diagonal movement.
1400+
check_bounds: If `False` then out-of-bounds points are silently ignored.
1401+
If `True` (default) then out-of-bounds points raise :any:`IndexError`.
1402+
1403+
Returns:
1404+
A `(length, 2)` array of indexes of the path including the start and end points.
1405+
If there is no path then an array with zero items will be returned.
1406+
1407+
Example::
1408+
1409+
# Note: coordinates in this example are (i, j), or (y, x)
1410+
>>> cost = np.array([
1411+
... [1, 0, 1, 1, 1, 0, 1],
1412+
... [1, 0, 1, 1, 1, 0, 1],
1413+
... [1, 0, 1, 0, 1, 0, 1],
1414+
... [1, 1, 1, 1, 1, 0, 1],
1415+
... ])
1416+
1417+
# Endpoints are reachable even when endpoints are on blocked nodes
1418+
>>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(2, 3)], cardinal=70, diagonal=99)
1419+
array([[0, 0],
1420+
[1, 0],
1421+
[2, 0],
1422+
[3, 1],
1423+
[2, 2],
1424+
[2, 3]], dtype=int...)
1425+
1426+
# Unreachable endpoints return a zero length array
1427+
>>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(3, 6)], cardinal=70, diagonal=99)
1428+
array([], shape=(0, 2), dtype=int...)
1429+
>>> tcod.path.path2d(cost, start_points=[(0, 0), (3, 0)], end_points=[(0, 6), (3, 6)], cardinal=70, diagonal=99)
1430+
array([], shape=(0, 2), dtype=int...)
1431+
>>> tcod.path.path2d(cost, start_points=[], end_points=[], cardinal=70, diagonal=99)
1432+
array([], shape=(0, 2), dtype=int...)
1433+
1434+
# Overlapping endpoints return a single step
1435+
>>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(0, 0)], cardinal=70, diagonal=99)
1436+
array([[0, 0]], dtype=int32)
1437+
1438+
# Multiple endpoints return the shortest path
1439+
>>> tcod.path.path2d(
1440+
... cost, start_points=[(0, 0)], end_points=[(1, 3), (3, 3), (2, 2), (2, 4)], cardinal=70, diagonal=99)
1441+
array([[0, 0],
1442+
[1, 0],
1443+
[2, 0],
1444+
[3, 1],
1445+
[2, 2]], dtype=int...)
1446+
>>> tcod.path.path2d(
1447+
... cost, start_points=[(0, 0), (0, 2)], end_points=[(1, 3), (3, 3), (2, 2), (2, 4)], cardinal=70, diagonal=99)
1448+
array([[0, 2],
1449+
[1, 3]], dtype=int...)
1450+
>>> tcod.path.path2d(cost, start_points=[(0, 0), (0, 2)], end_points=[(3, 2)], cardinal=1)
1451+
array([[0, 2],
1452+
[1, 2],
1453+
[2, 2],
1454+
[3, 2]], dtype=int...)
1455+
1456+
# Checking for out-of-bounds points may be toggled
1457+
>>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(-1, -1), (3, 1)], cardinal=1)
1458+
Traceback (most recent call last):
1459+
...
1460+
IndexError: End point (-1, -1) is out-of-bounds of cost shape (4, 7)
1461+
>>> tcod.path.path2d(cost, start_points=[(0, 0)], end_points=[(-1, -1), (3, 1)], cardinal=1, check_bounds=False)
1462+
array([[0, 0],
1463+
[1, 0],
1464+
[2, 0],
1465+
[3, 0],
1466+
[3, 1]], dtype=int...)
1467+
1468+
.. versionadded:: Unreleased
1469+
"""
1470+
cost = np.copy(cost) # Copy array to later modify nodes to be always reachable
1471+
1472+
# Check bounds of endpoints
1473+
if check_bounds:
1474+
for points, name in [(start_points, "start"), (end_points, "end")]:
1475+
for i, j in points:
1476+
if not (0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]):
1477+
msg = f"{name.capitalize()} point {(i, j)!r} is out-of-bounds of cost shape {cost.shape!r}"
1478+
raise IndexError(msg)
1479+
else:
1480+
start_points = [(i, j) for i, j in start_points if 0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]]
1481+
end_points = [(i, j) for i, j in end_points if 0 <= i < cost.shape[0] and 0 <= j < cost.shape[1]]
1482+
1483+
if not start_points or not end_points:
1484+
return np.zeros((0, 2), dtype=np.intc) # Missing endpoints
1485+
1486+
# Check if endpoints can be manipulated to use A* for a one-to-many computation
1487+
reversed_path = False
1488+
if len(end_points) == 1 and len(start_points) > 1:
1489+
# Swap endpoints to ensure single start point as the A* goal
1490+
reversed_path = True
1491+
start_points, end_points = end_points, start_points
1492+
1493+
for ij in start_points:
1494+
cost[ij] = 1 # Enforce reachability of endpoint
1495+
1496+
graph = SimpleGraph(cost=cost, cardinal=cardinal, diagonal=diagonal or 0)
1497+
pf = Pathfinder(graph)
1498+
for ij in end_points:
1499+
pf.add_root(ij)
1500+
1501+
if len(start_points) == 1: # Compute A* from possibly multiple roots to one goal
1502+
out = pf.path_from(start_points[0])
1503+
if pf.distance[start_points[0]] == np.iinfo(pf.distance.dtype).max:
1504+
return np.zeros((0, 2), dtype=np.intc) # Unreachable endpoint
1505+
if reversed_path:
1506+
out = out[::-1]
1507+
return out
1508+
1509+
# Crude Dijkstra implementation until issues with Pathfinder are fixed
1510+
pf.resolve(None)
1511+
best_distance, best_ij = min((pf.distance[ij], ij) for ij in start_points)
1512+
if best_distance == np.iinfo(pf.distance.dtype).max:
1513+
return np.zeros((0, 2), dtype=np.intc) # All endpoints unreachable
1514+
1515+
return hillclimb2d(pf.distance, best_ij, cardinal=bool(cardinal), diagonal=bool(diagonal))

0 commit comments

Comments
 (0)