sampling.py

# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025 SWGY, Inc
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""
Host-side sampling prep (pure NumPy, float64).

Builds the filtered explosion grid, per-explosion orthonormal frames, the padded
azimuth/elevation ray-fan offsets, and the Friedlander impulse constant. The
Warp device core (:mod:`warp_core`) consumes these arrays and generates the rays
in-kernel, so nothing large is ever materialised here.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import numpy as np

from config import Config

# Arc-minute -> radian conversion: 1 arc-minute = (pi/180)/60 = pi/10800.
_ARCMIN_TO_RAD = np.pi / 10800.0


@dataclass(frozen=True)
class Sensor:
    origin: np.ndarray  # (3,) world coordinates
    radius: float


@dataclass(frozen=True)
class GridArrays:
    """Compact per-explosion ray-fan description (the device builds the rays)."""
    blast_os: np.ndarray   # (E, 3) kept blast points
    frames: np.ndarray     # (E, 3, 3) rows = (forward, right, up)
    az_off: np.ndarray     # (E, Gaz) azimuth angles
    el_off: np.ndarray     # (E, Gel) elevation angles
    valid_az: np.ndarray   # (E, Gaz) bool
    valid_el: np.ndarray   # (E, Gel) bool
    res_rad: float         # angular ray spacing


def _unit_rows(v: np.ndarray) -> np.ndarray:
    """Normalise each row; rows with ~zero norm become zero."""
    norm = np.linalg.norm(v, axis=-1, keepdims=True)
    safe = np.where(norm >= 1e-12, norm, 1.0)
    return np.where(norm >= 1e-12, v / safe, 0.0)


def _frames(forward: np.ndarray) -> np.ndarray:
    """Orthonormal (forward, right, up) rows per explosion (gimbal-guarded)."""
    fwd = _unit_rows(forward)
    up0 = np.array([0.0, 0.0, 1.0])
    alt = np.array([0.0, 1.0, 0.0])
    near_parallel = np.abs(fwd @ up0) > 0.99
    up = np.where(near_parallel[:, None], alt, up0)
    right = _unit_rows(np.cross(fwd, up))
    up = _unit_rows(np.cross(right, fwd))
    return np.stack([fwd, right, up], axis=1)  # (E, 3, 3)


def precompute_waveform_constant(t_d: float, alpha: float) -> float:
    """Impulse per unit peak pressure: K = trapz(friedlander(., 1.0, t_d, alpha)).

    The Friedlander waveform is linear in peak pressure, so a ray's impulse is
    just ``p_eff * K``; K is that fixed shape integral, evaluated once as a
    1000-point trapezoid over the positive (clipped) phase.
    """
    t = np.linspace(0.0, 3.0 * t_d, 1000)
    p = np.zeros_like(t)
    m = t >= 0
    p[m] = 1.0 * (1.0 - t[m] / t_d) * np.exp(-alpha * t[m] / t_d)
    p[p < 0] = 0.0
    return float(np.sum(0.5 * (p[1:] + p[:-1]) * np.diff(t)))


def _axis_samples(center: float, extents: float, segments: int) -> np.ndarray:
    """Sample coordinates along one axis, centred correctly (single offset)."""
    return np.linspace(center - extents / 2.0, center + extents / 2.0, segments)


def _counts(lo: np.ndarray, hi: np.ndarray, res: float) -> np.ndarray:
    """Sample count per axis, matching ``len(np.arange(lo, hi + res, res))``."""
    return np.maximum(1, np.ceil((hi - lo) / res + 1.0 - 1e-9)).astype(np.int64)


def build_grid(cfg: Config, sensor: Sensor,
               vertices_all: Optional[np.ndarray] = None) -> GridArrays:
    """Build the filtered explosion grid and its padded per-explosion ray fans.

    `vertices_all` (all armor vertices) is only needed for
    ``rays.fan_mode == "geometry"``.
    """
    b = cfg.blast
    xs = _axis_samples(b.cube_center_m[0], b.cube_extents_m, b.cube_segments)
    ys = _axis_samples(b.cube_center_m[1], b.cube_extents_m, b.cube_segments)
    zs = _axis_samples(b.cube_center_m[2], b.cube_extents_m, b.cube_segments)
    grid = np.stack(np.meshgrid(xs, ys, zs, indexing="ij"), axis=-1).reshape(-1, 3)

    dist = np.linalg.norm(grid - sensor.origin, axis=-1)
    keep = dist >= b.min_dist_m
    blast_os = grid[keep]
    dist = dist[keep]
    if blast_os.shape[0] == 0:
        raise ValueError("No blast points survive the min-distance filter; "
                         "check blast.cube_* and blast.min_dist_m.")

    frames = _frames(sensor.origin - blast_os)      # (E, 3, 3)
    fwd, right, up = frames[:, 0, :], frames[:, 1, :], frames[:, 2, :]

    ratio = np.clip(sensor.radius / dist, -1.0, 1.0)
    half = np.where(dist <= sensor.radius, np.pi / 2.0, np.arcsin(ratio))
    az_lo, az_hi, el_lo, el_hi = -half, half, -half, half

    if cfg.rays.fan_mode == "geometry":
        if vertices_all is None:
            raise ValueError("fan_mode 'geometry' requires vertices_all")
        rel = vertices_all[None, :, :] - blast_os[:, None, :]     # (E, V, 3)
        proj_fwd = np.einsum("evc,ec->ev", rel, fwd)
        h_ang = np.arctan2(np.einsum("evc,ec->ev", rel, right), proj_fwd)
        v_ang = np.arctan2(np.einsum("evc,ec->ev", rel, up), proj_fwd)
        az_lo = np.minimum(az_lo, h_ang.min(axis=1))
        az_hi = np.maximum(az_hi, h_ang.max(axis=1))
        el_lo = np.minimum(el_lo, v_ang.min(axis=1))
        el_hi = np.maximum(el_hi, v_ang.max(axis=1))

    res = float(cfg.rays.resolution_arcmin) * _ARCMIN_TO_RAD
    n_az = _counts(az_lo, az_hi, res)
    n_el = _counts(el_lo, el_hi, res)
    gaz, gel = int(n_az.max()), int(n_el.max())

    k_az, k_el = np.arange(gaz), np.arange(gel)
    az_off = az_lo[:, None] + res * k_az[None, :]     # (E, Gaz)
    el_off = el_lo[:, None] + res * k_el[None, :]     # (E, Gel)
    valid_az = k_az[None, :] < n_az[:, None]
    valid_el = k_el[None, :] < n_el[:, None]

    return GridArrays(blast_os, frames, az_off, el_off, valid_az, valid_el, res)