mod kernels;
mod mesh;
#[cfg(test)]
mod tests;
pub use kernels::{
ExportedKernel, ExportedZone, ZoneKernel, ZoneKernelF32, zone_lengths, zone_lengths_f32,
};
#[allow(
unused_imports,
reason = "read by the GPU zone table and its WGSL-constant test"
)]
pub(crate) use kernels::{K_CYL, K_HALF, K_NONE, K_PRISM, K_SECTOR, K_SLAB, SOFT_SUBDIV};
use crate::optics::{
absorption::AbsorptionTensor, birefringence::AbsorptionTensor3, materials::AbsorptionUnit,
raytracer::spectral_absorption,
};
use glam::{DQuat, DVec3, Vec3};
pub const MAX_ZONES: usize = 4;
pub const MIN_PRISM_SIDES: u32 = 3;
pub const MAX_PRISM_SIDES: u32 = 12;
pub const MAX_MESH_TRIANGLES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZoneFrame {
pub rotation: DQuat,
pub translation: DVec3,
}
impl ZoneFrame {
pub const IDENTITY: Self = Self {
rotation: DQuat::IDENTITY,
translation: DVec3::ZERO,
};
#[must_use]
pub fn from_rotation_vector(rotation_vector: [f64; 3], translation: [f64; 3]) -> Self {
Self {
rotation: DQuat::from_scaled_axis(DVec3::from_array(rotation_vector)),
translation: DVec3::from_array(translation),
}
}
#[must_use]
pub fn point(&self, p: DVec3) -> DVec3 {
self.rotation * p + self.translation
}
#[must_use]
pub fn direction(&self, d: DVec3) -> DVec3 {
self.rotation * d
}
#[must_use]
pub fn inverse_point(&self, p: DVec3) -> DVec3 {
self.rotation.inverse() * (p - self.translation)
}
#[must_use]
pub fn inverse(&self) -> Self {
let rotation = self.rotation.inverse();
Self {
rotation,
translation: -(rotation * self.translation),
}
}
#[must_use]
pub fn compose(&self, inner: &Self) -> Self {
Self {
rotation: (self.rotation * inner.rotation).normalize(),
translation: self.rotation * inner.translation + self.translation,
}
}
}
impl Default for ZoneFrame {
fn default() -> Self {
Self::IDENTITY
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ZoneShape {
HalfSpace {
normal: DVec3,
offset: f64,
},
Slab {
normal: DVec3,
offset_min: f64,
offset_max: f64,
},
CoaxialCylinder {
axis_point: DVec3,
axis_dir: DVec3,
r_in: f64,
r_out: f64,
},
CoaxialPrism {
axis_point: DVec3,
axis_dir: DVec3,
n_sides: u32,
r_in: f64,
r_out: f64,
phase: f64,
},
Sector {
axis_point: DVec3,
axis_dir: DVec3,
angle_from: f64,
angle_to: f64,
},
MeshShell {
vertices: Vec<[f64; 3]>,
triangles: Vec<[u32; 3]>,
},
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZoneAbsorption {
pub tensor: AbsorptionTensor,
pub unit: AbsorptionUnit,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PleochroicMode {
pub e_mode_hat: Vec3,
pub c_axis: Vec3,
}
impl ZoneAbsorption {
#[must_use]
pub const fn per_mm(tensor: AbsorptionTensor) -> Self {
Self {
tensor,
unit: AbsorptionUnit::PerMm,
}
}
#[must_use]
pub fn alpha(&self, lambda_nm: f64, mode: Option<&PleochroicMode>) -> f64 {
#[expect(
clippy::cast_possible_truncation,
reason = "bands are evaluated in f32 like the tracer; a wavelength in nm is a few hundred"
)]
let lambda = lambda_nm as f32;
let tensor = &self.tensor;
let o = spectral_absorption(&tensor.o_ray, lambda);
if !tensor.is_pleochroic {
return f64::from(o);
}
let e = spectral_absorption(&tensor.e_ray, lambda);
let beta = tensor
.beta_ray
.as_ref()
.map(|bands| spectral_absorption(bands, lambda));
mode.map_or_else(
|| {
let mean = beta.map_or_else(|| 2.0f32.mul_add(o, e) / 3.0, |b| (o + b + e) / 3.0);
f64::from(mean)
},
|m| {
let tensor3 = beta.map_or_else(
|| AbsorptionTensor3::uniaxial(o, e, m.c_axis),
|b| AbsorptionTensor3::biaxial(o, b, e, m.c_axis),
);
f64::from(tensor3.quadratic_form(m.e_mode_hat))
},
)
}
#[must_use]
pub fn alpha_midpoint(
&self,
lambda_nm: f64,
eigen_a: Vec3,
eigen_b: Vec3,
c_axis: Vec3,
) -> f64 {
#[expect(
clippy::cast_possible_truncation,
reason = "bands are evaluated in f32 like the tracer; a wavelength in nm is a few hundred"
)]
let lambda = lambda_nm as f32;
let tensor = &self.tensor;
let o = spectral_absorption(&tensor.o_ray, lambda);
if !tensor.is_pleochroic {
return f64::from(o);
}
let e = spectral_absorption(&tensor.e_ray, lambda);
let tensor3 = tensor
.beta_ray
.as_ref()
.map(|bands| spectral_absorption(bands, lambda))
.map_or_else(
|| AbsorptionTensor3::uniaxial(o, e, c_axis),
|b| AbsorptionTensor3::biaxial(o, b, e, c_axis),
);
f64::from(f32::midpoint(
tensor3.quadratic_form(eigen_a),
tensor3.quadratic_form(eigen_b),
))
}
fn validate(&self, zone: Option<usize>) -> Result<(), ZoningError> {
if self.unit != AbsorptionUnit::PerMm {
return Err(ZoningError::NotPerMm { zone });
}
let bad_band = |bands: &[crate::optics::absorption::AbsorptionBand]| {
bands.iter().any(|b| {
!(b.center_nm.is_finite()
&& b.width_nm.is_finite()
&& b.width_nm > 0.0
&& b.peak.is_finite()
&& b.peak >= 0.0)
})
};
let t = &self.tensor;
if bad_band(&t.o_ray)
|| bad_band(&t.e_ray)
|| t.beta_ray.as_ref().is_some_and(|b| bad_band(b))
{
return Err(ZoningError::BadBands { zone });
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Zone {
pub shape: ZoneShape,
pub absorption: ZoneAbsorption,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZonedAbsorption {
pub frame: ZoneFrame,
pub base: ZoneAbsorption,
pub zones: Vec<Zone>,
pub boundary_softness_mm: f32,
}
impl ZonedAbsorption {
#[must_use]
pub const fn new(base: ZoneAbsorption) -> Self {
Self {
frame: ZoneFrame::IDENTITY,
base,
zones: Vec::new(),
boundary_softness_mm: 0.0,
}
}
#[must_use]
pub fn zone_absorption(&self, index: usize) -> Option<&ZoneAbsorption> {
if index == 0 {
Some(&self.base)
} else {
self.zones.get(index - 1).map(|z| &z.absorption)
}
}
pub fn validate(&self) -> Result<(), ZoningError> {
if self.zones.len() > MAX_ZONES {
return Err(ZoningError::TooManyZones {
count: self.zones.len(),
});
}
if !self.boundary_softness_mm.is_finite() || self.boundary_softness_mm < 0.0 {
return Err(ZoningError::BadSoftness);
}
let q = self.frame.rotation;
if !q.is_finite() || (q.length() - 1.0).abs() > 1e-6 || !self.frame.translation.is_finite()
{
return Err(ZoningError::BadFrame);
}
self.base.validate(None)?;
for (i, zone) in self.zones.iter().enumerate() {
zone.absorption.validate(Some(i + 1))?;
zone.shape.validate(i + 1)?;
}
let has_mesh = self
.zones
.iter()
.any(|z| matches!(z.shape, ZoneShape::MeshShell { .. }));
if has_mesh && self.boundary_softness_mm > 0.0 {
return Err(ZoningError::SoftMeshShell);
}
Ok(())
}
#[must_use]
pub fn transformed(&self, rigid: &ZoneFrame) -> Self {
let mut out = self.clone();
out.frame = rigid.compose(&self.frame);
out
}
#[must_use]
pub fn scaled(&self, factor: f64) -> Self {
let mut out = self.clone();
out.frame.translation *= factor;
#[expect(
clippy::cast_possible_truncation,
reason = "the softness width is stored as f32 (the GPU precision)"
)]
let softness = (f64::from(self.boundary_softness_mm) * factor) as f32;
out.boundary_softness_mm = softness;
for zone in &mut out.zones {
match &mut zone.shape {
ZoneShape::HalfSpace { offset, .. } => *offset *= factor,
ZoneShape::Slab {
offset_min,
offset_max,
..
} => {
*offset_min *= factor;
*offset_max *= factor;
}
ZoneShape::CoaxialCylinder {
axis_point,
r_in,
r_out,
..
}
| ZoneShape::CoaxialPrism {
axis_point,
r_in,
r_out,
..
} => {
*axis_point *= factor;
*r_in *= factor;
*r_out *= factor;
}
ZoneShape::Sector { axis_point, .. } => *axis_point *= factor,
ZoneShape::MeshShell { vertices, .. } => {
for v in vertices.iter_mut() {
for c in v.iter_mut() {
*c *= factor;
}
}
}
}
}
out
}
}
const fn require_finite(zone: usize, ok: bool, what: &'static str) -> Result<(), ZoningError> {
if ok {
Ok(())
} else {
Err(ZoningError::NotFinite {
zone: Some(zone),
what,
})
}
}
fn require_unit(zone: usize, v: DVec3, what: &'static str) -> Result<(), ZoningError> {
if v.is_finite() && (v.length() - 1.0).abs() <= 1e-6 {
Ok(())
} else {
Err(ZoningError::NotUnit { zone, what })
}
}
fn require_radii(zone: usize, r_in: f64, r_out: f64) -> Result<(), ZoningError> {
if r_in < 0.0 || r_in >= r_out {
return Err(ZoningError::BadRadii { zone });
}
Ok(())
}
impl ZoneShape {
fn validate(&self, zone: usize) -> Result<(), ZoningError> {
match self {
Self::HalfSpace { normal, offset } => {
require_finite(zone, normal.is_finite() && offset.is_finite(), "half space")?;
require_unit(zone, *normal, "normal")
}
Self::Slab {
normal,
offset_min,
offset_max,
} => {
require_finite(
zone,
normal.is_finite() && offset_min.is_finite() && offset_max.is_finite(),
"slab",
)?;
require_unit(zone, *normal, "normal")?;
if offset_min >= offset_max {
return Err(ZoningError::BadSlab { zone });
}
Ok(())
}
Self::CoaxialCylinder {
axis_point,
axis_dir,
r_in,
r_out,
} => {
require_finite(
zone,
axis_point.is_finite() && r_in.is_finite() && r_out.is_finite(),
"cylinder",
)?;
require_unit(zone, *axis_dir, "axis_dir")?;
require_radii(zone, *r_in, *r_out)
}
Self::CoaxialPrism {
axis_point,
axis_dir,
n_sides,
r_in,
r_out,
phase,
} => {
require_finite(
zone,
axis_point.is_finite()
&& r_in.is_finite()
&& r_out.is_finite()
&& phase.is_finite(),
"prism",
)?;
require_unit(zone, *axis_dir, "axis_dir")?;
if !(MIN_PRISM_SIDES..=MAX_PRISM_SIDES).contains(n_sides) {
return Err(ZoningError::BadSideCount {
zone,
n_sides: *n_sides,
});
}
require_radii(zone, *r_in, *r_out)
}
Self::Sector {
axis_point,
axis_dir,
angle_from,
angle_to,
} => {
require_finite(
zone,
axis_point.is_finite() && angle_from.is_finite() && angle_to.is_finite(),
"sector",
)?;
require_unit(zone, *axis_dir, "axis_dir")?;
let span = angle_to - angle_from;
if span <= 0.0 || span > std::f64::consts::TAU + 1e-9 {
return Err(ZoningError::BadAngles { zone });
}
Ok(())
}
Self::MeshShell {
vertices,
triangles,
} => mesh::check(vertices, triangles)
.map_err(|problem| ZoningError::BadMesh { zone, problem }),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoningError {
TooManyZones {
count: usize,
},
NotFinite {
zone: Option<usize>,
what: &'static str,
},
NotUnit {
zone: usize,
what: &'static str,
},
NotPerMm {
zone: Option<usize>,
},
BadBands {
zone: Option<usize>,
},
BadSlab {
zone: usize,
},
BadRadii {
zone: usize,
},
BadSideCount {
zone: usize,
n_sides: u32,
},
BadAngles {
zone: usize,
},
BadSoftness,
BadFrame,
SoftMeshShell,
BadMesh {
zone: usize,
problem: MeshProblem,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshProblem {
TooFewTriangles,
TooManyTriangles,
IndexOutOfRange,
NotFinite,
DegenerateTriangle,
NotClosed,
InconsistentWinding,
ZeroVolume,
}
fn zone_label(zone: Option<usize>) -> String {
zone.map_or_else(|| "base zone".to_owned(), |i| format!("zone {i}"))
}
impl core::fmt::Display for ZoningError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::TooManyZones { count } => {
write!(f, "{count} zones given, at most {MAX_ZONES} are allowed")
}
Self::NotFinite { zone, what } => {
write!(f, "{}: {what} has a non-finite value", zone_label(*zone))
}
Self::NotUnit { zone, what } => {
write!(f, "zone {zone}: {what} is not a unit vector")
}
Self::NotPerMm { zone } => {
write!(
f,
"{}: zoned absorption must be per millimetre",
zone_label(*zone)
)
}
Self::BadBands { zone } => write!(
f,
"{}: an absorption band has a non-finite value, a non-positive width or a negative peak",
zone_label(*zone)
),
Self::BadSlab { zone } => {
write!(f, "zone {zone}: slab offset_min must be below offset_max")
}
Self::BadRadii { zone } => {
write!(f, "zone {zone}: radii must satisfy 0 <= r_in < r_out")
}
Self::BadSideCount { zone, n_sides } => write!(
f,
"zone {zone}: a prism has {MIN_PRISM_SIDES} to {MAX_PRISM_SIDES} sides, not {n_sides}"
),
Self::BadAngles { zone } => {
write!(f, "zone {zone}: sector span must be in (0, 2 pi]")
}
Self::BadSoftness => write!(f, "boundary softness must be finite and not negative"),
Self::BadFrame => write!(
f,
"zone frame must be a finite unit rotation and translation"
),
Self::SoftMeshShell => {
write!(
f,
"a mesh shell has sharp edges only; set the boundary softness to 0"
)
}
Self::BadMesh { zone, problem } => {
write!(f, "zone {zone}: mesh shell problem {problem:?}")
}
}
}
}
impl std::error::Error for ZoningError {}
#[must_use]
pub fn segment_optical_depth(z: &ZonedAbsorption, from: DVec3, to: DVec3, lambda_nm: f64) -> f64 {
segment_optical_depth_mode(z, from, to, lambda_nm, None)
}
#[must_use]
pub fn segment_optical_depth_mode(
z: &ZonedAbsorption,
from: DVec3,
to: DVec3,
lambda_nm: f64,
mode: Option<&PleochroicMode>,
) -> f64 {
optical_depth_from_lengths(z, &zone_lengths(z, from, to), lambda_nm, mode)
}
#[must_use]
pub fn optical_depth_from_lengths(
z: &ZonedAbsorption,
lengths: &[f64; MAX_ZONES + 1],
lambda_nm: f64,
mode: Option<&PleochroicMode>,
) -> f64 {
let mut depth = z.base.alpha(lambda_nm, mode) * lengths[0];
for (i, zone) in z.zones.iter().take(MAX_ZONES).enumerate() {
depth = f64::mul_add(
zone.absorption.alpha(lambda_nm, mode),
lengths[i + 1],
depth,
);
}
depth
}