2121import functools
2222import itertools
2323import warnings
24- from typing import TYPE_CHECKING , Any , Callable , Final
24+ from typing import TYPE_CHECKING , Any , Callable , Final , Sequence
2525
2626import numpy as np
2727from 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