Source code for simvx.core.navigation3d

"""3D navigation mesh system — navmesh generation, A* pathfinding, agents, obstacles.

Provides NavigationMesh3D for defining walkable surfaces, NavigationServer3D as a
singleton coordinator, NavigationAgent3D for autonomous path-following, and
NavigationObstacle3D for dynamic obstacle avoidance.
"""


from __future__ import annotations

import heapq
import logging
import math
import random
from itertools import pairwise

import numpy as np

from .nodes_3d.node3d import Node3D
from .descriptors import Property, Signal
from .math.types import Vec3

log = logging.getLogger(__name__)

__all__ = [
    "NavigationMesh3D",
    "NavigationRegion3D",
    "NavigationAgent3D",
    "NavigationServer3D",
    "NavigationObstacle3D",
]


# ============================================================================
# NavigationMesh3D — navmesh data structure with A* pathfinding
# ============================================================================






# ============================================================================
# NavigationServer3D — singleton managing all navigation regions
# ============================================================================






# ============================================================================
# NavigationRegion3D — node that holds a NavigationMesh3D
# ============================================================================






# ============================================================================
# NavigationAgent3D — 3D pathfinding agent
# ============================================================================






# ============================================================================
# NavigationObstacle3D — dynamic obstacle
# ============================================================================






# ============================================================================
# Helper functions
# ============================================================================


def _vec3_dist(a: Vec3, b: Vec3) -> float:
    """Euclidean distance between two Vec3."""
    return (a - b).length()


def _path_length(path: list[Vec3]) -> float:
    """Total length of a path."""
    total = 0.0
    for a, b in pairwise(path):
        total += _vec3_dist(a, b)
    return total


def _point_in_triangle_xz(
    px: float,
    pz: float,
    ax: float,
    az: float,
    bx: float,
    bz: float,
    cx: float,
    cz: float,
) -> bool:
    """Test if point (px, pz) is inside triangle (a, b, c) in the xz plane using barycentric coordinates."""
    v0x, v0z = cx - ax, cz - az
    v1x, v1z = bx - ax, bz - az
    v2x, v2z = px - ax, pz - az
    dot00 = v0x * v0x + v0z * v0z
    dot01 = v0x * v1x + v0z * v1z
    dot02 = v0x * v2x + v0z * v2z
    dot11 = v1x * v1x + v1z * v1z
    dot12 = v1x * v2x + v1z * v2z
    inv_denom = dot00 * dot11 - dot01 * dot01
    if abs(inv_denom) < 1e-12:
        return False
    inv_denom = 1.0 / inv_denom
    u = (dot11 * dot02 - dot01 * dot12) * inv_denom
    v = (dot00 * dot12 - dot01 * dot02) * inv_denom
    return u >= -1e-6 and v >= -1e-6 and (u + v) <= 1.0 + 1e-6


def _interpolate_height(
    v0: np.ndarray,
    v1: np.ndarray,
    v2: np.ndarray,
    x: float,
    z: float,
) -> float | None:
    """Interpolate the Y height at (x, z) within triangle (v0, v1, v2). Returns None if outside."""
    if not _point_in_triangle_xz(x, z, v0[0], v0[2], v1[0], v1[2], v2[0], v2[2]):
        return None
    # Barycentric interpolation for Y
    denom = (v1[2] - v2[2]) * (v0[0] - v2[0]) + (v2[0] - v1[0]) * (v0[2] - v2[2])
    if abs(denom) < 1e-12:
        return float((v0[1] + v1[1] + v2[1]) / 3.0)
    w0 = ((v1[2] - v2[2]) * (x - v2[0]) + (v2[0] - v1[0]) * (z - v2[2])) / denom
    w1 = ((v2[2] - v0[2]) * (x - v2[0]) + (v0[0] - v2[0]) * (z - v2[2])) / denom
    w2 = 1.0 - w0 - w1
    return float(w0 * v0[1] + w1 * v1[1] + w2 * v2[1])


def _closest_point_on_triangle_np(
    p: np.ndarray,
    a: np.ndarray,
    b: np.ndarray,
    c: np.ndarray,
) -> np.ndarray:
    """Find closest point on triangle (a, b, c) to point p. Returns numpy array."""
    ab = b - a
    ac = c - a
    ap = p - a
    d1 = np.dot(ab, ap)
    d2 = np.dot(ac, ap)
    if d1 <= 0.0 and d2 <= 0.0:
        return a.copy()

    bp = p - b
    d3 = np.dot(ab, bp)
    d4 = np.dot(ac, bp)
    if d3 >= 0.0 and d4 <= d3:
        return b.copy()

    cp_ = p - c
    d5 = np.dot(ab, cp_)
    d6 = np.dot(ac, cp_)
    if d6 >= 0.0 and d5 <= d6:
        return c.copy()

    vc = d1 * d4 - d3 * d2
    if vc <= 0.0 and d1 >= 0.0 and d3 <= 0.0:
        v = d1 / (d1 - d3)
        return a + v * ab

    vb = d5 * d2 - d1 * d6
    if vb <= 0.0 and d2 >= 0.0 and d6 <= 0.0:
        w = d2 / (d2 - d6)
        return a + w * ac

    va = d3 * d6 - d5 * d4
    if va <= 0.0 and (d4 - d3) >= 0.0 and (d5 - d6) >= 0.0:
        w = (d4 - d3) / ((d4 - d3) + (d5 - d6))
        return b + w * (c - b)

    denom = 1.0 / (va + vb + vc)
    v = vb * denom
    w = vc * denom
    return a + ab * v + ac * w


def _point_in_polygon_xz(px: float, pz: float, polygon: list[list[float]]) -> bool:
    """Ray-casting point-in-polygon test on the XZ plane.

    Args:
        px: X coordinate of the test point.
        pz: Z coordinate of the test point.
        polygon: List of [x, z] vertices defining the polygon boundary.

    Returns:
        True if the point is inside the polygon.
    """
    n = len(polygon)
    inside = False
    j = n - 1
    for i in range(n):
        xi, zi = polygon[i]
        xj, zj = polygon[j]
        if ((zi > pz) != (zj > pz)) and (px < (xj - xi) * (pz - zi) / (zj - zi) + xi):
            inside = not inside
        j = i
    return inside