21
21
import functools
22
22
import itertools
23
23
import warnings
24
- from typing import TYPE_CHECKING , Any , Callable , Final
24
+ from typing import TYPE_CHECKING , Any , Callable , Final , Sequence
25
25
26
26
import numpy as np
27
27
from typing_extensions import Literal , Self
@@ -324,7 +324,7 @@ def _export_dict(array: NDArray[Any]) -> dict[str, Any]:
324
324
}
325
325
326
326
327
- def _export (array : NDArray [np .number ]) -> Any : # noqa: ANN401
327
+ def _export (array : NDArray [np .integer ]) -> Any : # noqa: ANN401
328
328
"""Convert a NumPy array into a cffi object."""
329
329
return ffi .new ("struct NArray*" , _export_dict (array ))
330
330
@@ -355,8 +355,8 @@ def dijkstra2d( # noqa: PLR0913
355
355
diagonal : int | None = None ,
356
356
* ,
357
357
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 ]:
360
360
"""Return the computed distance of all nodes on a 2D Dijkstra grid.
361
361
362
362
`distance` is an input array of node distances. Is this often an
@@ -528,7 +528,7 @@ def hillclimb2d(
528
528
diagonal : bool | None = None ,
529
529
* ,
530
530
edge_map : ArrayLike | None = None ,
531
- ) -> NDArray [Any ]:
531
+ ) -> NDArray [np . intc ]:
532
532
"""Return a path on a grid from `start` to the lowest point.
533
533
534
534
`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:
1289
1289
self ._update_heuristic (goal )
1290
1290
self ._graph ._resolve (self )
1291
1291
1292
- def path_from (self , index : tuple [int , ...]) -> NDArray [Any ]:
1292
+ def path_from (self , index : tuple [int , ...]) -> NDArray [np . intc ]:
1293
1293
"""Return the shortest path from `index` to the nearest root.
1294
1294
1295
1295
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]:
1343
1343
)
1344
1344
return path [:, ::- 1 ] if self ._order == "F" else path
1345
1345
1346
- def path_to (self , index : tuple [int , ...]) -> NDArray [Any ]:
1346
+ def path_to (self , index : tuple [int , ...]) -> NDArray [np . intc ]:
1347
1347
"""Return the shortest path from the nearest root to `index`.
1348
1348
1349
1349
See :any:`path_from`.
@@ -1370,3 +1370,146 @@ def path_to(self, index: tuple[int, ...]) -> NDArray[Any]:
1370
1370
[]
1371
1371
"""
1372
1372
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