# 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)