use crate::material::NUM_BANDS;
use crate::room::Wall;
use hisab::Vec3;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AcousticBeam {
pub apex: Vec3,
pub direction: Vec3,
pub half_angle: f32,
pub energy: [f32; NUM_BANDS],
pub path_length: f32,
pub order: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BeamWallHit {
pub centre: Vec3,
pub distance: f32,
pub wall_index: usize,
pub coverage: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BeamPath {
pub hits: Vec<BeamWallHit>,
pub final_energy: [f32; NUM_BANDS],
pub total_distance: f32,
pub order: u32,
}
#[must_use]
#[tracing::instrument(skip(walls), fields(wall_count = walls.len(), max_order))]
pub fn trace_beam(beam: &AcousticBeam, walls: &[Wall], max_order: u32) -> BeamPath {
let mut current = beam.clone();
let mut hits = Vec::with_capacity(max_order.min(32) as usize);
let mut last_wall: Option<usize> = None;
for _ in 0..max_order {
let mut nearest: Option<(f32, usize)> = None;
for (i, wall) in walls.iter().enumerate() {
if last_wall == Some(i) {
continue;
}
if let Some(t) = beam_wall_distance(¤t, wall)
&& nearest.is_none_or(|(best, _)| t < best)
{
nearest = Some((t, i));
}
}
let Some((t, idx)) = nearest else { break };
let wall = &walls[idx];
let footprint_radius = t * current.half_angle.tan();
let wall_area = wall.area();
let beam_area = std::f32::consts::PI * footprint_radius * footprint_radius;
let coverage = if beam_area > f32::EPSILON {
(wall_area / beam_area).clamp(0.0, 1.0)
} else {
1.0 };
let hit_point = current.apex + current.direction * t;
hits.push(BeamWallHit {
centre: hit_point,
distance: t,
wall_index: idx,
coverage,
});
let n = wall.normal;
let d_dot_n = current.direction.dot(n);
let reflected_dir = current.direction - 2.0 * d_dot_n * n;
let len = reflected_dir.length();
let reflected_dir = if len > f32::EPSILON {
reflected_dir / len
} else {
current.direction
};
let mut new_energy = [0.0_f32; NUM_BANDS];
for (band, new_e) in new_energy.iter_mut().enumerate() {
*new_e = current.energy[band] * (1.0 - wall.material.absorption[band]);
}
let max_e = new_energy.iter().copied().fold(0.0_f32, f32::max);
if max_e < 0.001 {
break;
}
current = AcousticBeam {
apex: hit_point,
direction: reflected_dir,
half_angle: current.half_angle,
energy: new_energy,
path_length: current.path_length + t,
order: current.order + 1,
};
last_wall = Some(idx);
}
BeamPath {
total_distance: current.path_length,
final_energy: current.energy,
order: current.order,
hits,
}
}
#[must_use]
pub fn generate_beam_set(source: Vec3, subdivisions: u32) -> Vec<AcousticBeam> {
let n = 20 * 4_u32.pow(subdivisions.min(3));
let half_angle = std::f32::consts::PI / (n as f32).sqrt();
crate::diffuse::fibonacci_sphere(n)
.into_iter()
.map(|dir| AcousticBeam {
apex: source,
direction: dir,
half_angle,
energy: [1.0; NUM_BANDS],
path_length: 0.0,
order: 0,
})
.collect()
}
#[must_use]
#[inline]
fn beam_wall_distance(beam: &AcousticBeam, wall: &Wall) -> Option<f32> {
if wall.vertices.len() < 3 {
return None;
}
let n = wall.normal;
let d_dot_n = beam.direction.dot(n);
if d_dot_n.abs() < f32::EPSILON {
return None;
}
let p0 = wall.vertices[0];
let t = (p0 - beam.apex).dot(n) / d_dot_n;
if t < f32::EPSILON {
return None;
}
Some(t)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::material::AcousticMaterial;
use crate::room::RoomGeometry;
#[test]
fn beam_traces_through_shoebox() {
let geom = RoomGeometry::shoebox(10.0, 8.0, 3.0, AcousticMaterial::concrete());
let beam = AcousticBeam {
apex: Vec3::new(5.0, 1.5, 4.0),
direction: Vec3::Z,
half_angle: 0.1,
energy: [1.0; NUM_BANDS],
path_length: 0.0,
order: 0,
};
let path = trace_beam(&beam, &geom.walls, 10);
assert!(!path.hits.is_empty(), "beam should hit walls in shoebox");
assert!(path.total_distance > 0.0);
}
#[test]
fn beam_energy_decreases() {
let geom = RoomGeometry::shoebox(10.0, 8.0, 3.0, AcousticMaterial::carpet());
let beam = AcousticBeam {
apex: Vec3::new(5.0, 1.5, 4.0),
direction: Vec3::Z,
half_angle: 0.1,
energy: [1.0; NUM_BANDS],
path_length: 0.0,
order: 0,
};
let path = trace_beam(&beam, &geom.walls, 20);
for &e in &path.final_energy {
assert!(e < 1.0, "energy should decrease");
}
}
#[test]
fn beam_set_covers_sphere() {
let beams = generate_beam_set(Vec3::ZERO, 0);
assert_eq!(beams.len(), 20);
for b in &beams {
assert!((b.direction.length() - 1.0).abs() < 0.01);
}
}
#[test]
fn beam_set_subdivision_increases_count() {
let b0 = generate_beam_set(Vec3::ZERO, 0);
let b1 = generate_beam_set(Vec3::ZERO, 1);
assert!(b1.len() > b0.len());
}
#[test]
fn beam_empty_scene() {
let beam = AcousticBeam {
apex: Vec3::ZERO,
direction: Vec3::X,
half_angle: 0.1,
energy: [1.0; NUM_BANDS],
path_length: 0.0,
order: 0,
};
let path = trace_beam(&beam, &[], 10);
assert!(path.hits.is_empty());
}
#[test]
fn beam_wall_hit_coverage_in_range() {
let geom = RoomGeometry::shoebox(10.0, 8.0, 3.0, AcousticMaterial::concrete());
let beam = AcousticBeam {
apex: Vec3::new(5.0, 1.5, 4.0),
direction: Vec3::Z,
half_angle: 0.05,
energy: [1.0; NUM_BANDS],
path_length: 0.0,
order: 0,
};
let path = trace_beam(&beam, &geom.walls, 5);
for hit in &path.hits {
assert!((0.0..=1.0).contains(&hit.coverage));
}
}
#[test]
fn beam_path_serializes() {
let path = BeamPath {
hits: vec![],
final_energy: [0.5; NUM_BANDS],
total_distance: 10.0,
order: 3,
};
let json = serde_json::to_string(&path).unwrap();
let back: BeamPath = serde_json::from_str(&json).unwrap();
assert_eq!(path, back);
}
}