Source code for simvx.core.navigation

"""2D Navigation and Pathfinding for SimVX.

Provides graph-based and grid-based A* pathfinding, plus a NavigationAgent2D
node that follows computed paths with steering and emits signals on arrival.

Usage:
    from simvx.core import PathFinder2D, NavGrid2D, NavigationAgent2D

    # Graph-based pathfinding
    astar = PathFinder2D()
    astar.add_point(0, (0, 0))
    astar.add_point(1, (10, 0))
    astar.connect_points(0, 1)
    path = astar.get_point_path(0, 1)  # [(0,0), (10,0)]

    # Grid-based pathfinding
    grid = NavGrid2D(20, 20, cell_size=32.0)
    grid.set_solid(5, 5)
    path = grid.find_path((0, 0), (10, 10))

    # Navigation agent node
    agent = NavigationAgent2D()
    agent.set_navigation(astar)
    agent.target_position = (10, 0)
"""

import heapq
import logging
import math

from ..descriptors import Property
from ..math.types import Vec2
from ..nodes_2d.node2d import Node2D
from ..signals import Signal

log = logging.getLogger(__name__)

__all__ = ["PathFinder2D", "NavGrid2D", "NavigationAgent2D", "PathFollower2D"]

[docs] class PathFinder2D: """Graph-based A* pathfinding. Supports both grid-based and arbitrary graphs.""" __slots__ = ("_points", "_connections", "_weights", "_disabled") def __init__(self): self._points: dict[int, tuple[float, float]] = {} self._connections: dict[int, set[int]] = {} self._weights: dict[int, float] = {} self._disabled: set[int] = set()
[docs] def add_point(self, id: int, position: tuple[float, float], weight: float = 1.0): """Add a point to the graph. Weight scales traversal cost (default 1.0).""" self._points[id] = (float(position[0]), float(position[1])) self._weights[id] = weight if id not in self._connections: self._connections[id] = set()
[docs] def remove_point(self, id: int): """Remove a point and all its connections.""" if id not in self._points: return del self._points[id] for neighbor in self._connections.pop(id, set()): self._connections.get(neighbor, set()).discard(id) self._weights.pop(id, None) self._disabled.discard(id)
[docs] def connect_points(self, id1: int, id2: int, bidirectional: bool = True): """Connect two points. Both must already exist.""" self._connections.setdefault(id1, set()).add(id2) if bidirectional: self._connections.setdefault(id2, set()).add(id1)
[docs] def disconnect_points(self, id1: int, id2: int, bidirectional: bool = True): """Remove connection between two points.""" self._connections.get(id1, set()).discard(id2) if bidirectional: self._connections.get(id2, set()).discard(id1)
[docs] def set_point_disabled(self, id: int, disabled: bool = True): """Disable/enable a point. Disabled points are excluded from pathfinding.""" if disabled: self._disabled.add(id) else: self._disabled.discard(id)
[docs] def is_point_disabled(self, id: int) -> bool: return id in self._disabled
[docs] def has_point(self, id: int) -> bool: return id in self._points
[docs] def get_point_position(self, id: int) -> tuple[float, float]: return self._points[id]
[docs] def get_point_connections(self, id: int) -> set[int]: return self._connections.get(id, set()).copy()
[docs] @property def point_count(self) -> int: """Number of registered points (including disabled).""" return len(self._points)
[docs] def get_closest_point(self, position: tuple[float, float]) -> int: """Return id of the closest non-disabled point to the given position.""" best_id, best_dist = -1, float("inf") px, py = float(position[0]), float(position[1]) for pid, (x, y) in self._points.items(): if pid in self._disabled: continue d = (x - px) ** 2 + (y - py) ** 2 if d < best_dist: best_dist = d best_id = pid return best_id
[docs] def get_id_path(self, from_id: int, to_id: int) -> list[int]: """A* search returning list of point IDs from start to end. Empty list if no path.""" if from_id not in self._points or to_id not in self._points: return [] if from_id == to_id: return [from_id] open_set: list[tuple[float, int, int]] = [(0.0, 0, from_id)] # (f, tiebreaker, id) came_from: dict[int, int] = {} g_score: dict[int, float] = {from_id: 0.0} counter = 1 # Tiebreaker for heap stability tx, ty = self._points[to_id] while open_set: _, _, current = heapq.heappop(open_set) if current == to_id: path = [current] while current in came_from: current = came_from[current] path.append(current) path.reverse() return path # Skip if we already found a better route to this node cx, cy = self._points[current] for neighbor in self._connections.get(current, set()): if neighbor in self._disabled: continue nx, ny = self._points[neighbor] edge_cost = math.hypot(nx - cx, ny - cy) * self._weights.get(neighbor, 1.0) tentative_g = g_score[current] + edge_cost if tentative_g < g_score.get(neighbor, float("inf")): came_from[neighbor] = current g_score[neighbor] = tentative_g h = math.hypot(nx - tx, ny - ty) heapq.heappush(open_set, (tentative_g + h, counter, neighbor)) counter += 1 return []
[docs] def get_point_path(self, from_id: int, to_id: int) -> list[tuple[float, float]]: """A* search returning list of world positions.""" return [self._points[pid] for pid in self.get_id_path(from_id, to_id)]
# Imported at module bottom so PathFollower2D can re-use Vec2 / Node2D resolution # without re-importing the parent package. from .path_follower_2d import PathFollower2D # noqa: E402, F401