use crate::material::AcousticMaterial;
use crate::room::{AcousticRoom, Wall};
use hisab::Vec3;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageSource {
pub position: Vec3,
pub order: u32,
pub attenuation: [f32; crate::material::NUM_BANDS],
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EarlyReflection {
pub delay_seconds: f32,
pub amplitude: [f32; crate::material::NUM_BANDS],
pub direction: Vec3,
pub order: u32,
pub distance: f32,
}
#[must_use]
#[inline]
fn reflect_point(point: Vec3, plane_point: Vec3, plane_normal: Vec3) -> Vec3 {
let d = (point - plane_point).dot(plane_normal);
point - 2.0 * d * plane_normal
}
#[must_use]
#[tracing::instrument(skip(materials), fields(max_order))]
pub fn compute_image_sources_shoebox(
source: Vec3,
length: f32,
width: f32,
height: f32,
materials: &[AcousticMaterial; 6],
max_order: u32,
) -> Vec<ImageSource> {
let max_order = max_order.min(20);
let max_n = max_order as i32;
let side = (2 * max_n + 1) as usize;
let mut sources = Vec::with_capacity(side * side * side);
sources.push(ImageSource {
position: source,
order: 0,
attenuation: [1.0; crate::material::NUM_BANDS],
});
for nx in -(max_n)..=max_n {
for ny in -(max_n)..=max_n {
for nz in -(max_n)..=max_n {
let order = nx.unsigned_abs() + ny.unsigned_abs() + nz.unsigned_abs();
if order == 0 || order > max_order {
continue;
}
let x = image_coordinate(source.x, length, nx);
let y = image_coordinate(source.y, height, ny);
let z = image_coordinate(source.z, width, nz);
let mut atten = [1.0_f32; crate::material::NUM_BANDS];
apply_axis_attenuation(&mut atten, materials, 4, 5, nx); apply_axis_attenuation(&mut atten, materials, 0, 1, ny); apply_axis_attenuation(&mut atten, materials, 2, 3, nz);
sources.push(ImageSource {
position: Vec3::new(x, y, z),
order,
attenuation: atten,
});
}
}
}
sources
}
#[must_use]
#[inline]
fn image_coordinate(source_coord: f32, dimension: f32, n: i32) -> f32 {
let nd = n as f32 * dimension;
if n % 2 == 0 {
nd + source_coord
} else if n > 0 {
nd + (dimension - source_coord)
} else {
nd - (dimension - source_coord)
}
}
#[inline]
fn apply_axis_attenuation(
atten: &mut [f32; crate::material::NUM_BANDS],
materials: &[AcousticMaterial; 6],
neg_wall: usize,
pos_wall: usize,
n: i32,
) {
let abs_n = n.unsigned_abs();
let (neg_hits, pos_hits) = if n > 0 {
(abs_n / 2, abs_n.div_ceil(2))
} else if n < 0 {
(abs_n.div_ceil(2), abs_n / 2)
} else {
return;
};
for (band, a) in atten.iter_mut().enumerate() {
let neg_factor = (1.0 - materials[neg_wall].absorption[band]).powi(neg_hits as i32);
let pos_factor = (1.0 - materials[pos_wall].absorption[band]).powi(pos_hits as i32);
*a *= neg_factor * pos_factor;
}
}
#[must_use]
#[tracing::instrument(skip(walls), fields(wall_count = walls.len(), max_order))]
pub fn compute_image_sources_general(
source: Vec3,
walls: &[Wall],
max_order: u32,
) -> Vec<ImageSource> {
let max_order = max_order.min(6);
let mut sources = vec![ImageSource {
position: source,
order: 0,
attenuation: [1.0; crate::material::NUM_BANDS],
}];
let mut current_order = vec![sources[0].clone()];
for _order in 1..=max_order {
let mut next_order = Vec::new();
for parent in ¤t_order {
for wall in walls {
let reflected_pos = reflect_point(parent.position, wall.vertices[0], wall.normal);
let mut atten = parent.attenuation;
for (band, a) in atten.iter_mut().enumerate() {
*a *= 1.0 - wall.material.absorption[band];
}
let img = ImageSource {
position: reflected_pos,
order: parent.order + 1,
attenuation: atten,
};
next_order.push(img);
}
}
sources.extend(next_order.iter().cloned());
current_order = next_order;
}
sources
}
#[must_use]
#[tracing::instrument(skip(room), fields(max_order, speed_of_sound))]
pub fn compute_early_reflections(
source: Vec3,
listener: Vec3,
room: &AcousticRoom,
max_order: u32,
speed_of_sound: f32,
) -> Vec<EarlyReflection> {
let geom = &room.geometry;
if geom.walls.is_empty() || speed_of_sound <= 0.0 {
return Vec::new();
}
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for wall in &geom.walls {
for &v in &wall.vertices {
min = min.min(v);
max = max.max(v);
}
}
let dims = max - min;
let materials: [AcousticMaterial; 6] = if geom.walls.len() >= 6 {
std::array::from_fn(|i| geom.walls[i].material.clone())
} else {
std::array::from_fn(|_| geom.walls[0].material.clone())
};
let image_sources =
compute_image_sources_shoebox(source, dims.x, dims.z, dims.y, &materials, max_order);
let mut reflections: Vec<EarlyReflection> = image_sources
.iter()
.filter_map(|img| {
let diff = listener - img.position;
let distance = diff.length();
if distance < f32::EPSILON {
return None;
}
let delay = distance / speed_of_sound;
let inv_dist = 1.0 / distance;
let mut amplitude = [0.0_f32; crate::material::NUM_BANDS];
for (band, amp) in amplitude.iter_mut().enumerate() {
*amp = img.attenuation[band] * inv_dist;
}
let direction = diff / distance;
Some(EarlyReflection {
delay_seconds: delay,
amplitude,
direction,
order: img.order,
distance,
})
})
.collect();
reflections.sort_by(|a, b| {
a.delay_seconds
.partial_cmp(&b.delay_seconds)
.unwrap_or(std::cmp::Ordering::Equal)
});
reflections
}
#[cfg(test)]
mod tests {
use super::*;
use crate::material::AcousticMaterial;
use crate::propagation::speed_of_sound;
fn test_room() -> AcousticRoom {
AcousticRoom::shoebox(10.0, 8.0, 3.0, AcousticMaterial::concrete())
}
#[test]
fn direct_path_is_order_zero() {
let room = test_room();
let source = Vec3::new(3.0, 1.5, 4.0);
let listener = Vec3::new(7.0, 1.5, 4.0);
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(source, listener, &room, 3, c);
assert!(!reflections.is_empty());
let direct = reflections.iter().find(|r| r.order == 0);
assert!(direct.is_some(), "should have direct path (order 0)");
let direct = direct.unwrap();
let expected_dist = (listener - source).length();
assert!(
(direct.distance - expected_dist).abs() < 0.1,
"direct distance should be ~{expected_dist}, got {}",
direct.distance
);
}
#[test]
fn direct_path_arrives_first() {
let room = test_room();
let source = Vec3::new(3.0, 1.5, 4.0);
let listener = Vec3::new(7.0, 1.5, 4.0);
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(source, listener, &room, 3, c);
assert!(reflections.len() > 1);
assert_eq!(reflections[0].order, 0, "direct path should arrive first");
}
#[test]
fn first_order_arrives_after_direct() {
let room = test_room();
let source = Vec3::new(3.0, 1.5, 4.0);
let listener = Vec3::new(7.0, 1.5, 4.0);
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(source, listener, &room, 1, c);
let direct_delay = reflections[0].delay_seconds;
for r in reflections.iter().skip(1) {
assert!(
r.delay_seconds >= direct_delay,
"order {} reflection at {:.4}s should arrive after direct at {:.4}s",
r.order,
r.delay_seconds,
direct_delay
);
}
}
#[test]
fn amplitude_decreases_with_order() {
let room = test_room();
let source = Vec3::new(5.0, 1.5, 4.0);
let listener = Vec3::new(5.0, 1.5, 4.0 + 0.01); let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(source, listener, &room, 3, c);
let avg_amp = |order: u32| -> f32 {
let refs: Vec<_> = reflections.iter().filter(|r| r.order == order).collect();
if refs.is_empty() {
return 0.0;
}
let total: f32 = refs
.iter()
.map(|r| r.amplitude.iter().sum::<f32>() / r.amplitude.len() as f32)
.sum();
total / refs.len() as f32
};
let amp_1 = avg_amp(1);
let amp_2 = avg_amp(2);
let amp_3 = avg_amp(3);
assert!(
amp_1 > amp_2,
"order 1 avg amp ({amp_1}) should exceed order 2 ({amp_2})"
);
assert!(
amp_2 > amp_3,
"order 2 avg amp ({amp_2}) should exceed order 3 ({amp_3})"
);
}
#[test]
fn image_source_count_shoebox_order_1() {
let materials = std::array::from_fn(|_| AcousticMaterial::concrete());
let source = Vec3::new(5.0, 1.5, 4.0);
let sources = compute_image_sources_shoebox(source, 10.0, 8.0, 3.0, &materials, 1);
assert_eq!(
sources.len(),
7,
"order 1 should have 7 image sources (1 direct + 6 first-order)"
);
}
#[test]
fn image_coordinate_even_n() {
assert!((image_coordinate(3.0, 10.0, 0) - 3.0).abs() < f32::EPSILON);
assert!((image_coordinate(3.0, 10.0, 2) - 23.0).abs() < f32::EPSILON);
assert!((image_coordinate(3.0, 10.0, -2) - (-17.0)).abs() < f32::EPSILON);
}
#[test]
fn image_coordinate_odd_n() {
assert!((image_coordinate(3.0, 10.0, 1) - 17.0).abs() < f32::EPSILON);
assert!((image_coordinate(3.0, 10.0, -1) - (-17.0)).abs() < f32::EPSILON);
}
#[test]
fn reflections_sorted_by_delay() {
let room = test_room();
let source = Vec3::new(3.0, 1.5, 4.0);
let listener = Vec3::new(7.0, 1.5, 4.0);
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(source, listener, &room, 3, c);
for window in reflections.windows(2) {
assert!(
window[0].delay_seconds <= window[1].delay_seconds,
"reflections should be sorted by delay"
);
}
}
#[test]
fn general_method_produces_correct_order_count() {
let room = test_room();
let source = Vec3::new(5.0, 1.5, 4.0);
let sources = compute_image_sources_general(source, &room.geometry.walls, 1);
assert_eq!(sources.len(), 7);
}
#[test]
fn reflect_point_across_plane() {
let reflected = reflect_point(Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO, Vec3::X);
assert!((reflected.x - (-1.0)).abs() < f32::EPSILON);
assert!(reflected.y.abs() < f32::EPSILON);
assert!(reflected.z.abs() < f32::EPSILON);
}
#[test]
fn carpet_room_attenuates_more() {
let concrete_room = AcousticRoom::shoebox(10.0, 8.0, 3.0, AcousticMaterial::concrete());
let carpet_room = AcousticRoom::shoebox(10.0, 8.0, 3.0, AcousticMaterial::carpet());
let source = Vec3::new(3.0, 1.5, 4.0);
let listener = Vec3::new(7.0, 1.5, 4.0);
let c = speed_of_sound(20.0);
let concrete_refs = compute_early_reflections(source, listener, &concrete_room, 2, c);
let carpet_refs = compute_early_reflections(source, listener, &carpet_room, 2, c);
let concrete_first: f32 = concrete_refs
.iter()
.filter(|r| r.order == 1)
.map(|r| r.amplitude.iter().sum::<f32>())
.sum();
let carpet_first: f32 = carpet_refs
.iter()
.filter(|r| r.order == 1)
.map(|r| r.amplitude.iter().sum::<f32>())
.sum();
assert!(
concrete_first > carpet_first,
"concrete reflections ({concrete_first}) should be stronger than carpet ({carpet_first})"
);
}
#[test]
fn direct_path_attenuation_is_one() {
let materials = std::array::from_fn(|_| AcousticMaterial::concrete());
let source = Vec3::new(5.0, 1.5, 4.0);
let sources = compute_image_sources_shoebox(source, 10.0, 8.0, 3.0, &materials, 1);
let direct = sources.iter().find(|s| s.order == 0).unwrap();
for &a in &direct.attenuation {
assert!(
(a - 1.0).abs() < f32::EPSILON,
"direct path should have unit attenuation"
);
}
}
#[test]
fn empty_geometry_returns_empty() {
let room = AcousticRoom {
geometry: crate::room::RoomGeometry { walls: vec![] },
temperature_celsius: 20.0,
humidity_percent: 50.0,
};
let reflections = compute_early_reflections(
Vec3::new(1.0, 1.0, 1.0),
Vec3::new(2.0, 1.0, 1.0),
&room,
3,
343.0,
);
assert!(
reflections.is_empty(),
"empty geometry should produce no reflections"
);
}
#[test]
fn zero_speed_of_sound_returns_empty() {
let room = AcousticRoom::shoebox(10.0, 8.0, 3.0, AcousticMaterial::concrete());
let reflections = compute_early_reflections(
Vec3::new(3.0, 1.5, 4.0),
Vec3::new(7.0, 1.5, 4.0),
&room,
3,
0.0,
);
assert!(reflections.is_empty());
}
#[test]
fn max_order_zero_direct_only() {
let room = test_room();
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(
Vec3::new(3.0, 1.5, 4.0),
Vec3::new(7.0, 1.5, 4.0),
&room,
0,
c,
);
assert_eq!(reflections.len(), 1, "order 0 should only have direct path");
assert_eq!(reflections[0].order, 0);
}
#[test]
fn collocated_source_listener() {
let room = test_room();
let c = speed_of_sound(20.0);
let pos = Vec3::new(5.0, 1.5, 4.0);
let reflections = compute_early_reflections(pos, pos, &room, 2, c);
let has_higher_order = reflections.iter().any(|r| r.order > 0);
assert!(
has_higher_order,
"collocated should still produce reflections"
);
}
#[test]
fn general_method_empty_walls() {
let sources = compute_image_sources_general(Vec3::ZERO, &[], 3);
assert_eq!(sources.len(), 1, "should have only direct source");
assert_eq!(sources[0].order, 0);
}
#[test]
fn early_reflections_non_shoebox_room() {
let mat = AcousticMaterial::concrete();
let room = AcousticRoom {
geometry: crate::room::RoomGeometry {
walls: vec![
crate::room::Wall {
vertices: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(10.0, 0.0, 0.0),
Vec3::new(10.0, 3.0, 0.0),
Vec3::new(0.0, 3.0, 0.0),
],
material: mat.clone(),
normal: Vec3::new(0.0, 0.0, -1.0),
},
crate::room::Wall {
vertices: vec![
Vec3::new(0.0, 0.0, 8.0),
Vec3::new(10.0, 0.0, 8.0),
Vec3::new(10.0, 3.0, 8.0),
Vec3::new(0.0, 3.0, 8.0),
],
material: mat.clone(),
normal: Vec3::new(0.0, 0.0, 1.0),
},
crate::room::Wall {
vertices: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 8.0),
Vec3::new(0.0, 3.0, 8.0),
Vec3::new(0.0, 3.0, 0.0),
],
material: mat.clone(),
normal: Vec3::new(-1.0, 0.0, 0.0),
},
crate::room::Wall {
vertices: vec![
Vec3::new(10.0, 0.0, 0.0),
Vec3::new(10.0, 0.0, 8.0),
Vec3::new(10.0, 3.0, 8.0),
Vec3::new(10.0, 3.0, 0.0),
],
material: mat,
normal: Vec3::new(1.0, 0.0, 0.0),
},
],
},
temperature_celsius: 20.0,
humidity_percent: 50.0,
};
let c = speed_of_sound(20.0);
let reflections = compute_early_reflections(
Vec3::new(5.0, 1.5, 4.0),
Vec3::new(5.0, 1.5, 4.0 + 0.5),
&room,
2,
c,
);
assert!(!reflections.is_empty());
}
}