Skip to main content

autoeq_roomsim/
lib.rs

1//! WASM bindings for Room Acoustics Simulator
2//!
3//! This module provides WebAssembly bindings to run room acoustics
4//! simulations in the browser. It supports:
5//! - Rectangular and L-shaped room geometries
6//! - Multiple sources with directivity and crossover filters
7//! - Multiple solver methods:
8//!   - Direct field computation (free-field propagation)
9//!   - Image Source Method (ISM) with 1st, 2nd, 3rd order reflections
10//!   - Modal analysis for low frequencies
11//!   - Hybrid modal/ISM with Schroeder frequency crossover
12//!   - BEM (Boundary Element Method) for accurate wave-based simulation
13//! - Frequency response and spatial slice visualization
14//! - Impulse response generation
15//! - Binaural rendering
16//!
17//! The BEM solver uses pure Rust linear algebra (no BLAS) for WASM compatibility.
18
19use ndarray::Array2;
20use num_complex::Complex64;
21use rayon::prelude::*;
22use serde::{Deserialize, Serialize};
23use std::f64::consts::PI;
24use wasm_bindgen::prelude::*;
25
26// Re-export thread pool initialization for WASM
27pub use wasm_bindgen_rayon::init_thread_pool;
28
29// BEM solver from math-bem (with pure Rust fallbacks for WASM)
30mod bem_solver;
31mod scattering_objects;
32
33// Re-export BEM types for external use
34pub use bem_solver::{BemAssemblyMethod, BemConfig, BemResult, BemSolverMethod, FmmConfig};
35pub use scattering_objects::{BoxObject, CylinderObject, ScatteringObjectConfig, SphereObject};
36
37// ============================================================================
38// Wall Materials and Absorption Coefficients
39// ============================================================================
40
41/// Standard octave band center frequencies for absorption coefficients (Hz)
42pub const ABSORPTION_FREQUENCIES: [f64; 6] = [125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0];
43
44/// Wall material with frequency-dependent absorption coefficients
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WallMaterial {
47    pub name: String,
48    /// Absorption coefficients at 125, 250, 500, 1000, 2000, 4000 Hz
49    pub absorption: [f64; 6],
50}
51
52impl WallMaterial {
53    /// Create a new wall material with given absorption coefficients
54    pub fn new(name: &str, absorption: [f64; 6]) -> Self {
55        Self {
56            name: name.to_string(),
57            absorption,
58        }
59    }
60
61    /// Get absorption coefficient at a given frequency (interpolated)
62    pub fn absorption_at_frequency(&self, frequency: f64) -> f64 {
63        // Clamp to frequency range
64        if frequency <= ABSORPTION_FREQUENCIES[0] {
65            return self.absorption[0];
66        }
67        if frequency >= ABSORPTION_FREQUENCIES[5] {
68            return self.absorption[5];
69        }
70
71        // Find bracketing frequencies and interpolate logarithmically
72        for i in 0..5 {
73            if frequency >= ABSORPTION_FREQUENCIES[i] && frequency < ABSORPTION_FREQUENCIES[i + 1] {
74                let log_f = frequency.ln();
75                let log_f1 = ABSORPTION_FREQUENCIES[i].ln();
76                let log_f2 = ABSORPTION_FREQUENCIES[i + 1].ln();
77                let t = (log_f - log_f1) / (log_f2 - log_f1);
78                return self.absorption[i] * (1.0 - t) + self.absorption[i + 1] * t;
79            }
80        }
81
82        self.absorption[5]
83    }
84
85    /// Convert absorption coefficient to reflection coefficient: R = sqrt(1 - α)
86    pub fn reflection_at_frequency(&self, frequency: f64) -> f64 {
87        let alpha = self.absorption_at_frequency(frequency).clamp(0.0, 0.99);
88        (1.0 - alpha).sqrt()
89    }
90
91    // ========== Material Presets ==========
92
93    /// Concrete or brick (painted)
94    pub fn concrete() -> Self {
95        Self::new("Concrete", [0.01, 0.01, 0.02, 0.02, 0.02, 0.03])
96    }
97
98    /// Unpainted brick
99    pub fn brick() -> Self {
100        Self::new("Brick", [0.03, 0.03, 0.03, 0.04, 0.05, 0.07])
101    }
102
103    /// Gypsum board (drywall) on studs
104    pub fn drywall() -> Self {
105        Self::new("Drywall", [0.29, 0.10, 0.05, 0.04, 0.07, 0.09])
106    }
107
108    /// Plaster on brick
109    pub fn plaster() -> Self {
110        Self::new("Plaster", [0.01, 0.02, 0.02, 0.03, 0.04, 0.05])
111    }
112
113    /// Glass (large pane)
114    pub fn glass() -> Self {
115        Self::new("Glass", [0.18, 0.06, 0.04, 0.03, 0.02, 0.02])
116    }
117
118    /// Wood paneling (thin)
119    pub fn wood_thin() -> Self {
120        Self::new("Wood (thin)", [0.42, 0.21, 0.10, 0.08, 0.06, 0.06])
121    }
122
123    /// Heavy wooden door
124    pub fn wood_thick() -> Self {
125        Self::new("Wood (thick)", [0.14, 0.10, 0.06, 0.08, 0.10, 0.10])
126    }
127
128    /// Carpet on concrete
129    pub fn carpet_thin() -> Self {
130        Self::new("Carpet (thin)", [0.02, 0.06, 0.14, 0.37, 0.60, 0.65])
131    }
132
133    /// Heavy carpet on underlay
134    pub fn carpet_thick() -> Self {
135        Self::new("Carpet (thick)", [0.08, 0.24, 0.57, 0.69, 0.71, 0.73])
136    }
137
138    /// Acoustic ceiling tiles
139    pub fn acoustic_tile() -> Self {
140        Self::new("Acoustic Tile", [0.50, 0.70, 0.60, 0.70, 0.70, 0.50])
141    }
142
143    /// Curtains (medium weight, draped)
144    pub fn curtains() -> Self {
145        Self::new("Curtains", [0.07, 0.31, 0.49, 0.75, 0.70, 0.60])
146    }
147
148    /// Acoustic foam panels
149    pub fn acoustic_foam() -> Self {
150        Self::new("Acoustic Foam", [0.08, 0.25, 0.60, 0.90, 0.95, 0.90])
151    }
152
153    /// Hardwood floor
154    pub fn hardwood() -> Self {
155        Self::new("Hardwood", [0.15, 0.11, 0.10, 0.07, 0.06, 0.07])
156    }
157
158    /// Concrete floor (sealed)
159    pub fn concrete_floor() -> Self {
160        Self::new("Concrete Floor", [0.01, 0.01, 0.02, 0.02, 0.02, 0.02])
161    }
162
163    /// Default material (moderate absorption, like plaster walls)
164    #[allow(clippy::should_implement_trait)]
165    pub fn default() -> Self {
166        Self::plaster()
167    }
168}
169
170/// Wall surfaces in a rectangular room
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum WallSurface {
173    /// Left wall (x = 0)
174    Left,
175    /// Right wall (x = width)
176    Right,
177    /// Front wall (y = 0)
178    Front,
179    /// Back wall (y = depth)
180    Back,
181    /// Floor (z = 0)
182    Floor,
183    /// Ceiling (z = height)
184    Ceiling,
185}
186
187impl WallSurface {
188    pub fn all() -> [WallSurface; 6] {
189        [
190            WallSurface::Left,
191            WallSurface::Right,
192            WallSurface::Front,
193            WallSurface::Back,
194            WallSurface::Floor,
195            WallSurface::Ceiling,
196        ]
197    }
198}
199
200#[wasm_bindgen]
201extern "C" {
202    #[wasm_bindgen(js_namespace = console)]
203    fn log(s: &str);
204}
205
206macro_rules! console_log {
207    ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
208}
209
210/// Initialize panic hook for better error messages in browser console
211#[wasm_bindgen]
212pub fn init_panic_hook() {
213    console_error_panic_hook::set_once();
214}
215
216// ============================================================================
217// Room Geometry Types (local implementation for WASM)
218// ============================================================================
219
220/// 3D point in space
221#[derive(Debug, Clone, Copy)]
222pub struct Point3D {
223    pub x: f64,
224    pub y: f64,
225    pub z: f64,
226}
227
228impl Point3D {
229    pub fn new(x: f64, y: f64, z: f64) -> Self {
230        Self { x, y, z }
231    }
232
233    pub fn distance_to(&self, other: &Point3D) -> f64 {
234        let dx = self.x - other.x;
235        let dy = self.y - other.y;
236        let dz = self.z - other.z;
237        (dx * dx + dy * dy + dz * dz).sqrt()
238    }
239}
240
241/// Rectangular room
242pub struct RectangularRoom {
243    pub width: f64,
244    pub depth: f64,
245    pub height: f64,
246}
247
248impl RectangularRoom {
249    pub fn new(width: f64, depth: f64, height: f64) -> Self {
250        Self {
251            width,
252            depth,
253            height,
254        }
255    }
256
257    pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
258        vec![
259            // Floor edges
260            (
261                Point3D::new(0.0, 0.0, 0.0),
262                Point3D::new(self.width, 0.0, 0.0),
263            ),
264            (
265                Point3D::new(self.width, 0.0, 0.0),
266                Point3D::new(self.width, self.depth, 0.0),
267            ),
268            (
269                Point3D::new(self.width, self.depth, 0.0),
270                Point3D::new(0.0, self.depth, 0.0),
271            ),
272            (
273                Point3D::new(0.0, self.depth, 0.0),
274                Point3D::new(0.0, 0.0, 0.0),
275            ),
276            // Ceiling edges
277            (
278                Point3D::new(0.0, 0.0, self.height),
279                Point3D::new(self.width, 0.0, self.height),
280            ),
281            (
282                Point3D::new(self.width, 0.0, self.height),
283                Point3D::new(self.width, self.depth, self.height),
284            ),
285            (
286                Point3D::new(self.width, self.depth, self.height),
287                Point3D::new(0.0, self.depth, self.height),
288            ),
289            (
290                Point3D::new(0.0, self.depth, self.height),
291                Point3D::new(0.0, 0.0, self.height),
292            ),
293            // Vertical edges
294            (
295                Point3D::new(0.0, 0.0, 0.0),
296                Point3D::new(0.0, 0.0, self.height),
297            ),
298            (
299                Point3D::new(self.width, 0.0, 0.0),
300                Point3D::new(self.width, 0.0, self.height),
301            ),
302            (
303                Point3D::new(self.width, self.depth, 0.0),
304                Point3D::new(self.width, self.depth, self.height),
305            ),
306            (
307                Point3D::new(0.0, self.depth, 0.0),
308                Point3D::new(0.0, self.depth, self.height),
309            ),
310        ]
311    }
312}
313
314/// L-shaped room
315pub struct LShapedRoom {
316    pub width1: f64,
317    pub depth1: f64,
318    pub width2: f64,
319    pub depth2: f64,
320    pub height: f64,
321}
322
323impl LShapedRoom {
324    pub fn new(width1: f64, depth1: f64, width2: f64, depth2: f64, height: f64) -> Self {
325        Self {
326            width1,
327            depth1,
328            width2,
329            depth2,
330            height,
331        }
332    }
333
334    /// Check if a 2D point (x, y) is inside the L-shaped room footprint
335    /// The L-shape consists of two rectangular regions:
336    /// - Region 1: x in [0, width1], y in [0, depth1]
337    /// - Region 2: x in [0, width2], y in [depth1, depth1 + depth2]
338    pub fn contains_xy(&self, x: f64, y: f64) -> bool {
339        let total_depth = self.depth1 + self.depth2;
340
341        // Check region 1 (front section, wider)
342        if x >= 0.0 && x <= self.width1 && y >= 0.0 && y <= self.depth1 {
343            return true;
344        }
345
346        // Check region 2 (back section, narrower)
347        if x >= 0.0 && x <= self.width2 && y >= self.depth1 && y <= total_depth {
348            return true;
349        }
350
351        false
352    }
353
354    /// Check if a 3D point is inside the L-shaped room
355    pub fn contains(&self, p: &Point3D) -> bool {
356        self.contains_xy(p.x, p.y) && p.z >= 0.0 && p.z <= self.height
357    }
358
359    /// Validate if an image source produces a valid reflection path
360    /// The reflection path from source to listener via the image source must:
361    /// 1. Cross the intended wall
362    /// 2. Not pass through any internal walls or outside the room
363    pub fn is_valid_image_source(
364        &self,
365        image: &Point3D,
366        listener: &Point3D,
367        _source: &Point3D,
368    ) -> bool {
369        // The image source should be outside the room (on the other side of the wall)
370        // For an L-shaped room, we also need to check that the path doesn't pass
371        // through the internal corner or outside the room
372
373        // Find the midpoint (approximate reflection point)
374        let _midpoint = Point3D::new(
375            (image.x + listener.x) / 2.0,
376            (image.y + listener.y) / 2.0,
377            (image.z + listener.z) / 2.0,
378        );
379
380        // The reflection point should be on or near a wall
381        // For simplicity, check if the midpoint is approximately on the room boundary
382        // This is a heuristic - for more accuracy, we'd need to intersect the line
383        // with each wall plane
384
385        // Check a few points along the path from listener to image
386        let segments = 10;
387        for i in 1..segments {
388            let t = i as f64 / segments as f64;
389            let test_point = Point3D::new(
390                listener.x + t * (image.x - listener.x),
391                listener.y + t * (image.y - listener.y),
392                listener.z + t * (image.z - listener.z),
393            );
394
395            // If the test point is outside the room but not the image source position,
396            // this could be an invalid path
397            if !self.contains(&test_point) {
398                // Allow the path to go outside only at the end (where image source is)
399                if i < segments - 1 {
400                    // Check if this is a legitimate boundary crossing
401                    // The path should cross only one wall
402                    let prev_t = (i - 1) as f64 / segments as f64;
403                    let prev_point = Point3D::new(
404                        listener.x + prev_t * (image.x - listener.x),
405                        listener.y + prev_t * (image.y - listener.y),
406                        listener.z + prev_t * (image.z - listener.z),
407                    );
408
409                    if self.contains(&prev_point) {
410                        // This is the crossing point - valid so far
411                        continue;
412                    } else {
413                        // Path was already outside - might be going through corner
414                        // This needs more sophisticated checking
415                    }
416                }
417            }
418        }
419
420        // For now, use a simpler heuristic:
421        // Reject image sources that would require reflection off internal corners
422        let total_depth = self.depth1 + self.depth2;
423
424        // Check if image source is in the "forbidden" region
425        // The forbidden region is where width1 < x < infinity AND depth1 < y < total_depth
426        // This is the area cut out of the L-shape
427        if image.x > self.width2 && image.y > self.depth1 && image.y < total_depth {
428            return false;
429        }
430
431        true
432    }
433
434    /// Generate first-order image sources for L-shaped room
435    /// L-shaped room has 8 walls: 6 exterior + 2 interior (at the step)
436    ///
437    /// Walls:
438    /// - Left: x = 0 (full depth)
439    /// - Right (section 1): x = width1, y in [0, depth1]
440    /// - Right (section 2): x = width2, y in [depth1, total_depth]
441    /// - Front: y = 0, x in [0, width1]
442    /// - Back: y = total_depth, x in [0, width2]
443    /// - Floor: z = 0
444    /// - Ceiling: z = height
445    /// - Interior wall 1 (step): x in [width2, width1], y = depth1
446    /// - Interior wall 2 (step): y in [depth1, depth1], x = width2
447    ///
448    /// Returns: Vec of (image_position, wall_name)
449    pub fn get_first_order_images(&self, source: &Point3D) -> Vec<(Point3D, &'static str)> {
450        let total_depth = self.depth1 + self.depth2;
451        let mut images = Vec::new();
452
453        // Left wall (x = 0) - always present for full depth
454        images.push((Point3D::new(-source.x, source.y, source.z), "left"));
455
456        // Right wall handling depends on source y position
457        if source.y <= self.depth1 {
458            // Source is in section 1 (front), reflect off x = width1
459            images.push((
460                Point3D::new(2.0 * self.width1 - source.x, source.y, source.z),
461                "right",
462            ));
463        } else {
464            // Source is in section 2 (back), reflect off x = width2
465            images.push((
466                Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
467                "right",
468            ));
469        }
470
471        // Front wall (y = 0)
472        images.push((Point3D::new(source.x, -source.y, source.z), "front"));
473
474        // Back wall (y = total_depth)
475        images.push((
476            Point3D::new(source.x, 2.0 * total_depth - source.y, source.z),
477            "back",
478        ));
479
480        // Floor (z = 0)
481        images.push((Point3D::new(source.x, source.y, -source.z), "floor"));
482
483        // Ceiling (z = height)
484        images.push((
485            Point3D::new(source.x, source.y, 2.0 * self.height - source.z),
486            "ceiling",
487        ));
488
489        // Interior walls (the step) - L-shape has two interior walls:
490        // 1. Horizontal interior wall: y = depth1, x in [width2, width1]
491        // 2. Vertical interior wall: x = width2, y in [depth1, total_depth] (but only visible from section 1)
492
493        // Horizontal interior wall (y = depth1)
494        // Visible to sources in section 1 (y < depth1) when x > width2
495        if source.y < self.depth1 && source.x > self.width2 {
496            // Source is in the wider front section, can see the horizontal step
497            images.push((
498                Point3D::new(source.x, 2.0 * self.depth1 - source.y, source.z),
499                "step_horizontal",
500            ));
501        }
502
503        // Vertical interior wall (x = width2)
504        // Visible to sources in section 1 (y < depth1) when looking toward section 2
505        // The wall runs from y = depth1 to y = total_depth at x = width2
506        if source.y < self.depth1 && source.x > self.width2 {
507            // Source in overhang area can see vertical interior wall
508            images.push((
509                Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
510                "step_vertical",
511            ));
512        }
513
514        // Also check: sources in section 2 looking back at the vertical step
515        // (from the narrow back section toward the wider front)
516        if source.y > self.depth1 && source.x < self.width2 {
517            // Source in section 2 can see vertical interior wall from behind
518            // Reflection off x = width2 going toward section 1
519            // But this would put the image in the "cut-out" area, which we validate later
520            images.push((
521                Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
522                "step_vertical",
523            ));
524        }
525
526        images
527    }
528
529    pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
530        let total_depth = self.depth1 + self.depth2;
531        vec![
532            // Floor edges - Main section
533            (
534                Point3D::new(0.0, 0.0, 0.0),
535                Point3D::new(self.width1, 0.0, 0.0),
536            ),
537            (
538                Point3D::new(self.width1, 0.0, 0.0),
539                Point3D::new(self.width1, self.depth1, 0.0),
540            ),
541            (
542                Point3D::new(self.width1, self.depth1, 0.0),
543                Point3D::new(self.width2, self.depth1, 0.0),
544            ),
545            (
546                Point3D::new(self.width2, self.depth1, 0.0),
547                Point3D::new(self.width2, total_depth, 0.0),
548            ),
549            (
550                Point3D::new(self.width2, total_depth, 0.0),
551                Point3D::new(0.0, total_depth, 0.0),
552            ),
553            (
554                Point3D::new(0.0, total_depth, 0.0),
555                Point3D::new(0.0, 0.0, 0.0),
556            ),
557            // Ceiling edges
558            (
559                Point3D::new(0.0, 0.0, self.height),
560                Point3D::new(self.width1, 0.0, self.height),
561            ),
562            (
563                Point3D::new(self.width1, 0.0, self.height),
564                Point3D::new(self.width1, self.depth1, self.height),
565            ),
566            (
567                Point3D::new(self.width1, self.depth1, self.height),
568                Point3D::new(self.width2, self.depth1, self.height),
569            ),
570            (
571                Point3D::new(self.width2, self.depth1, self.height),
572                Point3D::new(self.width2, total_depth, self.height),
573            ),
574            (
575                Point3D::new(self.width2, total_depth, self.height),
576                Point3D::new(0.0, total_depth, self.height),
577            ),
578            (
579                Point3D::new(0.0, total_depth, self.height),
580                Point3D::new(0.0, 0.0, self.height),
581            ),
582            // Vertical edges
583            (
584                Point3D::new(0.0, 0.0, 0.0),
585                Point3D::new(0.0, 0.0, self.height),
586            ),
587            (
588                Point3D::new(self.width1, 0.0, 0.0),
589                Point3D::new(self.width1, 0.0, self.height),
590            ),
591            (
592                Point3D::new(self.width1, self.depth1, 0.0),
593                Point3D::new(self.width1, self.depth1, self.height),
594            ),
595            (
596                Point3D::new(self.width2, self.depth1, 0.0),
597                Point3D::new(self.width2, self.depth1, self.height),
598            ),
599            (
600                Point3D::new(self.width2, total_depth, 0.0),
601                Point3D::new(self.width2, total_depth, self.height),
602            ),
603            (
604                Point3D::new(0.0, total_depth, 0.0),
605                Point3D::new(0.0, total_depth, self.height),
606            ),
607        ]
608    }
609}
610
611/// Room geometry enum
612pub enum RoomGeometry {
613    Rectangular(RectangularRoom),
614    LShaped(LShapedRoom),
615}
616
617impl RoomGeometry {
618    pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
619        match self {
620            RoomGeometry::Rectangular(r) => r.get_edges(),
621            RoomGeometry::LShaped(r) => r.get_edges(),
622        }
623    }
624}
625
626/// Crossover filter
627pub enum CrossoverFilter {
628    FullRange,
629    Lowpass {
630        cutoff_freq: f64,
631        order: u32,
632    },
633    Highpass {
634        cutoff_freq: f64,
635        order: u32,
636    },
637    Bandpass {
638        low_cutoff: f64,
639        high_cutoff: f64,
640        order: u32,
641    },
642}
643
644impl CrossoverFilter {
645    pub fn amplitude_at_frequency(&self, frequency: f64) -> f64 {
646        match self {
647            CrossoverFilter::FullRange => 1.0,
648            CrossoverFilter::Lowpass { cutoff_freq, order } => {
649                let ratio = frequency / cutoff_freq;
650                1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
651            }
652            CrossoverFilter::Highpass { cutoff_freq, order } => {
653                let ratio = cutoff_freq / frequency;
654                1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
655            }
656            CrossoverFilter::Bandpass {
657                low_cutoff,
658                high_cutoff,
659                order,
660            } => {
661                let hp = 1.0 / (1.0 + (low_cutoff / frequency).powi(*order as i32 * 2)).sqrt();
662                let lp = 1.0 / (1.0 + (frequency / high_cutoff).powi(*order as i32 * 2)).sqrt();
663                hp * lp
664            }
665        }
666    }
667}
668
669/// Directivity pattern
670pub struct DirectivityPattern {
671    pub horizontal_angles: Vec<f64>,
672    pub vertical_angles: Vec<f64>,
673    pub magnitude: Array2<f64>,
674}
675
676impl DirectivityPattern {
677    pub fn omnidirectional() -> Self {
678        let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
679        let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
680        let magnitude = Array2::ones((vertical_angles.len(), horizontal_angles.len()));
681
682        Self {
683            horizontal_angles,
684            vertical_angles,
685            magnitude,
686        }
687    }
688
689    pub fn interpolate(&self, theta: f64, phi: f64) -> f64 {
690        let theta_deg = theta.to_degrees();
691        let mut phi_deg = phi.to_degrees();
692
693        while phi_deg < 0.0 {
694            phi_deg += 360.0;
695        }
696        while phi_deg >= 360.0 {
697            phi_deg -= 360.0;
698        }
699
700        let h_idx = (phi_deg / 10.0).floor() as usize;
701        let v_idx = (theta_deg / 10.0).floor() as usize;
702
703        let h_idx = h_idx.min(self.horizontal_angles.len() - 1);
704        let v_idx = v_idx.min(self.vertical_angles.len() - 1);
705
706        let h_next = (h_idx + 1) % self.horizontal_angles.len();
707        let v_next = (v_idx + 1).min(self.vertical_angles.len() - 1);
708
709        let h_frac = (phi_deg / 10.0) - h_idx as f64;
710        let v_frac = (theta_deg / 10.0) - v_idx as f64;
711
712        let m00 = self.magnitude[[v_idx, h_idx]];
713        let m01 = self.magnitude[[v_idx, h_next]];
714        let m10 = self.magnitude[[v_next, h_idx]];
715        let m11 = self.magnitude[[v_next, h_next]];
716
717        let m0 = m00 * (1.0 - h_frac) + m01 * h_frac;
718        let m1 = m10 * (1.0 - h_frac) + m11 * h_frac;
719
720        m0 * (1.0 - v_frac) + m1 * v_frac
721    }
722}
723
724/// Sound source
725pub struct Source {
726    pub position: Point3D,
727    pub directivity: DirectivityPattern,
728    pub amplitude: f64,
729    pub crossover: CrossoverFilter,
730    pub name: String,
731    /// Time delay in seconds
732    pub delay_sec: f64,
733    /// Phase inversion flag (true = 180 degree phase shift)
734    pub invert_phase: bool,
735}
736
737impl Source {
738    pub fn new(position: Point3D, directivity: DirectivityPattern, amplitude: f64) -> Self {
739        Self {
740            position,
741            directivity,
742            amplitude,
743            crossover: CrossoverFilter::FullRange,
744            name: String::from("Source"),
745            delay_sec: 0.0,
746            invert_phase: false,
747        }
748    }
749
750    pub fn with_crossover(mut self, crossover: CrossoverFilter) -> Self {
751        self.crossover = crossover;
752        self
753    }
754
755    pub fn with_name(mut self, name: String) -> Self {
756        self.name = name;
757        self
758    }
759
760    pub fn with_delay_ms(mut self, delay_ms: f64) -> Self {
761        self.delay_sec = delay_ms / 1000.0;
762        self
763    }
764
765    pub fn with_phase_inversion(mut self, invert: bool) -> Self {
766        self.invert_phase = invert;
767        self
768    }
769
770    /// Get the phase factor for this source at a given frequency
771    /// Includes both time delay and phase inversion
772    pub fn phase_factor(&self, frequency: f64) -> Complex64 {
773        let omega = 2.0 * PI * frequency;
774        // Phase shift from delay: e^(-i * omega * delay)
775        let delay_phase = Complex64::new(0.0, -omega * self.delay_sec).exp();
776        // Phase inversion: multiply by -1
777        if self.invert_phase {
778            -delay_phase
779        } else {
780            delay_phase
781        }
782    }
783
784    pub fn amplitude_towards(&self, point: &Point3D, frequency: f64) -> f64 {
785        let dx = point.x - self.position.x;
786        let dy = point.y - self.position.y;
787        let dz = point.z - self.position.z;
788
789        let r = (dx * dx + dy * dy + dz * dz).sqrt();
790        if r < 1e-10 {
791            return self.amplitude * self.crossover.amplitude_at_frequency(frequency);
792        }
793
794        let theta = (dz / r).acos();
795        let phi = dy.atan2(dx);
796
797        let directivity_factor = self.directivity.interpolate(theta, phi);
798        let crossover_factor = self.crossover.amplitude_at_frequency(frequency);
799        self.amplitude * directivity_factor * crossover_factor
800    }
801}
802
803// ============================================================================
804// Configuration types (JSON-serializable)
805// ============================================================================
806
807/// Room geometry configuration
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(tag = "type")]
810pub enum RoomGeometryConfig {
811    #[serde(rename = "rectangular")]
812    Rectangular { width: f64, depth: f64, height: f64 },
813    #[serde(rename = "lshaped")]
814    LShaped {
815        width1: f64,
816        depth1: f64,
817        width2: f64,
818        depth2: f64,
819        height: f64,
820    },
821}
822
823/// 3D point configuration
824#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
825pub struct Point3DConfig {
826    pub x: f64,
827    pub y: f64,
828    pub z: f64,
829}
830
831impl From<Point3DConfig> for Point3D {
832    fn from(p: Point3DConfig) -> Self {
833        Point3D::new(p.x, p.y, p.z)
834    }
835}
836
837impl From<Point3DConfig> for math_audio_bem::room_acoustics::Point3D {
838    fn from(p: Point3DConfig) -> Self {
839        math_audio_bem::room_acoustics::Point3D::new(p.x, p.y, p.z)
840    }
841}
842
843/// Crossover filter configuration
844#[derive(Debug, Clone, Default, Serialize, Deserialize)]
845#[serde(tag = "type")]
846pub enum CrossoverConfig {
847    #[serde(rename = "fullrange")]
848    #[default]
849    FullRange,
850    #[serde(rename = "lowpass")]
851    Lowpass { cutoff_freq: f64, order: u32 },
852    #[serde(rename = "highpass")]
853    Highpass { cutoff_freq: f64, order: u32 },
854    #[serde(rename = "bandpass")]
855    Bandpass {
856        low_cutoff: f64,
857        high_cutoff: f64,
858        order: u32,
859    },
860}
861
862impl CrossoverConfig {
863    fn to_filter(&self) -> CrossoverFilter {
864        match self {
865            CrossoverConfig::FullRange => CrossoverFilter::FullRange,
866            CrossoverConfig::Lowpass { cutoff_freq, order } => CrossoverFilter::Lowpass {
867                cutoff_freq: *cutoff_freq,
868                order: *order,
869            },
870            CrossoverConfig::Highpass { cutoff_freq, order } => CrossoverFilter::Highpass {
871                cutoff_freq: *cutoff_freq,
872                order: *order,
873            },
874            CrossoverConfig::Bandpass {
875                low_cutoff,
876                high_cutoff,
877                order,
878            } => CrossoverFilter::Bandpass {
879                low_cutoff: *low_cutoff,
880                high_cutoff: *high_cutoff,
881                order: *order,
882            },
883        }
884    }
885}
886
887/// Directivity pattern configuration
888#[derive(Debug, Clone, Default, Serialize, Deserialize)]
889#[serde(tag = "type")]
890pub enum DirectivityConfig {
891    #[serde(rename = "omnidirectional")]
892    #[default]
893    Omnidirectional,
894    #[serde(rename = "cardioid")]
895    Cardioid { front_back_ratio: f64 },
896    /// Spinorama directivity data with horizontal and vertical SPL curves at different angles
897    #[serde(rename = "spinorama")]
898    Spinorama {
899        /// Horizontal SPL data at different angles
900        horizontal: Vec<SpinoramaCurve>,
901        /// Vertical SPL data at different angles
902        vertical: Vec<SpinoramaCurve>,
903    },
904}
905
906/// A single SPL curve at a specific angle (for spinorama directivity data)
907#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct SpinoramaCurve {
909    /// Angle in degrees (e.g., -60, -50, ..., 0, ..., 50, 60)
910    pub angle: f64,
911    /// Frequency points in Hz
912    pub freq: Vec<f64>,
913    /// SPL values in dB
914    pub spl: Vec<f64>,
915}
916
917/// Wall material configuration (JSON-serializable)
918#[derive(Debug, Clone, Serialize, Deserialize)]
919#[serde(tag = "type")]
920pub enum WallMaterialConfig {
921    /// Use a preset material by name
922    #[serde(rename = "preset")]
923    Preset { name: String },
924    /// Custom absorption coefficients at 125, 250, 500, 1000, 2000, 4000 Hz
925    #[serde(rename = "custom")]
926    Custom {
927        name: String,
928        #[serde(default = "default_absorption")]
929        absorption: [f64; 6],
930    },
931}
932
933fn default_absorption() -> [f64; 6] {
934    [0.02, 0.02, 0.03, 0.03, 0.04, 0.05] // Default plaster-like
935}
936
937impl Default for WallMaterialConfig {
938    fn default() -> Self {
939        WallMaterialConfig::Preset {
940            name: "plaster".to_string(),
941        }
942    }
943}
944
945impl WallMaterialConfig {
946    /// Convert config to WallMaterial
947    pub fn to_material(&self) -> WallMaterial {
948        match self {
949            WallMaterialConfig::Preset { name } => {
950                match name.to_lowercase().as_str() {
951                    "concrete" => WallMaterial::concrete(),
952                    "brick" => WallMaterial::brick(),
953                    "drywall" | "gypsum" => WallMaterial::drywall(),
954                    "plaster" => WallMaterial::plaster(),
955                    "glass" => WallMaterial::glass(),
956                    "wood_thin" | "wood-thin" | "thin_wood" => WallMaterial::wood_thin(),
957                    "wood_thick" | "wood-thick" | "thick_wood" | "wood" => {
958                        WallMaterial::wood_thick()
959                    }
960                    "carpet_thin" | "carpet-thin" | "thin_carpet" => WallMaterial::carpet_thin(),
961                    "carpet_thick" | "carpet-thick" | "thick_carpet" | "carpet" => {
962                        WallMaterial::carpet_thick()
963                    }
964                    "acoustic_tile" | "acoustic-tile" | "ceiling_tile" => {
965                        WallMaterial::acoustic_tile()
966                    }
967                    "curtains" | "drapes" => WallMaterial::curtains(),
968                    "acoustic_foam" | "acoustic-foam" | "foam" => WallMaterial::acoustic_foam(),
969                    "hardwood" | "wood_floor" | "wood-floor" => WallMaterial::hardwood(),
970                    "concrete_floor" | "concrete-floor" => WallMaterial::concrete_floor(),
971                    _ => WallMaterial::plaster(), // Default fallback
972                }
973            }
974            WallMaterialConfig::Custom { name, absorption } => WallMaterial::new(name, *absorption),
975        }
976    }
977
978    /// Get absorption coefficient at a given frequency
979    pub fn absorption_at_frequency(&self, frequency: f64) -> f64 {
980        self.to_material().absorption_at_frequency(frequency)
981    }
982}
983
984/// Wall materials configuration for all 6 surfaces
985#[derive(Debug, Clone, Serialize, Deserialize)]
986pub struct WallMaterialsConfig {
987    /// Left wall (x = 0)
988    #[serde(default)]
989    pub left: WallMaterialConfig,
990    /// Right wall (x = width)
991    #[serde(default)]
992    pub right: WallMaterialConfig,
993    /// Front wall (y = 0)
994    #[serde(default)]
995    pub front: WallMaterialConfig,
996    /// Back wall (y = depth)
997    #[serde(default)]
998    pub back: WallMaterialConfig,
999    /// Floor (z = 0)
1000    #[serde(default = "default_floor_material")]
1001    pub floor: WallMaterialConfig,
1002    /// Ceiling (z = height)
1003    #[serde(default = "default_ceiling_material")]
1004    pub ceiling: WallMaterialConfig,
1005}
1006
1007fn default_floor_material() -> WallMaterialConfig {
1008    WallMaterialConfig::Preset {
1009        name: "hardwood".to_string(),
1010    }
1011}
1012
1013fn default_ceiling_material() -> WallMaterialConfig {
1014    WallMaterialConfig::Preset {
1015        name: "plaster".to_string(),
1016    }
1017}
1018
1019impl Default for WallMaterialsConfig {
1020    fn default() -> Self {
1021        Self {
1022            left: WallMaterialConfig::default(),
1023            right: WallMaterialConfig::default(),
1024            front: WallMaterialConfig::default(),
1025            back: WallMaterialConfig::default(),
1026            floor: default_floor_material(),
1027            ceiling: default_ceiling_material(),
1028        }
1029    }
1030}
1031
1032impl WallMaterialsConfig {
1033    /// Get the material for a specific wall surface
1034    pub fn get_material(&self, surface: WallSurface) -> WallMaterial {
1035        match surface {
1036            WallSurface::Left => self.left.to_material(),
1037            WallSurface::Right => self.right.to_material(),
1038            WallSurface::Front => self.front.to_material(),
1039            WallSurface::Back => self.back.to_material(),
1040            WallSurface::Floor => self.floor.to_material(),
1041            WallSurface::Ceiling => self.ceiling.to_material(),
1042        }
1043    }
1044
1045    /// Get reflection coefficient for a wall at a given frequency
1046    pub fn reflection_at(&self, surface: WallSurface, frequency: f64) -> f64 {
1047        self.get_material(surface)
1048            .reflection_at_frequency(frequency)
1049    }
1050
1051    /// Get average absorption coefficient across all walls at a given frequency
1052    pub fn average_absorption_at(&self, frequency: f64) -> f64 {
1053        let surfaces = WallSurface::all();
1054        let total: f64 = surfaces
1055            .iter()
1056            .map(|&s| self.get_material(s).absorption_at_frequency(frequency))
1057            .sum();
1058        total / surfaces.len() as f64
1059    }
1060}
1061
1062/// Source configuration
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct SourceConfig {
1065    pub name: String,
1066    pub position: Point3DConfig,
1067    #[serde(default = "default_amplitude")]
1068    pub amplitude: f64,
1069    #[serde(default)]
1070    pub directivity: DirectivityConfig,
1071    #[serde(default)]
1072    pub crossover: CrossoverConfig,
1073    /// Time delay in milliseconds (for driver alignment)
1074    #[serde(default)]
1075    pub delay_ms: f64,
1076    /// Phase inversion (180 degree phase shift)
1077    #[serde(default)]
1078    pub invert_phase: bool,
1079}
1080
1081fn default_amplitude() -> f64 {
1082    1.0
1083}
1084
1085/// Frequency range configuration
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct FrequencyConfig {
1088    pub min_freq: f64,
1089    pub max_freq: f64,
1090    pub num_points: usize,
1091    #[serde(default = "default_spacing")]
1092    pub spacing: String,
1093}
1094
1095fn default_spacing() -> String {
1096    "logarithmic".to_string()
1097}
1098
1099/// Solver configuration
1100#[derive(Debug, Clone, Serialize, Deserialize)]
1101pub struct SolverConfig {
1102    /// Solver method: "direct", "image-source-1/2/3", "modal", "hybrid", "bem", or "hybrid-bem"
1103    #[serde(default = "default_method")]
1104    pub method: String,
1105    #[serde(default = "default_mesh_resolution")]
1106    pub mesh_resolution: usize,
1107    #[serde(default = "default_speed_of_sound")]
1108    pub speed_of_sound: f64,
1109    /// Temperature in Celsius (affects speed of sound and air absorption)
1110    #[serde(default = "default_temperature")]
1111    pub temperature: f64,
1112    /// Relative humidity in percent (0-100, affects air absorption)
1113    #[serde(default = "default_humidity")]
1114    pub humidity: f64,
1115    /// Enable air absorption modeling
1116    #[serde(default = "default_air_absorption")]
1117    pub air_absorption: bool,
1118    /// Enable edge diffraction modeling (adds diffraction contributions at room corners)
1119    #[serde(default = "default_edge_diffraction")]
1120    pub edge_diffraction: bool,
1121    /// Crossover width for hybrid solver (in octaves, default 0.5)
1122    #[serde(default = "default_hybrid_crossover_width")]
1123    pub hybrid_crossover_width: f64,
1124    /// Maximum mode order for modal analysis (default 20)
1125    #[serde(default = "default_max_mode_order")]
1126    pub max_mode_order: u32,
1127    /// Modal damping factor (Q factor, default 50)
1128    #[serde(default = "default_modal_damping")]
1129    pub modal_damping: f64,
1130    /// BEM solver configuration (used when method is "bem" or "hybrid-bem")
1131    #[serde(default)]
1132    pub bem_config: BemConfig,
1133}
1134
1135fn default_method() -> String {
1136    "direct".to_string()
1137}
1138fn default_mesh_resolution() -> usize {
1139    2
1140}
1141fn default_speed_of_sound() -> f64 {
1142    343.0
1143}
1144fn default_temperature() -> f64 {
1145    20.0
1146} // 20°C
1147fn default_humidity() -> f64 {
1148    50.0
1149} // 50% relative humidity
1150fn default_air_absorption() -> bool {
1151    true
1152}
1153fn default_edge_diffraction() -> bool {
1154    false
1155} // Disabled by default (computationally expensive)
1156fn default_hybrid_crossover_width() -> f64 {
1157    0.5
1158} // 0.5 octaves
1159fn default_max_mode_order() -> u32 {
1160    20
1161}
1162fn default_modal_damping() -> f64 {
1163    10.0
1164} // Q factor (typical for residential rooms)
1165
1166impl Default for SolverConfig {
1167    fn default() -> Self {
1168        Self {
1169            method: default_method(),
1170            mesh_resolution: default_mesh_resolution(),
1171            speed_of_sound: default_speed_of_sound(),
1172            temperature: default_temperature(),
1173            humidity: default_humidity(),
1174            air_absorption: default_air_absorption(),
1175            edge_diffraction: default_edge_diffraction(),
1176            hybrid_crossover_width: default_hybrid_crossover_width(),
1177            max_mode_order: default_max_mode_order(),
1178            modal_damping: default_modal_damping(),
1179            bem_config: BemConfig::default(),
1180        }
1181    }
1182}
1183
1184/// Calculate air absorption coefficient (Np/m) using ISO 9613-1 approximation
1185///
1186/// This is a simplified model based on ISO 9613-1 that provides reasonable
1187/// accuracy for typical room acoustics conditions (15-30°C, 20-80% RH).
1188///
1189/// Returns the absorption coefficient in Nepers per meter (Np/m).
1190/// To convert to dB/m: multiply by 8.686
1191///
1192/// # Arguments
1193/// * `frequency` - Sound frequency in Hz
1194/// * `temperature` - Temperature in Celsius
1195/// * `humidity` - Relative humidity in percent (0-100)
1196///
1197/// # Reference
1198/// ISO 9613-1:1993 - Acoustics -- Attenuation of sound during propagation outdoors
1199pub fn calculate_air_absorption(frequency: f64, temperature: f64, humidity: f64) -> f64 {
1200    // Simplified air absorption model based on empirical data (Np/m)
1201    // This provides reasonable accuracy for typical room acoustics conditions.
1202    //
1203    // Reference values at 20°C, 50% RH:
1204    // ~0.0001 Np/m at 500 Hz
1205    // ~0.001 Np/m at 1 kHz
1206    // ~0.004 Np/m at 4 kHz
1207    // ~0.010 Np/m at 8 kHz
1208    //
1209    // For more accurate results, implement full ISO 9613-1 calculation.
1210
1211    let base_absorption = match frequency {
1212        f if f < 500.0 => 0.0001 * (f / 500.0).powi(2),
1213        f if f < 2000.0 => 0.0001 + 0.0009 * ((f - 500.0) / 1500.0),
1214        f if f < 8000.0 => 0.001 + 0.009 * ((f - 2000.0) / 6000.0),
1215        _ => 0.01 + 0.005 * ((frequency - 8000.0) / 8000.0),
1216    };
1217
1218    // Temperature correction (absorption increases ~2%/°C deviation from 20°C)
1219    let temp_factor = 1.0 + 0.02 * (temperature - 20.0).abs();
1220
1221    // Humidity correction (absorption decreases with humidity up to ~40%, then increases)
1222    let humidity_factor = if humidity < 40.0 {
1223        1.0 + 0.01 * (40.0 - humidity)
1224    } else {
1225        1.0 + 0.005 * (humidity - 40.0)
1226    };
1227
1228    base_absorption * temp_factor * humidity_factor
1229}
1230
1231/// Visualization configuration
1232#[derive(Debug, Clone, Serialize, Deserialize)]
1233pub struct VisualizationConfig {
1234    #[serde(default = "default_generate_slices")]
1235    pub generate_slices: bool,
1236    #[serde(default = "default_slice_resolution")]
1237    pub slice_resolution: usize,
1238    #[serde(default)]
1239    pub slice_frequency_indices: Vec<usize>,
1240    /// Generate impulse response output
1241    #[serde(default = "default_generate_impulse_response")]
1242    pub generate_impulse_response: bool,
1243    /// Impulse response configuration
1244    #[serde(default)]
1245    pub impulse_response: ImpulseResponseConfig,
1246    /// Binaural rendering configuration
1247    #[serde(default)]
1248    pub binaural: BinauralConfig,
1249}
1250
1251fn default_generate_slices() -> bool {
1252    true
1253}
1254fn default_slice_resolution() -> usize {
1255    50
1256}
1257fn default_generate_impulse_response() -> bool {
1258    false
1259}
1260
1261impl Default for VisualizationConfig {
1262    fn default() -> Self {
1263        Self {
1264            generate_slices: true,
1265            slice_resolution: 50,
1266            slice_frequency_indices: Vec::new(),
1267            generate_impulse_response: false,
1268            impulse_response: ImpulseResponseConfig::default(),
1269            binaural: BinauralConfig::default(),
1270        }
1271    }
1272}
1273
1274/// Complete simulation configuration
1275#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct SimulationConfig {
1277    pub room: RoomGeometryConfig,
1278    pub sources: Vec<SourceConfig>,
1279    pub listening_positions: Vec<Point3DConfig>,
1280    pub frequencies: FrequencyConfig,
1281    #[serde(default)]
1282    pub solver: SolverConfig,
1283    #[serde(default)]
1284    pub visualization: VisualizationConfig,
1285    #[serde(default)]
1286    pub wall_materials: WallMaterialsConfig,
1287    #[serde(default)]
1288    pub metadata: MetadataConfig,
1289    /// Scattering objects inside the room (furniture, equipment, etc.)
1290    /// Only used with BEM solver methods ("bem" or "hybrid-bem")
1291    #[serde(default)]
1292    pub scattering_objects: Vec<ScatteringObjectConfig>,
1293}
1294
1295/// Simulation metadata
1296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1297pub struct MetadataConfig {
1298    #[serde(default)]
1299    pub description: String,
1300    #[serde(default)]
1301    pub author: String,
1302    #[serde(default)]
1303    pub date: String,
1304    #[serde(default)]
1305    pub notes: String,
1306}
1307
1308// ============================================================================
1309// Output types
1310// ============================================================================
1311
1312#[derive(Debug, Clone, Serialize)]
1313pub struct SourceResponse {
1314    pub source_name: String,
1315    pub spl: Vec<f64>,
1316}
1317
1318#[derive(Debug, Clone, Serialize)]
1319pub struct SliceOutput {
1320    pub frequency: f64,
1321    pub x: Vec<f64>,
1322    pub y: Vec<f64>,
1323    #[serde(skip_serializing_if = "Option::is_none")]
1324    pub z: Option<Vec<f64>>,
1325    pub spl: Vec<f64>,
1326    pub shape: [usize; 2],
1327}
1328
1329#[derive(Debug, Clone, Serialize)]
1330pub struct RoomOutput {
1331    pub width: f64,
1332    pub depth: f64,
1333    pub height: f64,
1334    #[serde(skip_serializing_if = "Option::is_none")]
1335    pub room_type: Option<String>,
1336    pub edges: Vec<[[f64; 3]; 2]>,
1337}
1338
1339#[derive(Debug, Clone, Serialize)]
1340pub struct SimulationResults {
1341    pub room: RoomOutput,
1342    pub sources: Vec<SourceOutputInfo>,
1343    pub listening_position: [f64; 3],
1344    pub frequencies: Vec<f64>,
1345    pub frequency_response: Vec<f64>,
1346    #[serde(skip_serializing_if = "Option::is_none")]
1347    pub source_responses: Option<Vec<SourceResponse>>,
1348    #[serde(skip_serializing_if = "Option::is_none")]
1349    pub horizontal_slices: Option<Vec<SliceOutput>>,
1350    #[serde(skip_serializing_if = "Option::is_none")]
1351    pub vertical_slices: Option<Vec<SliceOutput>>,
1352    pub solver: String,
1353    #[serde(skip_serializing_if = "Option::is_none")]
1354    pub mesh_nodes: Option<usize>,
1355    #[serde(skip_serializing_if = "Option::is_none")]
1356    pub mesh_elements: Option<usize>,
1357    #[serde(skip_serializing_if = "Option::is_none")]
1358    pub metadata: Option<MetadataConfig>,
1359    #[serde(skip_serializing_if = "Option::is_none")]
1360    pub room_modes: Option<Vec<RoomMode>>,
1361    #[serde(skip_serializing_if = "Option::is_none")]
1362    pub room_acoustics: Option<RoomAcoustics>,
1363    #[serde(skip_serializing_if = "Option::is_none")]
1364    pub impulse_response: Option<ImpulseResponse>,
1365    #[serde(skip_serializing_if = "Option::is_none")]
1366    pub binaural_response: Option<BinauralResponse>,
1367}
1368
1369#[derive(Debug, Clone, Serialize)]
1370pub struct SourceOutputInfo {
1371    pub name: String,
1372    pub position: [f64; 3],
1373    #[serde(skip_serializing_if = "Option::is_none")]
1374    pub crossover: Option<String>,
1375}
1376
1377// ============================================================================
1378// Room Modes
1379// ============================================================================
1380
1381/// Represents a room resonant mode
1382#[derive(Debug, Clone, Serialize)]
1383pub struct RoomMode {
1384    /// Resonant frequency in Hz
1385    pub frequency: f64,
1386    /// Mode indices (n, m, p) for x, y, z dimensions
1387    pub indices: [u32; 3],
1388    /// Mode type: "axial", "tangential", or "oblique"
1389    pub mode_type: String,
1390    /// Description of the mode (e.g., "1,0,0 - Length mode")
1391    pub description: String,
1392}
1393
1394/// Calculate room modes for a rectangular room
1395///
1396/// Room modes occur at frequencies where standing waves form between parallel surfaces.
1397/// - Axial modes: Standing waves between one pair of parallel walls (n,0,0), (0,m,0), or (0,0,p)
1398/// - Tangential modes: Standing waves between two pairs of walls (n,m,0), (n,0,p), or (0,m,p)
1399/// - Oblique modes: Standing waves between all three pairs of walls (n,m,p)
1400///
1401/// Formula: f = (c/2) * sqrt((n/Lx)² + (m/Ly)² + (p/Lz)²)
1402pub fn calculate_room_modes(
1403    length_x: f64, // Room width (x dimension)
1404    length_y: f64, // Room depth (y dimension)
1405    length_z: f64, // Room height (z dimension)
1406    speed_of_sound: f64,
1407    max_frequency: f64,
1408    max_order: u32,
1409) -> Vec<RoomMode> {
1410    let mut modes = Vec::new();
1411    let c = speed_of_sound;
1412
1413    for n in 0..=max_order {
1414        for m in 0..=max_order {
1415            for p in 0..=max_order {
1416                // Skip the (0,0,0) mode
1417                if n == 0 && m == 0 && p == 0 {
1418                    continue;
1419                }
1420
1421                // Calculate mode frequency
1422                let nx = n as f64 / length_x;
1423                let my = m as f64 / length_y;
1424                let pz = p as f64 / length_z;
1425                let freq = (c / 2.0) * (nx * nx + my * my + pz * pz).sqrt();
1426
1427                if freq > max_frequency {
1428                    continue;
1429                }
1430
1431                // Determine mode type
1432                let zero_count = [n, m, p].iter().filter(|&&x| x == 0).count();
1433                let mode_type = match zero_count {
1434                    2 => "axial",
1435                    1 => "tangential",
1436                    0 => "oblique",
1437                    _ => continue, // Shouldn't happen
1438                };
1439
1440                // Generate description
1441                let description = match (n, m, p) {
1442                    (n, 0, 0) if n > 0 => format!("{},0,0 - Length mode (X)", n),
1443                    (0, m, 0) if m > 0 => format!("0,{},0 - Width mode (Y)", m),
1444                    (0, 0, p) if p > 0 => format!("0,0,{} - Height mode (Z)", p),
1445                    (n, m, 0) => format!("{},{},0 - Floor tangential", n, m),
1446                    (n, 0, p) => format!("{},0,{} - Side tangential", n, p),
1447                    (0, m, p) => format!("0,{},{} - Front tangential", m, p),
1448                    (n, m, p) => format!("{},{},{} - Oblique", n, m, p),
1449                };
1450
1451                modes.push(RoomMode {
1452                    frequency: freq,
1453                    indices: [n, m, p],
1454                    mode_type: mode_type.to_string(),
1455                    description,
1456                });
1457            }
1458        }
1459    }
1460
1461    // Sort by frequency
1462    modes.sort_by(|a, b| a.frequency.partial_cmp(&b.frequency).unwrap());
1463
1464    modes
1465}
1466
1467/// Calculate modal pressure at a point from room mode superposition
1468///
1469/// The pressure field is represented as a sum of standing wave modes:
1470/// p(x,y,z,f) = Σ A_nmp * cos(n*π*x/Lx) * cos(m*π*y/Ly) * cos(p*π*z/Lz) * H(f, f_nmp)
1471///
1472/// Where:
1473/// - A_nmp is the mode amplitude (depends on source position)
1474/// - H(f, f_nmp) is the modal transfer function (resonant response)
1475/// - f_nmp is the mode frequency
1476///
1477/// The transfer function H(f, f_nmp) = 1 / (1 - (f/f_nmp)² + j*f/(f_nmp*Q))
1478/// where Q is the modal damping factor.
1479///
1480/// Reference: Kuttruff, "Room Acoustics", Chapter 3 - Modal expansion of room Green's function
1481///
1482/// G_modal = (c²/V) Σ [ε * Ψ(rs) * Ψ(r) / (ω² - ωₙ² + j*δₙ*ω)]
1483///
1484/// This has units of 1/m, matching the free-field Green's function G = e^(ikr)/(4πr).
1485#[allow(clippy::too_many_arguments)]
1486pub fn calculate_modal_pressure(
1487    source: &Point3D,
1488    listener: &Point3D,
1489    frequency: f64,
1490    room_width: f64,  // Lx
1491    room_depth: f64,  // Ly
1492    room_height: f64, // Lz
1493    speed_of_sound: f64,
1494    max_mode_order: u32,
1495    modal_damping: f64, // Q factor
1496) -> Complex64 {
1497    let volume = room_width * room_depth * room_height;
1498
1499    // Direct source-listener distance for phase
1500    let r = source.distance_to(listener).max(0.1);
1501    let omega = 2.0 * PI * frequency;
1502    let omega_sq = omega * omega;
1503    let k = omega / speed_of_sound;
1504    let c_sq = speed_of_sound * speed_of_sound;
1505
1506    // Modal Green's function prefactor: c²/V (units: m²/s² / m³ = m⁻¹ when divided by ω²)
1507    let prefactor = c_sq / volume;
1508
1509    let mut modal_sum = Complex64::new(0.0, 0.0);
1510
1511    for n in 0..=max_mode_order {
1512        for m in 0..=max_mode_order {
1513            for p in 0..=max_mode_order {
1514                // Skip DC mode (0,0,0)
1515                if n == 0 && m == 0 && p == 0 {
1516                    continue;
1517                }
1518
1519                // Calculate mode angular frequency
1520                // ωₙ = c * π * sqrt((n/Lx)² + (m/Ly)² + (p/Lz)²)
1521                let nx = n as f64 / room_width;
1522                let my = m as f64 / room_depth;
1523                let pz = p as f64 / room_height;
1524                let omega_n = speed_of_sound * PI * (nx * nx + my * my + pz * pz).sqrt();
1525                let omega_n_sq = omega_n * omega_n;
1526
1527                // Mode frequency for filtering
1528                let mode_freq = omega_n / (2.0 * PI);
1529
1530                // Skip modes far from the frequency of interest (2 octaves)
1531                if mode_freq > frequency * 4.0 || mode_freq < frequency / 4.0 {
1532                    continue;
1533                }
1534
1535                // Calculate mode shape at source position
1536                // Ψ(r) = cos(nπx/Lx) * cos(mπy/Ly) * cos(pπz/Lz)
1537                let source_mode = (n as f64 * PI * source.x / room_width).cos()
1538                    * (m as f64 * PI * source.y / room_depth).cos()
1539                    * (p as f64 * PI * source.z / room_height).cos();
1540
1541                // Calculate mode shape at listener position
1542                let listener_mode = (n as f64 * PI * listener.x / room_width).cos()
1543                    * (m as f64 * PI * listener.y / room_depth).cos()
1544                    * (p as f64 * PI * listener.z / room_height).cos();
1545
1546                // Neumann factor: ε = 1 if index is 0, else 2
1547                let epsilon = |i: u32| if i == 0 { 1.0 } else { 2.0 };
1548                let mode_norm = epsilon(n) * epsilon(m) * epsilon(p);
1549
1550                // Modal transfer function (Kuttruff Eq. 3.27):
1551                // H(ω) = 1 / (ωₙ² - ω² - j*2*δₙ*ω)
1552                // where δₙ = ωₙ/(2*Q) is the damping coefficient
1553                // The factor of 2 comes from expressing damping in terms of Q
1554                //
1555                // Units: 1/(rad/s)² = s²/rad²
1556                let delta_n = omega_n / (2.0 * modal_damping);
1557                let denominator = Complex64::new(omega_n_sq - omega_sq, -2.0 * delta_n * omega);
1558                let transfer_function = Complex64::new(1.0, 0.0) / denominator;
1559
1560                // Add mode contribution
1561                // Ψ(rs) * Ψ(r) is dimensionless (product of cosines)
1562                // mode_norm is dimensionless (Neumann factors)
1563                let mode_amplitude = mode_norm * source_mode * listener_mode;
1564                modal_sum += transfer_function * mode_amplitude;
1565            }
1566        }
1567    }
1568
1569    // Apply prefactor c²/V
1570    // Final units: (m²/s²) / m³ * (s²/rad²) = m⁻¹/rad² = 1/m (treating radians as dimensionless)
1571    // This matches the Green's function units
1572    modal_sum *= prefactor;
1573
1574    // Add phase term to match Green's function form e^(ikr)
1575    let phase = Complex64::new(0.0, k * r).exp();
1576
1577    modal_sum * phase
1578}
1579
1580/// Calculate hybrid crossover weight for blending modal and ISM responses
1581///
1582/// Returns a weight from 0 to 1 where:
1583/// - 0 = use only modal analysis
1584/// - 1 = use only ISM
1585///
1586/// The crossover uses a smooth cosine transition centered at Schroeder frequency.
1587pub fn hybrid_crossover_weight(
1588    frequency: f64,
1589    schroeder_frequency: f64,
1590    crossover_width_octaves: f64,
1591) -> f64 {
1592    if schroeder_frequency <= 0.0 {
1593        return 1.0; // Use ISM if no valid Schroeder frequency
1594    }
1595
1596    // Calculate distance from Schroeder frequency in octaves
1597    let octaves_from_schroeder = (frequency / schroeder_frequency).log2();
1598
1599    // Crossover region: +/- crossover_width_octaves around Schroeder
1600    let half_width = crossover_width_octaves / 2.0;
1601
1602    if octaves_from_schroeder < -half_width {
1603        // Well below Schroeder: use modal only
1604        0.0
1605    } else if octaves_from_schroeder > half_width {
1606        // Well above Schroeder: use ISM only
1607        1.0
1608    } else {
1609        // In crossover region: smooth blend using cosine
1610        let t = (octaves_from_schroeder + half_width) / crossover_width_octaves;
1611        // Cosine interpolation for smooth transition
1612        (1.0 - (t * PI).cos()) / 2.0
1613    }
1614}
1615
1616// ============================================================================
1617// RT60 and Reverberation Time Calculations
1618// ============================================================================
1619
1620/// Room acoustics metrics including RT60
1621#[derive(Debug, Clone, Serialize)]
1622pub struct RoomAcoustics {
1623    /// Sabine RT60 in seconds
1624    pub rt60_sabine: f64,
1625    /// Eyring RT60 in seconds (more accurate for absorptive rooms)
1626    pub rt60_eyring: f64,
1627    /// Room volume in cubic meters
1628    pub volume: f64,
1629    /// Total surface area in square meters
1630    pub surface_area: f64,
1631    /// Average absorption coefficient
1632    pub average_alpha: f64,
1633    /// Total absorption in sabins (m²)
1634    pub total_absorption: f64,
1635    /// Schroeder frequency (transition from modal to statistical behavior)
1636    pub schroeder_frequency: f64,
1637    /// Critical distance (where direct and reverberant fields are equal)
1638    pub critical_distance: f64,
1639}
1640
1641/// Calculate RT60 using Sabine's formula
1642///
1643/// RT60 = 0.161 * V / A
1644///
1645/// Where:
1646/// - V = room volume (m³)
1647/// - A = total absorption (sabins, m²) = Σ(αᵢ * Sᵢ)
1648///
1649/// Valid for rooms with average absorption < 0.2
1650pub fn rt60_sabine(volume: f64, total_absorption: f64) -> f64 {
1651    if total_absorption > 0.0 {
1652        0.161 * volume / total_absorption
1653    } else {
1654        f64::INFINITY // No absorption = infinite reverberation
1655    }
1656}
1657
1658/// Calculate RT60 using Eyring's formula
1659///
1660/// RT60 = 0.161 * V / (-S * ln(1 - α_avg))
1661///
1662/// Where:
1663/// - V = room volume (m³)
1664/// - S = total surface area (m²)
1665/// - α_avg = average absorption coefficient
1666///
1667/// More accurate than Sabine for rooms with higher absorption (α > 0.2)
1668pub fn rt60_eyring(volume: f64, surface_area: f64, average_alpha: f64) -> f64 {
1669    if average_alpha > 0.0 && average_alpha < 1.0 {
1670        let clamped_alpha = average_alpha.min(0.99); // Prevent ln(0)
1671        0.161 * volume / (-surface_area * (1.0 - clamped_alpha).ln())
1672    } else if average_alpha >= 1.0 {
1673        0.0 // Perfect absorption = no reverberation
1674    } else {
1675        f64::INFINITY // No absorption = infinite reverberation
1676    }
1677}
1678
1679/// Calculate critical distance (where direct and reverberant fields are equal)
1680///
1681/// r_c = 0.057 * sqrt(V / RT60)
1682///
1683/// At distances less than r_c, direct sound dominates.
1684/// At distances greater than r_c, reverberant sound dominates.
1685pub fn critical_distance(volume: f64, rt60: f64) -> f64 {
1686    if rt60 > 0.0 {
1687        0.057 * (volume / rt60).sqrt()
1688    } else {
1689        f64::INFINITY // No reverberation = direct sound always dominates
1690    }
1691}
1692
1693/// Calculate room acoustics for a rectangular room with given wall materials
1694pub fn calculate_room_acoustics(
1695    width: f64,
1696    depth: f64,
1697    height: f64,
1698    materials: &WallMaterialsConfig,
1699    frequency: f64,
1700) -> RoomAcoustics {
1701    // Calculate room geometry
1702    let volume = width * depth * height;
1703
1704    // Surface areas for each wall pair
1705    let area_left_right = depth * height; // Left and right walls (perpendicular to X)
1706    let area_front_back = width * height; // Front and back walls (perpendicular to Y)
1707    let area_floor_ceiling = width * depth; // Floor and ceiling (perpendicular to Z)
1708
1709    let surface_area = 2.0 * (area_left_right + area_front_back + area_floor_ceiling);
1710
1711    // Get absorption coefficients at the specified frequency
1712    let alpha_left = materials.left.absorption_at_frequency(frequency);
1713    let alpha_right = materials.right.absorption_at_frequency(frequency);
1714    let alpha_front = materials.front.absorption_at_frequency(frequency);
1715    let alpha_back = materials.back.absorption_at_frequency(frequency);
1716    let alpha_floor = materials.floor.absorption_at_frequency(frequency);
1717    let alpha_ceiling = materials.ceiling.absorption_at_frequency(frequency);
1718
1719    // Calculate total absorption (sabins)
1720    let total_absorption = alpha_left * area_left_right
1721        + alpha_right * area_left_right
1722        + alpha_front * area_front_back
1723        + alpha_back * area_front_back
1724        + alpha_floor * area_floor_ceiling
1725        + alpha_ceiling * area_floor_ceiling;
1726
1727    // Calculate average absorption coefficient
1728    let average_alpha = total_absorption / surface_area;
1729
1730    // Calculate RT60 using both formulas
1731    let rt60_sab = rt60_sabine(volume, total_absorption);
1732    let rt60_eyr = rt60_eyring(volume, surface_area, average_alpha);
1733
1734    // Use Eyring RT60 for Schroeder frequency (more accurate)
1735    let schroeder_freq = 2000.0 * (rt60_eyr / volume).sqrt();
1736    let crit_dist = critical_distance(volume, rt60_eyr);
1737
1738    RoomAcoustics {
1739        rt60_sabine: rt60_sab,
1740        rt60_eyring: rt60_eyr,
1741        volume,
1742        surface_area,
1743        average_alpha,
1744        total_absorption,
1745        schroeder_frequency: schroeder_freq,
1746        critical_distance: crit_dist,
1747    }
1748}
1749
1750// ============================================================================
1751// Impulse Response Calculation
1752// ============================================================================
1753
1754/// Impulse response data structure
1755#[derive(Debug, Clone, Serialize)]
1756pub struct ImpulseResponse {
1757    /// Time samples in seconds
1758    pub time: Vec<f64>,
1759    /// Amplitude samples (normalized)
1760    pub amplitude: Vec<f64>,
1761    /// Sample rate in Hz
1762    pub sample_rate: f64,
1763    /// Duration in seconds
1764    pub duration: f64,
1765    /// Peak amplitude
1766    pub peak_amplitude: f64,
1767    /// Energy decay curve (ETC) in dB
1768    pub energy_decay: Vec<f64>,
1769}
1770
1771/// Configuration for impulse response generation
1772#[derive(Debug, Clone, Serialize, Deserialize)]
1773pub struct ImpulseResponseConfig {
1774    /// Sample rate for output IR (default 48000 Hz)
1775    #[serde(default = "default_ir_sample_rate")]
1776    pub sample_rate: f64,
1777    /// IR duration in seconds (default: auto from RT60)
1778    #[serde(default)]
1779    pub duration: Option<f64>,
1780    /// Number of FFT points (default: power of 2 based on duration)
1781    #[serde(default)]
1782    pub fft_size: Option<usize>,
1783    /// Minimum frequency for spectrum (default: 20 Hz)
1784    #[serde(default = "default_ir_min_freq")]
1785    pub min_freq: f64,
1786    /// Maximum frequency for spectrum (default: Nyquist/2)
1787    #[serde(default)]
1788    pub max_freq: Option<f64>,
1789}
1790
1791fn default_ir_sample_rate() -> f64 {
1792    48000.0
1793}
1794fn default_ir_min_freq() -> f64 {
1795    20.0
1796}
1797
1798impl Default for ImpulseResponseConfig {
1799    fn default() -> Self {
1800        Self {
1801            sample_rate: default_ir_sample_rate(),
1802            duration: None,
1803            fft_size: None,
1804            min_freq: default_ir_min_freq(),
1805            max_freq: None,
1806        }
1807    }
1808}
1809
1810/// Calculate impulse response from frequency response using IFFT
1811///
1812/// This function takes complex frequency response data and converts it to
1813/// a time-domain impulse response using the inverse FFT.
1814///
1815/// The process:
1816/// 1. Interpolate frequency response to uniform spacing (for FFT)
1817/// 2. Create conjugate-symmetric spectrum (for real output)
1818/// 3. Apply IFFT
1819/// 4. Window and normalize the result
1820pub fn calculate_impulse_response(
1821    frequencies: &[f64],
1822    complex_response: &[Complex64],
1823    config: &ImpulseResponseConfig,
1824) -> ImpulseResponse {
1825    let sample_rate = config.sample_rate;
1826    let nyquist = sample_rate / 2.0;
1827
1828    // Determine FFT size (power of 2)
1829    let duration = config.duration.unwrap_or(0.5); // Default 500ms
1830    let min_fft_size = (duration * sample_rate).ceil() as usize;
1831    let fft_size = config.fft_size.unwrap_or_else(|| {
1832        // Round up to next power of 2
1833        let mut size = 1;
1834        while size < min_fft_size {
1835            size *= 2;
1836        }
1837        size.max(1024)
1838    });
1839
1840    // Frequency resolution
1841    let freq_resolution = sample_rate / fft_size as f64;
1842
1843    // Create uniform frequency bins for FFT
1844    let num_bins = fft_size / 2 + 1;
1845    let mut spectrum: Vec<Complex64> = vec![Complex64::new(0.0, 0.0); fft_size];
1846
1847    // Interpolate frequency response to FFT bins
1848    #[allow(clippy::needless_range_loop)]
1849    for bin in 0..num_bins {
1850        let freq = bin as f64 * freq_resolution;
1851
1852        // Skip DC and frequencies outside our range
1853        if freq < config.min_freq || freq > nyquist {
1854            spectrum[bin] = Complex64::new(0.0, 0.0);
1855            continue;
1856        }
1857
1858        // Find bracketing frequencies and interpolate
1859        let value = interpolate_complex(frequencies, complex_response, freq);
1860        spectrum[bin] = value;
1861    }
1862
1863    // Create conjugate-symmetric spectrum for real-valued output
1864    // spectrum[N-k] = conj(spectrum[k])
1865    for k in 1..fft_size / 2 {
1866        spectrum[fft_size - k] = spectrum[k].conj();
1867    }
1868
1869    // Apply IFFT manually using DFT (simple implementation for WASM)
1870    // For production, use a proper FFT library
1871    let time_domain = ifft_real(&spectrum);
1872
1873    // Find peak and normalize
1874    let peak = time_domain.iter().map(|&x| x.abs()).fold(0.0_f64, f64::max);
1875    let normalized: Vec<f64> = if peak > 1e-10 {
1876        time_domain.iter().map(|&x| x / peak).collect()
1877    } else {
1878        time_domain.clone()
1879    };
1880
1881    // Calculate time vector
1882    let time: Vec<f64> = (0..fft_size).map(|i| i as f64 / sample_rate).collect();
1883
1884    // Calculate energy decay curve (Schroeder integration)
1885    let energy_decay = calculate_energy_decay(&normalized);
1886
1887    ImpulseResponse {
1888        time,
1889        amplitude: normalized,
1890        sample_rate,
1891        duration: fft_size as f64 / sample_rate,
1892        peak_amplitude: peak,
1893        energy_decay,
1894    }
1895}
1896
1897/// Interpolate complex frequency response
1898fn interpolate_complex(frequencies: &[f64], values: &[Complex64], target_freq: f64) -> Complex64 {
1899    if frequencies.is_empty() || values.is_empty() {
1900        return Complex64::new(0.0, 0.0);
1901    }
1902
1903    // Clamp to range
1904    if target_freq <= frequencies[0] {
1905        return values[0];
1906    }
1907    if target_freq >= frequencies[frequencies.len() - 1] {
1908        return values[values.len() - 1];
1909    }
1910
1911    // Find bracketing indices using logarithmic interpolation
1912    for i in 0..frequencies.len() - 1 {
1913        if target_freq >= frequencies[i] && target_freq < frequencies[i + 1] {
1914            // Log-frequency interpolation
1915            let log_f = target_freq.ln();
1916            let log_f1 = frequencies[i].ln();
1917            let log_f2 = frequencies[i + 1].ln();
1918            let t = (log_f - log_f1) / (log_f2 - log_f1);
1919
1920            // Interpolate magnitude and phase separately for better results
1921            let mag1 = values[i].norm();
1922            let mag2 = values[i + 1].norm();
1923            let phase1 = values[i].arg();
1924            let phase2 = values[i + 1].arg();
1925
1926            // Unwrap phase if needed
1927            let mut phase_diff = phase2 - phase1;
1928            while phase_diff > PI {
1929                phase_diff -= 2.0 * PI;
1930            }
1931            while phase_diff < -PI {
1932                phase_diff += 2.0 * PI;
1933            }
1934
1935            let mag = mag1 * (1.0 - t) + mag2 * t;
1936            let phase = phase1 + t * phase_diff;
1937
1938            return Complex64::from_polar(mag, phase);
1939        }
1940    }
1941
1942    values[values.len() - 1]
1943}
1944
1945/// Simple IFFT implementation for real-valued output
1946/// Uses direct DFT computation (O(N²) but works without external FFT library)
1947fn ifft_real(spectrum: &[Complex64]) -> Vec<f64> {
1948    let n = spectrum.len();
1949    let mut output = vec![0.0; n];
1950
1951    // For small sizes, use direct DFT
1952    // For larger sizes, this should be replaced with a proper FFT
1953    if n <= 4096 {
1954        #[allow(clippy::needless_range_loop)]
1955        for k in 0..n {
1956            let mut sum = Complex64::new(0.0, 0.0);
1957            for (m, &spec_val) in spectrum.iter().enumerate() {
1958                let angle = 2.0 * PI * (k as f64) * (m as f64) / (n as f64);
1959                let twiddle = Complex64::new(angle.cos(), angle.sin());
1960                sum += spec_val * twiddle;
1961            }
1962            output[k] = sum.re / n as f64;
1963        }
1964    } else {
1965        // Cooley-Tukey radix-2 IFFT
1966        output = cooley_tukey_ifft(spectrum);
1967    }
1968
1969    output
1970}
1971
1972/// Cooley-Tukey radix-2 IFFT
1973fn cooley_tukey_ifft(spectrum: &[Complex64]) -> Vec<f64> {
1974    let n = spectrum.len();
1975
1976    // Bit-reversal permutation
1977    let mut data: Vec<Complex64> = spectrum.to_vec();
1978    let mut j = 0;
1979    for i in 0..n {
1980        if i < j {
1981            data.swap(i, j);
1982        }
1983        let mut m = n / 2;
1984        while m >= 1 && j >= m {
1985            j -= m;
1986            m /= 2;
1987        }
1988        j += m;
1989    }
1990
1991    // Iterative FFT
1992    let mut len = 2;
1993    while len <= n {
1994        let half = len / 2;
1995        let angle_step = 2.0 * PI / len as f64; // Note: positive for IFFT
1996
1997        for start in (0..n).step_by(len) {
1998            for k in 0..half {
1999                let angle = angle_step * k as f64;
2000                let twiddle = Complex64::new(angle.cos(), angle.sin());
2001
2002                let even = data[start + k];
2003                let odd = data[start + k + half] * twiddle;
2004
2005                data[start + k] = even + odd;
2006                data[start + k + half] = even - odd;
2007            }
2008        }
2009
2010        len *= 2;
2011    }
2012
2013    // Normalize and extract real part
2014    data.iter().map(|c| c.re / n as f64).collect()
2015}
2016
2017/// Calculate energy decay curve (Schroeder integration)
2018/// EDT is calculated by reverse integration of squared impulse response
2019fn calculate_energy_decay(ir: &[f64]) -> Vec<f64> {
2020    let n = ir.len();
2021    if n == 0 {
2022        return vec![];
2023    }
2024
2025    // Calculate squared amplitude (energy)
2026    let energy: Vec<f64> = ir.iter().map(|&x| x * x).collect();
2027
2028    // Reverse cumulative sum (Schroeder integration)
2029    let mut decay = vec![0.0; n];
2030    let mut cumsum = 0.0;
2031
2032    for i in (0..n).rev() {
2033        cumsum += energy[i];
2034        decay[i] = cumsum;
2035    }
2036
2037    // Normalize and convert to dB
2038    let max_energy = decay[0].max(1e-10);
2039    decay
2040        .iter()
2041        .map(|&e| 10.0 * (e / max_energy).log10())
2042        .collect()
2043}
2044
2045// ============================================================================
2046// Binaural Rendering
2047// ============================================================================
2048
2049/// Default head radius (meters) - average human head
2050const DEFAULT_HEAD_RADIUS: f64 = 0.0875; // 8.75 cm
2051
2052/// Default ear spacing (meters) - typical ear-to-ear distance
2053const DEFAULT_EAR_SPACING: f64 = 0.175; // 17.5 cm
2054
2055/// Binaural output containing left and right ear responses
2056#[derive(Debug, Clone, Serialize)]
2057pub struct BinauralResponse {
2058    /// Left ear impulse response
2059    pub left: ImpulseResponse,
2060    /// Right ear impulse response
2061    pub right: ImpulseResponse,
2062    /// Interaural time difference (seconds) - positive means left ear leads
2063    pub itd: f64,
2064    /// Interaural level difference (dB) - positive means left ear is louder
2065    pub ild: f64,
2066    /// Head position used
2067    pub head_position: [f64; 3],
2068    /// Head orientation (yaw in degrees, 0 = facing +Y)
2069    pub head_yaw: f64,
2070}
2071
2072/// Configuration for binaural rendering
2073#[derive(Debug, Clone, Serialize, Deserialize)]
2074pub struct BinauralConfig {
2075    /// Enable binaural rendering
2076    #[serde(default)]
2077    pub enabled: bool,
2078    /// Head center position (defaults to listening position)
2079    #[serde(default)]
2080    pub head_position: Option<Point3DConfig>,
2081    /// Head yaw angle in degrees (0 = facing +Y, 90 = facing -X)
2082    #[serde(default)]
2083    pub head_yaw: f64,
2084    /// Head radius in meters (default 0.0875m)
2085    #[serde(default = "default_head_radius")]
2086    pub head_radius: f64,
2087    /// Ear spacing in meters (default 0.175m)
2088    #[serde(default = "default_ear_spacing")]
2089    pub ear_spacing: f64,
2090    /// Impulse response configuration for binaural output
2091    #[serde(default)]
2092    pub ir_config: ImpulseResponseConfig,
2093}
2094
2095fn default_head_radius() -> f64 {
2096    DEFAULT_HEAD_RADIUS
2097}
2098fn default_ear_spacing() -> f64 {
2099    DEFAULT_EAR_SPACING
2100}
2101
2102impl Default for BinauralConfig {
2103    fn default() -> Self {
2104        Self {
2105            enabled: false,
2106            head_position: None,
2107            head_yaw: 0.0,
2108            head_radius: DEFAULT_HEAD_RADIUS,
2109            ear_spacing: DEFAULT_EAR_SPACING,
2110            ir_config: ImpulseResponseConfig::default(),
2111        }
2112    }
2113}
2114
2115/// Calculate ear positions from head center and orientation
2116pub fn calculate_ear_positions(
2117    head_center: &Point3D,
2118    head_yaw_deg: f64,
2119    ear_spacing: f64,
2120) -> (Point3D, Point3D) {
2121    let yaw_rad = head_yaw_deg.to_radians();
2122    let half_spacing = ear_spacing / 2.0;
2123
2124    // Ears are perpendicular to facing direction
2125    // If facing +Y (yaw=0), left ear is at -X, right ear is at +X
2126    let ear_dx = -yaw_rad.sin() * half_spacing;
2127    let ear_dy = yaw_rad.cos() * half_spacing;
2128
2129    // Left ear (positive perpendicular direction when facing forward)
2130    let left_ear = Point3D::new(
2131        head_center.x - ear_dx,
2132        head_center.y - ear_dy,
2133        head_center.z,
2134    );
2135
2136    // Right ear (negative perpendicular direction)
2137    let right_ear = Point3D::new(
2138        head_center.x + ear_dx,
2139        head_center.y + ear_dy,
2140        head_center.z,
2141    );
2142
2143    (left_ear, right_ear)
2144}
2145
2146/// Calculate interaural time difference (ITD) using Woodworth's formula
2147///
2148/// ITD = (r/c) * (theta + sin(theta))
2149///
2150/// Where:
2151/// - r = head radius
2152/// - c = speed of sound
2153/// - theta = azimuth angle from median plane
2154pub fn calculate_itd(
2155    source: &Point3D,
2156    head_center: &Point3D,
2157    head_yaw_deg: f64,
2158    head_radius: f64,
2159    speed_of_sound: f64,
2160) -> f64 {
2161    // Calculate azimuth angle relative to head orientation
2162    let dx = source.x - head_center.x;
2163    let dy = source.y - head_center.y;
2164
2165    // Source azimuth in world coordinates
2166    let source_azimuth = dx.atan2(dy);
2167
2168    // Head facing direction
2169    let head_facing = head_yaw_deg.to_radians();
2170
2171    // Relative azimuth (positive = source to the left)
2172    let theta = source_azimuth - head_facing;
2173
2174    // Woodworth's formula
2175    (head_radius / speed_of_sound) * (theta + theta.sin())
2176}
2177
2178/// Simplified HRTF magnitude approximation
2179///
2180/// This uses a simplified head-shadowing model based on the angle to the source.
2181/// For more accurate results, measured HRTF databases should be used.
2182///
2183/// Returns (left_gain, right_gain) as linear multipliers
2184pub fn approximate_hrtf_magnitude(
2185    source: &Point3D,
2186    head_center: &Point3D,
2187    head_yaw_deg: f64,
2188    frequency: f64,
2189) -> (f64, f64) {
2190    // Calculate azimuth relative to head
2191    let dx = source.x - head_center.x;
2192    let dy = source.y - head_center.y;
2193    let source_azimuth = dx.atan2(dy);
2194    let head_facing = head_yaw_deg.to_radians();
2195    let theta = source_azimuth - head_facing;
2196
2197    // Head shadowing is more pronounced at higher frequencies
2198    // Simple model: use sinusoidal ILD that increases with frequency
2199    let freq_factor = (frequency / 1000.0).clamp(0.1, 4.0);
2200
2201    // Maximum ILD is about 10-20 dB at high frequencies for sources at 90 degrees
2202    let max_ild_db = 6.0 * freq_factor; // ~6dB at 1kHz, ~24dB at 4kHz+
2203
2204    // ILD varies sinusoidally with azimuth
2205    let ild_db = max_ild_db * theta.sin();
2206
2207    // Convert to linear gains
2208    let left_gain = 10.0_f64.powf(-ild_db / 20.0);
2209    let right_gain = 10.0_f64.powf(ild_db / 20.0);
2210
2211    // Normalize so average is 1
2212    let avg = (left_gain + right_gain) / 2.0;
2213    (left_gain / avg, right_gain / avg)
2214}
2215
2216/// Calculate binaural response for a room simulation
2217pub fn calculate_binaural_response(
2218    frequencies: &[f64],
2219    left_pressures: &[Complex64],
2220    right_pressures: &[Complex64],
2221    config: &BinauralConfig,
2222    _speed_of_sound: f64,
2223    head_position: Point3D,
2224) -> BinauralResponse {
2225    // Calculate impulse responses for both ears
2226    let left_ir = calculate_impulse_response(frequencies, left_pressures, &config.ir_config);
2227    let right_ir = calculate_impulse_response(frequencies, right_pressures, &config.ir_config);
2228
2229    // Calculate average ITD from magnitude-weighted phases
2230    let mut itd_sum = 0.0;
2231    let mut weight_sum = 0.0;
2232
2233    for i in 0..frequencies
2234        .len()
2235        .min(left_pressures.len())
2236        .min(right_pressures.len())
2237    {
2238        let left_phase = left_pressures[i].arg();
2239        let right_phase = right_pressures[i].arg();
2240        let freq = frequencies[i];
2241
2242        if freq > 20.0 && freq < 1500.0 {
2243            // Phase-based ITD is most reliable below 1.5kHz
2244            let phase_diff = left_phase - right_phase;
2245            let itd_sample = phase_diff / (2.0 * PI * freq);
2246            let weight = (left_pressures[i].norm() + right_pressures[i].norm()) / 2.0;
2247            itd_sum += itd_sample * weight;
2248            weight_sum += weight;
2249        }
2250    }
2251
2252    let itd = if weight_sum > 1e-10 {
2253        itd_sum / weight_sum
2254    } else {
2255        0.0
2256    };
2257
2258    // Calculate ILD from average magnitude difference
2259    let left_energy: f64 = left_pressures.iter().map(|p| p.norm_sqr()).sum();
2260    let right_energy: f64 = right_pressures.iter().map(|p| p.norm_sqr()).sum();
2261
2262    let ild = if right_energy > 1e-10 {
2263        10.0 * (left_energy / right_energy).log10()
2264    } else if left_energy > 1e-10 {
2265        20.0 // Left much louder
2266    } else {
2267        0.0
2268    };
2269
2270    BinauralResponse {
2271        left: left_ir,
2272        right: right_ir,
2273        itd,
2274        ild,
2275        head_position: [head_position.x, head_position.y, head_position.z],
2276        head_yaw: config.head_yaw,
2277    }
2278}
2279
2280// ============================================================================
2281// Edge Diffraction (Simplified Biot-Tolstoy model)
2282// ============================================================================
2283
2284/// Calculate edge diffraction coefficient using simplified UTD (Uniform Theory of Diffraction)
2285///
2286/// This is a simplified approximation based on the Biot-Tolstoy-Medwin model,
2287/// commonly used in room acoustics for diffraction around edges.
2288///
2289/// The diffraction coefficient depends on:
2290/// - Wedge angle (for room corners, typically 90 degrees = pi/2)
2291/// - Source and receiver angles relative to the edge
2292/// - Frequency (through wavenumber k)
2293/// - Distances from source/receiver to edge
2294///
2295/// Reference: Svensson, U. P., Fred, R. I., & Vanderkooy, J. (1999).
2296/// "An analytic secondary source model of edge diffraction impulse responses."
2297pub fn edge_diffraction_coefficient(
2298    wedge_angle: f64,  // Interior wedge angle (radians), typically PI/2 for room corners
2299    r_source: f64,     // Distance from source to edge
2300    r_receiver: f64,   // Distance from receiver to edge
2301    theta_source: f64, // Angle from source to edge face (radians)
2302    theta_receiver: f64, // Angle from receiver to edge face (radians)
2303    k: f64,            // Wavenumber (2 * PI * f / c)
2304) -> Complex64 {
2305    // Wedge index (n = PI / wedge_angle)
2306    let n = PI / wedge_angle;
2307
2308    // Total path length from source around edge to receiver
2309    let path_length = r_source + r_receiver;
2310
2311    // Diffraction attenuation factor
2312    // This is a simplified version - full BTM involves integral over edge length
2313    let nu = n;
2314
2315    // Cotangent diffraction functions for each term
2316    let beta_plus = theta_source + theta_receiver;
2317    let beta_minus = theta_source - theta_receiver;
2318
2319    // Fresnel-like diffraction function (simplified)
2320    let d_plus = cot_diffraction_term(beta_plus, nu);
2321    let d_minus = cot_diffraction_term(beta_minus, nu);
2322
2323    // Distance factor (geometric spreading from edge)
2324    let distance_factor = 1.0 / (4.0 * PI * (r_source * r_receiver).sqrt());
2325
2326    // Frequency-dependent phase
2327    let phase = Complex64::new(0.0, k * path_length).exp();
2328
2329    // Combined diffraction coefficient
2330    let diffraction_amplitude = distance_factor * (d_plus + d_minus).abs();
2331
2332    phase * diffraction_amplitude
2333}
2334
2335/// Helper function for cotangent diffraction term
2336fn cot_diffraction_term(beta: f64, nu: f64) -> f64 {
2337    // Simplified cotangent function for diffraction calculation
2338    // cot((PI + beta) / (2 * nu))
2339    let arg = (PI + beta) / (2.0 * nu);
2340
2341    // Handle singularities
2342    if arg.sin().abs() < 1e-10 {
2343        return 0.0;
2344    }
2345
2346    arg.cos() / arg.sin()
2347}
2348
2349/// Represents an edge in the room for diffraction calculation
2350#[derive(Debug, Clone)]
2351pub struct DiffractionEdge {
2352    /// Start point of the edge
2353    pub start: Point3D,
2354    /// End point of the edge
2355    pub end: Point3D,
2356    /// Interior wedge angle (radians)
2357    pub wedge_angle: f64,
2358}
2359
2360impl DiffractionEdge {
2361    pub fn new(start: Point3D, end: Point3D, wedge_angle: f64) -> Self {
2362        Self {
2363            start,
2364            end,
2365            wedge_angle,
2366        }
2367    }
2368
2369    /// Get the edge direction vector
2370    pub fn direction(&self) -> Point3D {
2371        let dx = self.end.x - self.start.x;
2372        let dy = self.end.y - self.start.y;
2373        let dz = self.end.z - self.start.z;
2374        let len = (dx * dx + dy * dy + dz * dz).sqrt();
2375        Point3D::new(dx / len, dy / len, dz / len)
2376    }
2377
2378    /// Get the edge length
2379    pub fn length(&self) -> f64 {
2380        self.start.distance_to(&self.end)
2381    }
2382
2383    /// Find closest point on edge to a given point
2384    pub fn closest_point(&self, point: &Point3D) -> Point3D {
2385        let edge_vec = Point3D::new(
2386            self.end.x - self.start.x,
2387            self.end.y - self.start.y,
2388            self.end.z - self.start.z,
2389        );
2390        let point_vec = Point3D::new(
2391            point.x - self.start.x,
2392            point.y - self.start.y,
2393            point.z - self.start.z,
2394        );
2395
2396        let edge_len_sq =
2397            edge_vec.x * edge_vec.x + edge_vec.y * edge_vec.y + edge_vec.z * edge_vec.z;
2398        if edge_len_sq < 1e-10 {
2399            return self.start;
2400        }
2401
2402        let dot = point_vec.x * edge_vec.x + point_vec.y * edge_vec.y + point_vec.z * edge_vec.z;
2403        let t = (dot / edge_len_sq).clamp(0.0, 1.0);
2404
2405        Point3D::new(
2406            self.start.x + t * edge_vec.x,
2407            self.start.y + t * edge_vec.y,
2408            self.start.z + t * edge_vec.z,
2409        )
2410    }
2411
2412    /// Calculate diffraction contribution from this edge
2413    pub fn diffraction_contribution(
2414        &self,
2415        source: &Point3D,
2416        receiver: &Point3D,
2417        k: f64,
2418    ) -> Complex64 {
2419        // Find the diffraction point (closest point on edge to midpoint of source-receiver)
2420        let midpoint = Point3D::new(
2421            (source.x + receiver.x) / 2.0,
2422            (source.y + receiver.y) / 2.0,
2423            (source.z + receiver.z) / 2.0,
2424        );
2425        let diff_point = self.closest_point(&midpoint);
2426
2427        // Distances from source/receiver to diffraction point
2428        let r_source = source.distance_to(&diff_point);
2429        let r_receiver = receiver.distance_to(&diff_point);
2430
2431        // Skip if distances are too small
2432        if r_source < 0.01 || r_receiver < 0.01 {
2433            return Complex64::new(0.0, 0.0);
2434        }
2435
2436        // Simplified angles (assume perpendicular to edge)
2437        // In a full implementation, we'd calculate the actual angles relative to edge faces
2438        let theta_source = PI / 4.0; // 45 degrees - typical value
2439        let theta_receiver = PI / 4.0;
2440
2441        edge_diffraction_coefficient(
2442            self.wedge_angle,
2443            r_source,
2444            r_receiver,
2445            theta_source,
2446            theta_receiver,
2447            k,
2448        )
2449    }
2450}
2451
2452/// Get diffraction edges for a rectangular room (12 edges at corners)
2453pub fn get_rectangular_room_edges(width: f64, depth: f64, height: f64) -> Vec<DiffractionEdge> {
2454    let wedge_90 = PI / 2.0; // 90-degree corners
2455
2456    vec![
2457        // Floor edges
2458        DiffractionEdge::new(
2459            Point3D::new(0.0, 0.0, 0.0),
2460            Point3D::new(width, 0.0, 0.0),
2461            wedge_90,
2462        ),
2463        DiffractionEdge::new(
2464            Point3D::new(width, 0.0, 0.0),
2465            Point3D::new(width, depth, 0.0),
2466            wedge_90,
2467        ),
2468        DiffractionEdge::new(
2469            Point3D::new(width, depth, 0.0),
2470            Point3D::new(0.0, depth, 0.0),
2471            wedge_90,
2472        ),
2473        DiffractionEdge::new(
2474            Point3D::new(0.0, depth, 0.0),
2475            Point3D::new(0.0, 0.0, 0.0),
2476            wedge_90,
2477        ),
2478        // Ceiling edges
2479        DiffractionEdge::new(
2480            Point3D::new(0.0, 0.0, height),
2481            Point3D::new(width, 0.0, height),
2482            wedge_90,
2483        ),
2484        DiffractionEdge::new(
2485            Point3D::new(width, 0.0, height),
2486            Point3D::new(width, depth, height),
2487            wedge_90,
2488        ),
2489        DiffractionEdge::new(
2490            Point3D::new(width, depth, height),
2491            Point3D::new(0.0, depth, height),
2492            wedge_90,
2493        ),
2494        DiffractionEdge::new(
2495            Point3D::new(0.0, depth, height),
2496            Point3D::new(0.0, 0.0, height),
2497            wedge_90,
2498        ),
2499        // Vertical edges
2500        DiffractionEdge::new(
2501            Point3D::new(0.0, 0.0, 0.0),
2502            Point3D::new(0.0, 0.0, height),
2503            wedge_90,
2504        ),
2505        DiffractionEdge::new(
2506            Point3D::new(width, 0.0, 0.0),
2507            Point3D::new(width, 0.0, height),
2508            wedge_90,
2509        ),
2510        DiffractionEdge::new(
2511            Point3D::new(width, depth, 0.0),
2512            Point3D::new(width, depth, height),
2513            wedge_90,
2514        ),
2515        DiffractionEdge::new(
2516            Point3D::new(0.0, depth, 0.0),
2517            Point3D::new(0.0, depth, height),
2518            wedge_90,
2519        ),
2520    ]
2521}
2522
2523// ============================================================================
2524// Core computation functions
2525// ============================================================================
2526
2527/// Green's function for 3D Helmholtz equation: G(r) = exp(ikr) / (4 pi r)
2528fn greens_function_3d(r: f64, k: f64) -> Complex64 {
2529    if r < 1e-10 {
2530        return Complex64::new(0.0, 0.0);
2531    }
2532    let ikr = Complex64::new(0.0, k * r);
2533    ikr.exp() / (4.0 * PI * r)
2534}
2535
2536/// Convert complex pressure to SPL (dB re 20 uPa)
2537fn pressure_to_spl(pressure: Complex64) -> f64 {
2538    let magnitude = pressure.norm();
2539    let p_ref = 20e-6;
2540    20.0 * (magnitude / p_ref).max(1e-10).log10()
2541}
2542
2543/// Generate logarithmically spaced frequencies
2544fn log_space(start: f64, end: f64, num: usize) -> Vec<f64> {
2545    if num <= 1 {
2546        return vec![start];
2547    }
2548    let log_start = start.ln();
2549    let log_end = end.ln();
2550    (0..num)
2551        .map(|i| {
2552            let log_val = log_start + (log_end - log_start) * i as f64 / (num - 1) as f64;
2553            log_val.exp()
2554        })
2555        .collect()
2556}
2557
2558/// Generate linearly spaced values
2559fn lin_space(start: f64, end: f64, num: usize) -> Vec<f64> {
2560    if num <= 1 {
2561        return vec![start];
2562    }
2563    (0..num)
2564        .map(|i| start + (end - start) * i as f64 / (num - 1) as f64)
2565        .collect()
2566}
2567
2568/// Create a simple cardioid directivity pattern
2569fn create_cardioid_pattern(front_back_ratio: f64) -> DirectivityPattern {
2570    let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
2571    let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
2572
2573    let mut magnitude = Array2::zeros((vertical_angles.len(), horizontal_angles.len()));
2574
2575    // Cardioid pattern: (a + b*cos(theta)) where a + b = 1 (front), a - b = 1/ratio (back)
2576    // Solving: a = (ratio + 1) / (2 * ratio), b = (ratio - 1) / (2 * ratio)
2577    let a = (front_back_ratio + 1.0) / (2.0 * front_back_ratio);
2578    let b = (front_back_ratio - 1.0) / (2.0 * front_back_ratio);
2579
2580    for (v_idx, &theta_deg) in vertical_angles.iter().enumerate() {
2581        for (h_idx, &phi_deg) in horizontal_angles.iter().enumerate() {
2582            let theta = theta_deg.to_radians();
2583            let phi = phi_deg.to_radians();
2584            let cos_angle = theta.sin() * phi.cos();
2585            let response = (a + b * cos_angle).max(0.0);
2586            magnitude[[v_idx, h_idx]] = response;
2587        }
2588    }
2589
2590    DirectivityPattern {
2591        horizontal_angles,
2592        vertical_angles,
2593        magnitude,
2594    }
2595}
2596
2597/// Create a DirectivityPattern from spinorama.org directivity data
2598///
2599/// The spinorama data contains SPL curves at different angles for both horizontal
2600/// and vertical planes. This function:
2601/// 1. Normalizes all SPL values relative to the on-axis response
2602/// 2. Converts dB differences to linear magnitude ratios
2603/// 3. Creates a 2D interpolation grid for arbitrary angle lookups
2604///
2605/// # Arguments
2606/// * `horizontal` - Horizontal plane SPL curves at different angles
2607/// * `vertical` - Vertical plane SPL curves at different angles
2608/// * `frequency` - The frequency at which to sample the directivity
2609fn create_spinorama_pattern(
2610    horizontal: &[SpinoramaCurve],
2611    vertical: &[SpinoramaCurve],
2612    frequency: f64,
2613) -> DirectivityPattern {
2614    // Standard 10-degree resolution for the pattern
2615    let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
2616    let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
2617
2618    let mut magnitude = Array2::ones((vertical_angles.len(), horizontal_angles.len()));
2619
2620    // Find the on-axis SPL for normalization (0° in horizontal data)
2621    let on_axis_spl = horizontal
2622        .iter()
2623        .find(|c| (c.angle - 0.0).abs() < 0.5)
2624        .map(|c| interpolate_spl_at_frequency(&c.freq, &c.spl, frequency))
2625        .unwrap_or(0.0);
2626
2627    // Helper to get SPL at a specific angle from spinorama data
2628    let get_spl_at_angle = |curves: &[SpinoramaCurve], angle: f64| -> f64 {
2629        // Handle the fact that spinorama uses -60 to +60 range
2630        // while our pattern uses 0 to 360 for horizontal
2631        let search_angle = if angle > 180.0 { angle - 360.0 } else { angle };
2632
2633        // Find the two closest angles and interpolate
2634        let mut closest_below: Option<&SpinoramaCurve> = None;
2635        let mut closest_above: Option<&SpinoramaCurve> = None;
2636
2637        for curve in curves {
2638            if curve.angle <= search_angle
2639                && (closest_below.is_none() || curve.angle > closest_below.unwrap().angle)
2640            {
2641                closest_below = Some(curve);
2642            }
2643            if curve.angle >= search_angle
2644                && (closest_above.is_none() || curve.angle < closest_above.unwrap().angle)
2645            {
2646                closest_above = Some(curve);
2647            }
2648        }
2649
2650        match (closest_below, closest_above) {
2651            (Some(below), Some(above)) if (below.angle - above.angle).abs() < 0.5 => {
2652                // Exact match
2653                interpolate_spl_at_frequency(&below.freq, &below.spl, frequency)
2654            }
2655            (Some(below), Some(above)) => {
2656                // Interpolate between two angles
2657                let spl_below = interpolate_spl_at_frequency(&below.freq, &below.spl, frequency);
2658                let spl_above = interpolate_spl_at_frequency(&above.freq, &above.spl, frequency);
2659                let t = (search_angle - below.angle) / (above.angle - below.angle);
2660                spl_below * (1.0 - t) + spl_above * t
2661            }
2662            (Some(only), None) | (None, Some(only)) => {
2663                interpolate_spl_at_frequency(&only.freq, &only.spl, frequency)
2664            }
2665            (None, None) => on_axis_spl, // Fallback to on-axis
2666        }
2667    };
2668
2669    // Fill the magnitude array
2670    // Horizontal plane (theta = 90°, phi varies) - use horizontal data
2671    // Vertical plane (phi = 0°, theta varies) - use vertical data
2672    // Other angles: interpolate between horizontal and vertical
2673
2674    for (v_idx, &theta_deg) in vertical_angles.iter().enumerate() {
2675        for (h_idx, &phi_deg) in horizontal_angles.iter().enumerate() {
2676            // Get SPL from horizontal and vertical data
2677            let h_spl = get_spl_at_angle(horizontal, phi_deg);
2678            let v_spl = get_spl_at_angle(vertical, theta_deg);
2679
2680            // Blend based on angle: at theta=90° use horizontal, at theta=0° or 180° use vertical
2681            let theta_rad = theta_deg.to_radians();
2682            let blend = theta_rad.sin(); // 1 at 90°, 0 at 0° and 180°
2683
2684            let combined_spl = h_spl * blend + v_spl * (1.0 - blend);
2685
2686            // Convert dB difference to linear magnitude
2687            let db_diff = combined_spl - on_axis_spl;
2688            let linear_mag = 10.0_f64.powf(db_diff / 20.0);
2689
2690            magnitude[[v_idx, h_idx]] = linear_mag.clamp(0.0, 10.0);
2691        }
2692    }
2693
2694    DirectivityPattern {
2695        horizontal_angles,
2696        vertical_angles,
2697        magnitude,
2698    }
2699}
2700
2701/// Interpolate SPL value at a specific frequency from spinorama frequency/SPL arrays
2702fn interpolate_spl_at_frequency(freq: &[f64], spl: &[f64], target_freq: f64) -> f64 {
2703    if freq.is_empty() || spl.is_empty() {
2704        return 0.0;
2705    }
2706
2707    // Handle edge cases
2708    if target_freq <= freq[0] {
2709        return spl[0];
2710    }
2711    if target_freq >= freq[freq.len() - 1] {
2712        return spl[spl.len() - 1];
2713    }
2714
2715    // Find bracketing indices and interpolate logarithmically
2716    for i in 0..freq.len() - 1 {
2717        if target_freq >= freq[i] && target_freq < freq[i + 1] {
2718            let log_f = target_freq.ln();
2719            let log_f1 = freq[i].ln();
2720            let log_f2 = freq[i + 1].ln();
2721            let t = (log_f - log_f1) / (log_f2 - log_f1);
2722            return spl[i] * (1.0 - t) + spl[i + 1] * t;
2723        }
2724    }
2725
2726    spl[spl.len() - 1]
2727}
2728
2729// ============================================================================
2730// WASM-exported Room Simulator
2731// ============================================================================
2732
2733/// Room Acoustics Simulator - WASM interface
2734#[wasm_bindgen]
2735pub struct RoomSimulatorWasm {
2736    config: SimulationConfig,
2737    room_geometry: RoomGeometry,
2738    sources: Vec<Source>,
2739    listening_position: Point3D,
2740    frequencies: Vec<f64>,
2741    speed_of_sound: f64,
2742    wall_materials: WallMaterialsConfig,
2743    /// Temperature in Celsius
2744    temperature: f64,
2745    /// Relative humidity (0-100%)
2746    humidity: f64,
2747    /// Whether to model air absorption
2748    air_absorption_enabled: bool,
2749    /// Whether to model edge diffraction
2750    edge_diffraction_enabled: bool,
2751    /// Pre-computed diffraction edges for the room
2752    diffraction_edges: Vec<DiffractionEdge>,
2753}
2754
2755#[wasm_bindgen]
2756impl RoomSimulatorWasm {
2757    /// Create a new simulator from JSON configuration
2758    #[wasm_bindgen(constructor)]
2759    pub fn new(config_json: &str) -> Result<RoomSimulatorWasm, JsValue> {
2760        init_panic_hook();
2761
2762        let config: SimulationConfig = serde_json::from_str(config_json)
2763            .map_err(|e| JsValue::from_str(&format!("Config parse error: {}", e)))?;
2764
2765        // Build room geometry
2766        let room_geometry = match &config.room {
2767            RoomGeometryConfig::Rectangular {
2768                width,
2769                depth,
2770                height,
2771            } => {
2772                console_log!(
2773                    "Creating rectangular room: {}x{}x{} m",
2774                    width,
2775                    depth,
2776                    height
2777                );
2778                RoomGeometry::Rectangular(RectangularRoom::new(*width, *depth, *height))
2779            }
2780            RoomGeometryConfig::LShaped {
2781                width1,
2782                depth1,
2783                width2,
2784                depth2,
2785                height,
2786            } => {
2787                console_log!(
2788                    "Creating L-shaped room: {}x{} + {}x{} x {} m",
2789                    width1,
2790                    depth1,
2791                    width2,
2792                    depth2,
2793                    height
2794                );
2795                RoomGeometry::LShaped(LShapedRoom::new(
2796                    *width1, *depth1, *width2, *depth2, *height,
2797                ))
2798            }
2799        };
2800
2801        // Build sources
2802        let sources: Vec<Source> = config
2803            .sources
2804            .iter()
2805            .map(|s| {
2806                let directivity = match &s.directivity {
2807                    DirectivityConfig::Omnidirectional => DirectivityPattern::omnidirectional(),
2808                    DirectivityConfig::Cardioid { front_back_ratio } => {
2809                        create_cardioid_pattern(*front_back_ratio)
2810                    }
2811                    DirectivityConfig::Spinorama {
2812                        horizontal,
2813                        vertical,
2814                    } => {
2815                        // Use 1kHz as the representative frequency for the directivity pattern
2816                        // This is a common choice for speaker directivity visualization
2817                        create_spinorama_pattern(horizontal, vertical, 1000.0)
2818                    }
2819                };
2820
2821                Source::new(s.position.into(), directivity, s.amplitude)
2822                    .with_name(s.name.clone())
2823                    .with_crossover(s.crossover.to_filter())
2824                    .with_delay_ms(s.delay_ms)
2825                    .with_phase_inversion(s.invert_phase)
2826            })
2827            .collect();
2828
2829        console_log!("Created {} sources", sources.len());
2830        for source in &sources {
2831            if source.delay_sec > 0.0 || source.invert_phase {
2832                console_log!(
2833                    "  {}: delay={:.2}ms, invert={}",
2834                    source.name,
2835                    source.delay_sec * 1000.0,
2836                    source.invert_phase
2837                );
2838            }
2839        }
2840
2841        let listening_position = config
2842            .listening_positions
2843            .first()
2844            .map(|p| (*p).into())
2845            .unwrap_or(Point3D::new(0.0, 0.0, 0.0));
2846
2847        let frequencies = if config.frequencies.spacing == "linear" {
2848            lin_space(
2849                config.frequencies.min_freq,
2850                config.frequencies.max_freq,
2851                config.frequencies.num_points,
2852            )
2853        } else {
2854            log_space(
2855                config.frequencies.min_freq,
2856                config.frequencies.max_freq,
2857                config.frequencies.num_points,
2858            )
2859        };
2860
2861        console_log!(
2862            "Frequency range: {:.1} - {:.1} Hz ({} points)",
2863            frequencies.first().unwrap_or(&0.0),
2864            frequencies.last().unwrap_or(&0.0),
2865            frequencies.len()
2866        );
2867
2868        let speed_of_sound = config.solver.speed_of_sound;
2869        let wall_materials = config.wall_materials.clone();
2870        let temperature = config.solver.temperature;
2871        let humidity = config.solver.humidity;
2872        let air_absorption_enabled = config.solver.air_absorption;
2873
2874        // Log wall materials
2875        console_log!(
2876            "Wall materials: Left={}, Right={}, Front={}, Back={}, Floor={}, Ceiling={}",
2877            wall_materials.get_material(WallSurface::Left).name,
2878            wall_materials.get_material(WallSurface::Right).name,
2879            wall_materials.get_material(WallSurface::Front).name,
2880            wall_materials.get_material(WallSurface::Back).name,
2881            wall_materials.get_material(WallSurface::Floor).name,
2882            wall_materials.get_material(WallSurface::Ceiling).name,
2883        );
2884
2885        // Log environmental conditions
2886        if air_absorption_enabled {
2887            console_log!(
2888                "Air absorption enabled: T={:.1}°C, RH={:.0}%",
2889                temperature,
2890                humidity
2891            );
2892        }
2893
2894        let edge_diffraction_enabled = config.solver.edge_diffraction;
2895
2896        // Pre-compute diffraction edges for the room
2897        let diffraction_edges = if edge_diffraction_enabled {
2898            match &room_geometry {
2899                RoomGeometry::Rectangular(r) => {
2900                    console_log!("Edge diffraction enabled: computing 12 room edges");
2901                    get_rectangular_room_edges(r.width, r.depth, r.height)
2902                }
2903                RoomGeometry::LShaped(l) => {
2904                    // For L-shaped rooms, we have more edges (18 total)
2905                    // For now, use a simplified approximation with outer bounding box
2906                    console_log!("Edge diffraction enabled for L-shaped room (simplified)");
2907                    get_rectangular_room_edges(l.width1, l.depth1 + l.depth2, l.height)
2908                }
2909            }
2910        } else {
2911            Vec::new()
2912        };
2913
2914        Ok(RoomSimulatorWasm {
2915            config,
2916            room_geometry,
2917            sources,
2918            listening_position,
2919            frequencies,
2920            speed_of_sound,
2921            wall_materials,
2922            temperature,
2923            humidity,
2924            air_absorption_enabled,
2925            edge_diffraction_enabled,
2926            diffraction_edges,
2927        })
2928    }
2929
2930    fn wavenumber(&self, frequency: f64) -> f64 {
2931        2.0 * PI * frequency / self.speed_of_sound
2932    }
2933
2934    /// Check if a point is inside the room geometry
2935    fn point_inside_room(&self, point: &Point3D) -> bool {
2936        match &self.room_geometry {
2937            RoomGeometry::Rectangular(r) => {
2938                point.x >= 0.0
2939                    && point.x <= r.width
2940                    && point.y >= 0.0
2941                    && point.y <= r.depth
2942                    && point.z >= 0.0
2943                    && point.z <= r.height
2944            }
2945            RoomGeometry::LShaped(l) => l.contains(point),
2946        }
2947    }
2948
2949    /// Calculate air absorption attenuation factor for a given distance
2950    /// Returns a multiplier in the range (0, 1] where 1 = no attenuation
2951    fn air_absorption_factor(&self, distance: f64, frequency: f64) -> f64 {
2952        if !self.air_absorption_enabled || distance < 1e-6 {
2953            return 1.0;
2954        }
2955
2956        let alpha = calculate_air_absorption(frequency, self.temperature, self.humidity);
2957        // Attenuation: exp(-alpha * distance)
2958        (-alpha * distance).exp()
2959    }
2960
2961    fn calculate_direct_field(&self, point: &Point3D, frequency: f64) -> Complex64 {
2962        let (room_width, room_depth, room_height) = self.get_room_dimensions();
2963
2964        // Handle different solver methods
2965        let method = self.config.solver.method.as_str();
2966
2967        // BEM-based methods
2968        if method == "bem" || method == "hybrid-bem" {
2969            return self.calculate_bem_or_hybrid_bem(point, frequency, method);
2970        }
2971
2972        // Modal and modal-ISM hybrid methods
2973        if method == "modal" || method == "hybrid" {
2974            // Modal analysis is only valid for rectangular rooms
2975            if let RoomGeometry::Rectangular(_) = &self.room_geometry {
2976                // Calculate modal pressure for all sources
2977                let mut modal_pressure = Complex64::new(0.0, 0.0);
2978                for source in &self.sources {
2979                    let amplitude = source.amplitude_towards(point, frequency);
2980                    let phase_factor = source.phase_factor(frequency);
2981
2982                    let modal = calculate_modal_pressure(
2983                        &source.position,
2984                        point,
2985                        frequency,
2986                        room_width,
2987                        room_depth,
2988                        room_height,
2989                        self.config.solver.speed_of_sound,
2990                        self.config.solver.max_mode_order,
2991                        self.config.solver.modal_damping,
2992                    );
2993
2994                    modal_pressure += modal * amplitude * phase_factor;
2995                }
2996
2997                if method == "modal" {
2998                    // Pure modal: return modal pressure only
2999                    return modal_pressure;
3000                }
3001
3002                // Hybrid: blend modal and ISM based on Schroeder frequency
3003                let volume = room_width * room_depth * room_height;
3004                let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3005                let surface_area = 2.0
3006                    * (room_width * room_depth
3007                        + room_width * room_height
3008                        + room_depth * room_height);
3009                let total_abs = avg_absorption * surface_area;
3010                let rt60 = rt60_sabine(volume, total_abs);
3011                let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3012
3013                let ism_weight = hybrid_crossover_weight(
3014                    frequency,
3015                    schroeder_freq,
3016                    self.config.solver.hybrid_crossover_width,
3017                );
3018
3019                // If fully in modal region, return modal pressure
3020                if ism_weight < 1e-6 {
3021                    return modal_pressure;
3022                }
3023
3024                // Calculate ISM pressure and blend
3025                let ism_pressure = self.calculate_ism_field(point, frequency);
3026
3027                // Blend: p = (1-w) * modal + w * ISM
3028                return modal_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight;
3029            }
3030        }
3031
3032        // Standard ISM calculation for non-hybrid modes
3033        self.calculate_ism_field(point, frequency)
3034    }
3035
3036    /// Calculate field using BEM or hybrid BEM+ISM
3037    ///
3038    /// For "bem" mode: Uses modal analysis for rectangular rooms (captures room modes),
3039    /// falls back to BEM direct field + scattering for non-rectangular.
3040    ///
3041    /// For "hybrid-bem" mode: Blends modal/BEM at low frequencies with ISM at high
3042    /// frequencies using Schroeder frequency crossover.
3043    fn calculate_bem_or_hybrid_bem(
3044        &self,
3045        point: &Point3D,
3046        frequency: f64,
3047        method: &str,
3048    ) -> Complex64 {
3049        let (room_width, room_depth, room_height) = self.get_room_dimensions();
3050
3051        // Calculate low-frequency pressure using modal analysis (rectangular) or BEM (other)
3052        let low_freq_pressure = if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3053            // Modal analysis for rectangular rooms - captures room resonances accurately
3054            let mut modal_pressure = Complex64::new(0.0, 0.0);
3055            for source in &self.sources {
3056                let amplitude = source.amplitude_towards(point, frequency);
3057                let phase_factor = source.phase_factor(frequency);
3058
3059                let modal = calculate_modal_pressure(
3060                    &source.position,
3061                    point,
3062                    frequency,
3063                    room_width,
3064                    room_depth,
3065                    room_height,
3066                    self.config.solver.speed_of_sound,
3067                    self.config.solver.max_mode_order,
3068                    self.config.solver.modal_damping,
3069                );
3070
3071                modal_pressure += modal * amplitude * phase_factor;
3072            }
3073
3074            // Add scattering object contributions if present
3075            if !self.config.scattering_objects.is_empty() {
3076                let scattering_result = bem_solver::solve_bem(
3077                    &self.room_geometry,
3078                    &self.sources,
3079                    &self.config.scattering_objects,
3080                    point,
3081                    frequency,
3082                    self.config.solver.speed_of_sound,
3083                    &self.config.solver.bem_config,
3084                );
3085                if let Ok(result) = scattering_result {
3086                    // Add scattered field contribution (subtract direct to avoid double counting)
3087                    let k =
3088                        2.0 * std::f64::consts::PI * frequency / self.config.solver.speed_of_sound;
3089                    let mut direct_field = Complex64::new(0.0, 0.0);
3090                    for source in &self.sources {
3091                        let amp = source.amplitude_towards(point, frequency);
3092                        let dist = source.position.distance_to(point);
3093                        direct_field += bem_solver::greens_function(dist, k)
3094                            * amp
3095                            * source.phase_factor(frequency);
3096                    }
3097                    // Scattered contribution = total BEM - direct
3098                    let scattered = result.pressure - direct_field;
3099                    modal_pressure += scattered;
3100                }
3101            }
3102
3103            modal_pressure
3104        } else {
3105            // Non-rectangular rooms: use full BEM solver
3106            let bem_result = bem_solver::solve_bem(
3107                &self.room_geometry,
3108                &self.sources,
3109                &self.config.scattering_objects,
3110                point,
3111                frequency,
3112                self.config.solver.speed_of_sound,
3113                &self.config.solver.bem_config,
3114            );
3115
3116            match bem_result {
3117                Ok(result) => result.pressure,
3118                Err(_) => return self.calculate_ism_field(point, frequency),
3119            }
3120        };
3121
3122        if method == "bem" {
3123            // Pure BEM/modal: return low frequency pressure only
3124            return low_freq_pressure;
3125        }
3126
3127        // Hybrid-BEM: blend modal/BEM at low frequencies with ISM at high frequencies
3128        // Use Schroeder frequency as the crossover point
3129        let volume = room_width * room_depth * room_height;
3130        let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3131        let surface_area =
3132            2.0 * (room_width * room_depth + room_width * room_height + room_depth * room_height);
3133        let total_abs = avg_absorption * surface_area;
3134        let rt60 = rt60_sabine(volume, total_abs);
3135        let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3136
3137        // ISM weight increases above Schroeder frequency
3138        let ism_weight = hybrid_crossover_weight(
3139            frequency,
3140            schroeder_freq,
3141            self.config.solver.hybrid_crossover_width,
3142        );
3143
3144        // If fully in modal/BEM region (below Schroeder), return low freq pressure
3145        if ism_weight < 1e-6 {
3146            return low_freq_pressure;
3147        }
3148
3149        // If fully in ISM region (above Schroeder), return ISM pressure
3150        if ism_weight > 1.0 - 1e-6 {
3151            return self.calculate_ism_field(point, frequency);
3152        }
3153
3154        // Blend: p = (1-w) * modal/BEM + w * ISM
3155        let ism_pressure = self.calculate_ism_field(point, frequency);
3156        low_freq_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight
3157    }
3158
3159    /// Calculate field using Image Source Method (ISM)
3160    fn calculate_ism_field(&self, point: &Point3D, frequency: f64) -> Complex64 {
3161        let k = self.wavenumber(frequency);
3162        let mut total_pressure = Complex64::new(0.0, 0.0);
3163
3164        // Determine reflection order from solver method
3165        let reflection_order = match self.config.solver.method.as_str() {
3166            "direct" => 0,
3167            "image-source-1" => 1,
3168            "image-source-2" => 2,
3169            "image-source-3" => 3,
3170            "modal" => 0,      // Modal doesn't use ISM reflections
3171            "bem" => 0,        // BEM doesn't use ISM reflections
3172            "hybrid" => 2,     // Hybrid modal/ISM uses 2nd order
3173            "hybrid-bem" => 2, // Hybrid BEM/ISM uses 2nd order
3174            _ => 2,            // Default to 2nd order
3175        };
3176
3177        // Get room dimensions for image source calculation
3178        let (room_width, room_depth, room_height) = self.get_room_dimensions();
3179
3180        // Get frequency-dependent reflection coefficients for each wall
3181        let r_left = self
3182            .wall_materials
3183            .reflection_at(WallSurface::Left, frequency);
3184        let r_right = self
3185            .wall_materials
3186            .reflection_at(WallSurface::Right, frequency);
3187        let r_front = self
3188            .wall_materials
3189            .reflection_at(WallSurface::Front, frequency);
3190        let r_back = self
3191            .wall_materials
3192            .reflection_at(WallSurface::Back, frequency);
3193        let r_floor = self
3194            .wall_materials
3195            .reflection_at(WallSurface::Floor, frequency);
3196        let r_ceiling = self
3197            .wall_materials
3198            .reflection_at(WallSurface::Ceiling, frequency);
3199
3200        for source in &self.sources {
3201            let amplitude = source.amplitude_towards(point, frequency);
3202            // Get phase factor for delay and phase inversion
3203            let phase_factor = source.phase_factor(frequency);
3204
3205            // Direct sound (always included) with air absorption and phase
3206            let r_direct = source.position.distance_to(point);
3207            let air_atten_direct = self.air_absorption_factor(r_direct, frequency);
3208            total_pressure +=
3209                greens_function_3d(r_direct, k) * amplitude * air_atten_direct * phase_factor;
3210
3211            if reflection_order >= 1 {
3212                // First-order image sources - handle L-shaped rooms specially
3213                let first_order_images: Vec<(Point3D, f64)> = match &self.room_geometry {
3214                    RoomGeometry::LShaped(l_room) => {
3215                        // Use L-shaped room's proper image source generation
3216                        l_room
3217                            .get_first_order_images(&source.position)
3218                            .into_iter()
3219                            .filter_map(|(image, wall_name)| {
3220                                // Validate the image source path
3221                                if !l_room.is_valid_image_source(&image, point, &source.position) {
3222                                    return None;
3223                                }
3224                                // Map wall name to reflection coefficient
3225                                let refl = match wall_name {
3226                                    "left" => r_left,
3227                                    "right" => r_right,
3228                                    "front" => r_front,
3229                                    "back" => r_back,
3230                                    "floor" => r_floor,
3231                                    "ceiling" => r_ceiling,
3232                                    "step_horizontal" | "step_vertical" => {
3233                                        // Interior step walls - use average of nearby walls
3234                                        (r_right + r_back) / 2.0
3235                                    }
3236                                    _ => r_right, // Default fallback
3237                                };
3238                                Some((image, refl))
3239                            })
3240                            .collect()
3241                    }
3242                    RoomGeometry::Rectangular(_) => {
3243                        // Standard rectangular room image sources (6 walls)
3244                        vec![
3245                            // Left wall (x=0)
3246                            (
3247                                Point3D::new(
3248                                    -source.position.x,
3249                                    source.position.y,
3250                                    source.position.z,
3251                                ),
3252                                r_left,
3253                            ),
3254                            // Right wall (x=width)
3255                            (
3256                                Point3D::new(
3257                                    2.0 * room_width - source.position.x,
3258                                    source.position.y,
3259                                    source.position.z,
3260                                ),
3261                                r_right,
3262                            ),
3263                            // Front wall (y=0)
3264                            (
3265                                Point3D::new(
3266                                    source.position.x,
3267                                    -source.position.y,
3268                                    source.position.z,
3269                                ),
3270                                r_front,
3271                            ),
3272                            // Back wall (y=depth)
3273                            (
3274                                Point3D::new(
3275                                    source.position.x,
3276                                    2.0 * room_depth - source.position.y,
3277                                    source.position.z,
3278                                ),
3279                                r_back,
3280                            ),
3281                            // Floor (z=0)
3282                            (
3283                                Point3D::new(
3284                                    source.position.x,
3285                                    source.position.y,
3286                                    -source.position.z,
3287                                ),
3288                                r_floor,
3289                            ),
3290                            // Ceiling (z=height)
3291                            (
3292                                Point3D::new(
3293                                    source.position.x,
3294                                    source.position.y,
3295                                    2.0 * room_height - source.position.z,
3296                                ),
3297                                r_ceiling,
3298                            ),
3299                        ]
3300                    }
3301                };
3302
3303                for (image_src, refl_coeff) in &first_order_images {
3304                    let r_image = image_src.distance_to(point);
3305                    if r_image > 1e-6 {
3306                        let air_atten = self.air_absorption_factor(r_image, frequency);
3307                        total_pressure += greens_function_3d(r_image, k)
3308                            * amplitude
3309                            * refl_coeff
3310                            * air_atten
3311                            * phase_factor;
3312                    }
3313                }
3314            }
3315
3316            // Higher-order reflections only for rectangular rooms
3317            // L-shaped room higher-order ISM is complex and would need proper path validation
3318            if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3319                if reflection_order >= 2 {
3320                    // Second-order image sources (edges - 12 combinations)
3321                    // Each involves reflection off two walls, so multiply their coefficients
3322                    let second_order_images = [
3323                        // Left + Front (x=0, y=0)
3324                        (
3325                            Point3D::new(-source.position.x, -source.position.y, source.position.z),
3326                            r_left * r_front,
3327                        ),
3328                        // Left + Back (x=0, y=depth)
3329                        (
3330                            Point3D::new(
3331                                -source.position.x,
3332                                2.0 * room_depth - source.position.y,
3333                                source.position.z,
3334                            ),
3335                            r_left * r_back,
3336                        ),
3337                        // Right + Front (x=width, y=0)
3338                        (
3339                            Point3D::new(
3340                                2.0 * room_width - source.position.x,
3341                                -source.position.y,
3342                                source.position.z,
3343                            ),
3344                            r_right * r_front,
3345                        ),
3346                        // Right + Back (x=width, y=depth)
3347                        (
3348                            Point3D::new(
3349                                2.0 * room_width - source.position.x,
3350                                2.0 * room_depth - source.position.y,
3351                                source.position.z,
3352                            ),
3353                            r_right * r_back,
3354                        ),
3355                        // Left + Floor (x=0, z=0)
3356                        (
3357                            Point3D::new(-source.position.x, source.position.y, -source.position.z),
3358                            r_left * r_floor,
3359                        ),
3360                        // Left + Ceiling (x=0, z=height)
3361                        (
3362                            Point3D::new(
3363                                -source.position.x,
3364                                source.position.y,
3365                                2.0 * room_height - source.position.z,
3366                            ),
3367                            r_left * r_ceiling,
3368                        ),
3369                        // Right + Floor (x=width, z=0)
3370                        (
3371                            Point3D::new(
3372                                2.0 * room_width - source.position.x,
3373                                source.position.y,
3374                                -source.position.z,
3375                            ),
3376                            r_right * r_floor,
3377                        ),
3378                        // Right + Ceiling (x=width, z=height)
3379                        (
3380                            Point3D::new(
3381                                2.0 * room_width - source.position.x,
3382                                source.position.y,
3383                                2.0 * room_height - source.position.z,
3384                            ),
3385                            r_right * r_ceiling,
3386                        ),
3387                        // Front + Floor (y=0, z=0)
3388                        (
3389                            Point3D::new(source.position.x, -source.position.y, -source.position.z),
3390                            r_front * r_floor,
3391                        ),
3392                        // Front + Ceiling (y=0, z=height)
3393                        (
3394                            Point3D::new(
3395                                source.position.x,
3396                                -source.position.y,
3397                                2.0 * room_height - source.position.z,
3398                            ),
3399                            r_front * r_ceiling,
3400                        ),
3401                        // Back + Floor (y=depth, z=0)
3402                        (
3403                            Point3D::new(
3404                                source.position.x,
3405                                2.0 * room_depth - source.position.y,
3406                                -source.position.z,
3407                            ),
3408                            r_back * r_floor,
3409                        ),
3410                        // Back + Ceiling (y=depth, z=height)
3411                        (
3412                            Point3D::new(
3413                                source.position.x,
3414                                2.0 * room_depth - source.position.y,
3415                                2.0 * room_height - source.position.z,
3416                            ),
3417                            r_back * r_ceiling,
3418                        ),
3419                    ];
3420
3421                    for (image_src, refl_coeff) in &second_order_images {
3422                        let r_image = image_src.distance_to(point);
3423                        if r_image > 1e-6 {
3424                            let air_atten = self.air_absorption_factor(r_image, frequency);
3425                            total_pressure += greens_function_3d(r_image, k)
3426                                * amplitude
3427                                * refl_coeff
3428                                * air_atten
3429                                * phase_factor;
3430                        }
3431                    }
3432                }
3433
3434                if reflection_order >= 3 {
3435                    // Third-order image sources (corners - 8 combinations)
3436                    // Each involves reflection off three walls
3437                    let third_order_images = [
3438                        // Left + Front + Floor
3439                        (
3440                            Point3D::new(
3441                                -source.position.x,
3442                                -source.position.y,
3443                                -source.position.z,
3444                            ),
3445                            r_left * r_front * r_floor,
3446                        ),
3447                        // Left + Front + Ceiling
3448                        (
3449                            Point3D::new(
3450                                -source.position.x,
3451                                -source.position.y,
3452                                2.0 * room_height - source.position.z,
3453                            ),
3454                            r_left * r_front * r_ceiling,
3455                        ),
3456                        // Left + Back + Floor
3457                        (
3458                            Point3D::new(
3459                                -source.position.x,
3460                                2.0 * room_depth - source.position.y,
3461                                -source.position.z,
3462                            ),
3463                            r_left * r_back * r_floor,
3464                        ),
3465                        // Left + Back + Ceiling
3466                        (
3467                            Point3D::new(
3468                                -source.position.x,
3469                                2.0 * room_depth - source.position.y,
3470                                2.0 * room_height - source.position.z,
3471                            ),
3472                            r_left * r_back * r_ceiling,
3473                        ),
3474                        // Right + Front + Floor
3475                        (
3476                            Point3D::new(
3477                                2.0 * room_width - source.position.x,
3478                                -source.position.y,
3479                                -source.position.z,
3480                            ),
3481                            r_right * r_front * r_floor,
3482                        ),
3483                        // Right + Front + Ceiling
3484                        (
3485                            Point3D::new(
3486                                2.0 * room_width - source.position.x,
3487                                -source.position.y,
3488                                2.0 * room_height - source.position.z,
3489                            ),
3490                            r_right * r_front * r_ceiling,
3491                        ),
3492                        // Right + Back + Floor
3493                        (
3494                            Point3D::new(
3495                                2.0 * room_width - source.position.x,
3496                                2.0 * room_depth - source.position.y,
3497                                -source.position.z,
3498                            ),
3499                            r_right * r_back * r_floor,
3500                        ),
3501                        // Right + Back + Ceiling
3502                        (
3503                            Point3D::new(
3504                                2.0 * room_width - source.position.x,
3505                                2.0 * room_depth - source.position.y,
3506                                2.0 * room_height - source.position.z,
3507                            ),
3508                            r_right * r_back * r_ceiling,
3509                        ),
3510                    ];
3511
3512                    for (image_src, refl_coeff) in &third_order_images {
3513                        let r_image = image_src.distance_to(point);
3514                        if r_image > 1e-6 {
3515                            let air_atten = self.air_absorption_factor(r_image, frequency);
3516                            total_pressure += greens_function_3d(r_image, k)
3517                                * amplitude
3518                                * refl_coeff
3519                                * air_atten
3520                                * phase_factor;
3521                        }
3522                    }
3523                }
3524            }
3525
3526            // Add edge diffraction contributions
3527            if self.edge_diffraction_enabled && !self.diffraction_edges.is_empty() {
3528                for edge in &self.diffraction_edges {
3529                    let diff_contrib = edge.diffraction_contribution(&source.position, point, k);
3530                    // Scale diffraction by source amplitude and phase
3531                    total_pressure += diff_contrib * amplitude * phase_factor;
3532                }
3533            }
3534        }
3535
3536        total_pressure
3537    }
3538
3539    /// Calculate field from a single source using the configured solver method
3540    /// This mirrors calculate_direct_field but for a single source only
3541    fn calculate_source_field(
3542        &self,
3543        source_idx: usize,
3544        point: &Point3D,
3545        frequency: f64,
3546    ) -> Complex64 {
3547        if source_idx >= self.sources.len() {
3548            return Complex64::new(0.0, 0.0);
3549        }
3550
3551        let (room_width, room_depth, room_height) = self.get_room_dimensions();
3552        let method = self.config.solver.method.as_str();
3553        let source = &self.sources[source_idx];
3554
3555        // BEM-based methods - use modal for single source
3556        if method == "bem" || method == "hybrid-bem" {
3557            return self
3558                .calculate_single_source_bem_or_hybrid(source_idx, point, frequency, method);
3559        }
3560
3561        // Modal and modal-ISM hybrid methods
3562        if (method == "modal" || method == "hybrid")
3563            && let RoomGeometry::Rectangular(_) = &self.room_geometry {
3564                let amplitude = source.amplitude_towards(point, frequency);
3565                let phase_factor = source.phase_factor(frequency);
3566
3567                let modal_pressure = calculate_modal_pressure(
3568                    &source.position,
3569                    point,
3570                    frequency,
3571                    room_width,
3572                    room_depth,
3573                    room_height,
3574                    self.config.solver.speed_of_sound,
3575                    self.config.solver.max_mode_order,
3576                    self.config.solver.modal_damping,
3577                ) * amplitude
3578                    * phase_factor;
3579
3580                if method == "modal" {
3581                    return modal_pressure;
3582                }
3583
3584                // Hybrid: blend modal and ISM based on Schroeder frequency
3585                let volume = room_width * room_depth * room_height;
3586                let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3587                let surface_area = 2.0
3588                    * (room_width * room_depth
3589                        + room_width * room_height
3590                        + room_depth * room_height);
3591                let total_abs = avg_absorption * surface_area;
3592                let rt60 = rt60_sabine(volume, total_abs);
3593                let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3594
3595                let ism_weight = hybrid_crossover_weight(
3596                    frequency,
3597                    schroeder_freq,
3598                    self.config.solver.hybrid_crossover_width,
3599                );
3600
3601                if ism_weight < 1e-6 {
3602                    return modal_pressure;
3603                }
3604
3605                let ism_pressure = self.calculate_single_source_ism(source_idx, point, frequency);
3606                return modal_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight;
3607            }
3608
3609        // Standard ISM calculation
3610        self.calculate_single_source_ism(source_idx, point, frequency)
3611    }
3612
3613    /// Calculate field from a single source using BEM or hybrid BEM+ISM
3614    fn calculate_single_source_bem_or_hybrid(
3615        &self,
3616        source_idx: usize,
3617        point: &Point3D,
3618        frequency: f64,
3619        method: &str,
3620    ) -> Complex64 {
3621        let (room_width, room_depth, room_height) = self.get_room_dimensions();
3622        let source = &self.sources[source_idx];
3623
3624        // For rectangular rooms, use modal analysis for the single source
3625        let low_freq_pressure = if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3626            let amplitude = source.amplitude_towards(point, frequency);
3627            let phase_factor = source.phase_factor(frequency);
3628
3629            calculate_modal_pressure(
3630                &source.position,
3631                point,
3632                frequency,
3633                room_width,
3634                room_depth,
3635                room_height,
3636                self.config.solver.speed_of_sound,
3637                self.config.solver.max_mode_order,
3638                self.config.solver.modal_damping,
3639            ) * amplitude
3640                * phase_factor
3641        } else {
3642            // Non-rectangular: use direct field approximation
3643            let k = self.wavenumber(frequency);
3644            let amplitude = source.amplitude_towards(point, frequency);
3645            let dist = source.position.distance_to(point);
3646            let phase_factor = source.phase_factor(frequency);
3647            greens_function_3d(dist, k) * amplitude * phase_factor
3648        };
3649
3650        if method == "bem" {
3651            return low_freq_pressure;
3652        }
3653
3654        // Hybrid-BEM: blend with ISM at high frequencies
3655        let volume = room_width * room_depth * room_height;
3656        let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3657        let surface_area =
3658            2.0 * (room_width * room_depth + room_width * room_height + room_depth * room_height);
3659        let total_abs = avg_absorption * surface_area;
3660        let rt60 = rt60_sabine(volume, total_abs);
3661        let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3662
3663        let ism_weight = hybrid_crossover_weight(
3664            frequency,
3665            schroeder_freq,
3666            self.config.solver.hybrid_crossover_width,
3667        );
3668
3669        if ism_weight < 1e-6 {
3670            return low_freq_pressure;
3671        }
3672
3673        if ism_weight > 1.0 - 1e-6 {
3674            return self.calculate_single_source_ism(source_idx, point, frequency);
3675        }
3676
3677        let ism_pressure = self.calculate_single_source_ism(source_idx, point, frequency);
3678        low_freq_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight
3679    }
3680
3681    /// Calculate field from a single source using ISM
3682    fn calculate_single_source_ism(
3683        &self,
3684        source_idx: usize,
3685        point: &Point3D,
3686        frequency: f64,
3687    ) -> Complex64 {
3688        let k = self.wavenumber(frequency);
3689        let source = &self.sources[source_idx];
3690        let amplitude = source.amplitude_towards(point, frequency);
3691        let phase_factor = source.phase_factor(frequency);
3692
3693        // Determine reflection order from solver method
3694        let reflection_order = match self.config.solver.method.as_str() {
3695            "direct" => 0,
3696            "image-source-1" => 1,
3697            "image-source-2" => 2,
3698            "image-source-3" => 3,
3699            "modal" => 0,
3700            "bem" => 0,
3701            "hybrid" => 2,
3702            "hybrid-bem" => 2,
3703            _ => 2,
3704        };
3705
3706        let (room_width, room_depth, room_height) = self.get_room_dimensions();
3707
3708        // Get frequency-dependent reflection coefficients
3709        let r_left = self
3710            .wall_materials
3711            .reflection_at(WallSurface::Left, frequency);
3712        let r_right = self
3713            .wall_materials
3714            .reflection_at(WallSurface::Right, frequency);
3715        let r_front = self
3716            .wall_materials
3717            .reflection_at(WallSurface::Front, frequency);
3718        let r_back = self
3719            .wall_materials
3720            .reflection_at(WallSurface::Back, frequency);
3721        let r_floor = self
3722            .wall_materials
3723            .reflection_at(WallSurface::Floor, frequency);
3724        let r_ceiling = self
3725            .wall_materials
3726            .reflection_at(WallSurface::Ceiling, frequency);
3727
3728        let mut total_pressure = Complex64::new(0.0, 0.0);
3729
3730        // Direct sound
3731        let r_direct = source.position.distance_to(point);
3732        let air_atten_direct = self.air_absorption_factor(r_direct, frequency);
3733        total_pressure +=
3734            greens_function_3d(r_direct, k) * amplitude * air_atten_direct * phase_factor;
3735
3736        if reflection_order >= 1 {
3737            // First-order image sources - handle L-shaped rooms
3738            let first_order_images: Vec<(Point3D, f64)> = match &self.room_geometry {
3739                RoomGeometry::LShaped(l_room) => l_room
3740                    .get_first_order_images(&source.position)
3741                    .into_iter()
3742                    .filter_map(|(image, wall_name)| {
3743                        if !l_room.is_valid_image_source(&image, point, &source.position) {
3744                            return None;
3745                        }
3746                        let refl = match wall_name {
3747                            "left" => r_left,
3748                            "right" => r_right,
3749                            "front" => r_front,
3750                            "back" => r_back,
3751                            "floor" => r_floor,
3752                            "ceiling" => r_ceiling,
3753                            "step_horizontal" | "step_vertical" => (r_right + r_back) / 2.0,
3754                            _ => r_right,
3755                        };
3756                        Some((image, refl))
3757                    })
3758                    .collect(),
3759                RoomGeometry::Rectangular(_) => {
3760                    vec![
3761                        (
3762                            Point3D::new(-source.position.x, source.position.y, source.position.z),
3763                            r_left,
3764                        ),
3765                        (
3766                            Point3D::new(
3767                                2.0 * room_width - source.position.x,
3768                                source.position.y,
3769                                source.position.z,
3770                            ),
3771                            r_right,
3772                        ),
3773                        (
3774                            Point3D::new(source.position.x, -source.position.y, source.position.z),
3775                            r_front,
3776                        ),
3777                        (
3778                            Point3D::new(
3779                                source.position.x,
3780                                2.0 * room_depth - source.position.y,
3781                                source.position.z,
3782                            ),
3783                            r_back,
3784                        ),
3785                        (
3786                            Point3D::new(source.position.x, source.position.y, -source.position.z),
3787                            r_floor,
3788                        ),
3789                        (
3790                            Point3D::new(
3791                                source.position.x,
3792                                source.position.y,
3793                                2.0 * room_height - source.position.z,
3794                            ),
3795                            r_ceiling,
3796                        ),
3797                    ]
3798                }
3799            };
3800
3801            for (image_src, refl_coeff) in first_order_images {
3802                let r_image = image_src.distance_to(point);
3803                if r_image > 1e-6 {
3804                    let air_atten = self.air_absorption_factor(r_image, frequency);
3805                    total_pressure += greens_function_3d(r_image, k)
3806                        * amplitude
3807                        * refl_coeff
3808                        * air_atten
3809                        * phase_factor;
3810                }
3811            }
3812
3813            // Higher-order reflections for rectangular rooms only
3814            if reflection_order >= 2
3815                && let RoomGeometry::Rectangular(_) = &self.room_geometry {
3816                    for nx in -2i32..=2 {
3817                        for ny in -2i32..=2 {
3818                            for nz in -2i32..=2 {
3819                                let order = nx.abs() + ny.abs() + nz.abs();
3820                                if order < 2 || order > reflection_order {
3821                                    continue;
3822                                }
3823
3824                                let image_x = if nx % 2 == 0 {
3825                                    source.position.x + (nx as f64) * room_width
3826                                } else {
3827                                    -source.position.x + (nx as f64 + 1.0) * room_width
3828                                };
3829                                let image_y = if ny % 2 == 0 {
3830                                    source.position.y + (ny as f64) * room_depth
3831                                } else {
3832                                    -source.position.y + (ny as f64 + 1.0) * room_depth
3833                                };
3834                                let image_z = if nz % 2 == 0 {
3835                                    source.position.z + (nz as f64) * room_height
3836                                } else {
3837                                    -source.position.z + (nz as f64 + 1.0) * room_height
3838                                };
3839
3840                                let image_pos = Point3D::new(image_x, image_y, image_z);
3841                                let r_image = image_pos.distance_to(point);
3842
3843                                if r_image > 1e-6 {
3844                                    let refl_coeff = r_left.powi((nx.abs() + 1) / 2)
3845                                        * r_right.powi(nx.abs() / 2)
3846                                        * r_front.powi((ny.abs() + 1) / 2)
3847                                        * r_back.powi(ny.abs() / 2)
3848                                        * r_floor.powi((nz.abs() + 1) / 2)
3849                                        * r_ceiling.powi(nz.abs() / 2);
3850
3851                                    let air_atten = self.air_absorption_factor(r_image, frequency);
3852                                    total_pressure += greens_function_3d(r_image, k)
3853                                        * amplitude
3854                                        * refl_coeff
3855                                        * air_atten
3856                                        * phase_factor;
3857                                }
3858                            }
3859                        }
3860                    }
3861                }
3862        }
3863
3864        total_pressure
3865    }
3866
3867    /// Run the full simulation and return JSON results
3868    #[wasm_bindgen]
3869    pub fn run_simulation(&self) -> Result<String, JsValue> {
3870        console_log!("Starting simulation...");
3871
3872        let compute_ir = self.config.visualization.generate_impulse_response;
3873        let compute_binaural = self.config.visualization.binaural.enabled;
3874
3875        // Precompute ear positions for binaural
3876        let (left_ear_pos, right_ear_pos) = if compute_binaural {
3877            let binaural_config = &self.config.visualization.binaural;
3878            let head_center = binaural_config
3879                .head_position
3880                .as_ref()
3881                .map(|p| Point3D::new(p.x, p.y, p.z))
3882                .unwrap_or(self.listening_position);
3883            calculate_ear_positions(
3884                &head_center,
3885                binaural_config.head_yaw,
3886                binaural_config.ear_spacing,
3887            )
3888        } else {
3889            (self.listening_position, self.listening_position)
3890        };
3891
3892        // Precompute head center and yaw for HRTF
3893        let head_center_for_hrtf = self
3894            .config
3895            .visualization
3896            .binaural
3897            .head_position
3898            .as_ref()
3899            .map(|p| Point3D::new(p.x, p.y, p.z))
3900            .unwrap_or(self.listening_position);
3901        let head_yaw = self.config.visualization.binaural.head_yaw;
3902
3903        console_log!(
3904            "Computing frequency response using {} threads...",
3905            rayon::current_num_threads()
3906        );
3907
3908        // Parallel computation of frequency response
3909        // Each frequency is computed independently, results collected in order
3910        let freq_results: Vec<_> = self
3911            .frequencies
3912            .par_iter()
3913            .enumerate()
3914            .map(|(idx, &freq)| {
3915                // Main pressure at listening position
3916                let pressure = self.calculate_direct_field(&self.listening_position, freq);
3917                let spl = pressure_to_spl(pressure);
3918
3919                // Per-source SPL values
3920                let source_spls: Vec<f64> = (0..self.sources.len())
3921                    .map(|src_idx| {
3922                        let p =
3923                            self.calculate_source_field(src_idx, &self.listening_position, freq);
3924                        pressure_to_spl(p)
3925                    })
3926                    .collect();
3927
3928                // Binaural computation if enabled
3929                let binaural = if compute_binaural {
3930                    let left_pressure = self.calculate_direct_field(&left_ear_pos, freq);
3931                    let right_pressure = self.calculate_direct_field(&right_ear_pos, freq);
3932
3933                    // Apply simplified HRTF magnitude correction
3934                    let (mut left_total, mut right_total) = (left_pressure, right_pressure);
3935                    for source in &self.sources {
3936                        let (left_gain, right_gain) = approximate_hrtf_magnitude(
3937                            &source.position,
3938                            &head_center_for_hrtf,
3939                            head_yaw,
3940                            freq,
3941                        );
3942                        left_total *= Complex64::new(left_gain, 0.0);
3943                        right_total *= Complex64::new(right_gain, 0.0);
3944                    }
3945                    Some((left_total, right_total))
3946                } else {
3947                    None
3948                };
3949
3950                (idx, spl, pressure, source_spls, binaural)
3951            })
3952            .collect();
3953
3954        // Unpack parallel results into separate vectors (maintaining order)
3955        let mut combined_spl = Vec::with_capacity(self.frequencies.len());
3956        let mut complex_pressures = Vec::with_capacity(self.frequencies.len());
3957        let mut source_responses: Vec<Vec<f64>> = self
3958            .sources
3959            .iter()
3960            .map(|_| Vec::with_capacity(self.frequencies.len()))
3961            .collect();
3962        let mut left_ear_pressures = Vec::with_capacity(self.frequencies.len());
3963        let mut right_ear_pressures = Vec::with_capacity(self.frequencies.len());
3964
3965        for (_idx, spl, pressure, source_spls, binaural) in freq_results {
3966            combined_spl.push(spl);
3967
3968            if compute_ir {
3969                complex_pressures.push(pressure);
3970            }
3971
3972            for (src_idx, src_spl) in source_spls.into_iter().enumerate() {
3973                source_responses[src_idx].push(src_spl);
3974            }
3975
3976            if let Some((left, right)) = binaural {
3977                left_ear_pressures.push(left);
3978                right_ear_pressures.push(right);
3979            }
3980        }
3981
3982        console_log!("Frequency response computed");
3983
3984        let source_responses_output: Vec<SourceResponse> = self
3985            .sources
3986            .iter()
3987            .zip(source_responses)
3988            .map(|(source, spl)| SourceResponse {
3989                source_name: source.name.clone(),
3990                spl,
3991            })
3992            .collect();
3993
3994        let (horizontal_slices, vertical_slices) = if self.config.visualization.generate_slices {
3995            console_log!("Computing spatial slices...");
3996            self.compute_slices()
3997        } else {
3998            (None, None)
3999        };
4000
4001        let room_output = self.build_room_output();
4002
4003        // Calculate room modes and acoustics for rectangular rooms only
4004        let (room_modes, room_acoustics) = match &self.config.room {
4005            RoomGeometryConfig::Rectangular {
4006                width,
4007                depth,
4008                height,
4009            } => {
4010                let max_freq = self.frequencies.last().copied().unwrap_or(500.0);
4011                let modes = calculate_room_modes(
4012                    *width,
4013                    *depth,
4014                    *height,
4015                    self.config.solver.speed_of_sound,
4016                    max_freq,
4017                    10, // max order
4018                );
4019                // Calculate room acoustics at 1kHz (mid-frequency reference)
4020                let acoustics =
4021                    calculate_room_acoustics(*width, *depth, *height, &self.wall_materials, 1000.0);
4022                (Some(modes), Some(acoustics))
4023            }
4024            _ => (None, None), // L-shaped and complex rooms don't have simple calculations
4025        };
4026
4027        // Calculate impulse response if enabled
4028        let impulse_response = if compute_ir && !complex_pressures.is_empty() {
4029            console_log!("Computing impulse response...");
4030            let ir_config = &self.config.visualization.impulse_response;
4031
4032            // If duration not set, use RT60 + 100ms if available, otherwise default
4033            let ir_duration = ir_config
4034                .duration
4035                .or_else(|| room_acoustics.as_ref().map(|ra| ra.rt60_eyring + 0.1));
4036
4037            let config_with_duration = ImpulseResponseConfig {
4038                duration: ir_duration,
4039                ..ir_config.clone()
4040            };
4041
4042            Some(calculate_impulse_response(
4043                &self.frequencies,
4044                &complex_pressures,
4045                &config_with_duration,
4046            ))
4047        } else {
4048            None
4049        };
4050
4051        // Calculate binaural response if enabled
4052        let binaural_response = if compute_binaural && !left_ear_pressures.is_empty() {
4053            console_log!("Computing binaural response...");
4054            let binaural_config = &self.config.visualization.binaural;
4055            let head_center = binaural_config
4056                .head_position
4057                .as_ref()
4058                .map(|p| Point3D::new(p.x, p.y, p.z))
4059                .unwrap_or(self.listening_position);
4060
4061            Some(calculate_binaural_response(
4062                &self.frequencies,
4063                &left_ear_pressures,
4064                &right_ear_pressures,
4065                binaural_config,
4066                self.config.solver.speed_of_sound,
4067                head_center,
4068            ))
4069        } else {
4070            None
4071        };
4072
4073        let sources_output: Vec<SourceOutputInfo> = self
4074            .sources
4075            .iter()
4076            .zip(self.config.sources.iter())
4077            .map(|(source, config)| {
4078                let crossover_str = match &config.crossover {
4079                    CrossoverConfig::FullRange => None,
4080                    CrossoverConfig::Lowpass { cutoff_freq, order } => {
4081                        Some(format!("LP {}Hz {}nd", cutoff_freq, order))
4082                    }
4083                    CrossoverConfig::Highpass { cutoff_freq, order } => {
4084                        Some(format!("HP {}Hz {}nd", cutoff_freq, order))
4085                    }
4086                    CrossoverConfig::Bandpass {
4087                        low_cutoff,
4088                        high_cutoff,
4089                        order,
4090                    } => Some(format!("BP {}-{}Hz {}nd", low_cutoff, high_cutoff, order)),
4091                };
4092
4093                SourceOutputInfo {
4094                    name: source.name.clone(),
4095                    position: [source.position.x, source.position.y, source.position.z],
4096                    crossover: crossover_str,
4097                }
4098            })
4099            .collect();
4100
4101        let results = SimulationResults {
4102            room: room_output,
4103            sources: sources_output,
4104            listening_position: [
4105                self.listening_position.x,
4106                self.listening_position.y,
4107                self.listening_position.z,
4108            ],
4109            frequencies: self.frequencies.clone(),
4110            frequency_response: combined_spl,
4111            source_responses: Some(source_responses_output),
4112            horizontal_slices,
4113            vertical_slices,
4114            solver: self.config.solver.method.clone(),
4115            mesh_nodes: None,
4116            mesh_elements: None,
4117            metadata: Some(self.config.metadata.clone()),
4118            room_modes,
4119            room_acoustics,
4120            impulse_response,
4121            binaural_response,
4122        };
4123
4124        console_log!("Simulation complete!");
4125
4126        serde_json::to_string(&results)
4127            .map_err(|e| JsValue::from_str(&format!("JSON serialization error: {}", e)))
4128    }
4129
4130    /// Compute a single frequency response (for progressive updates)
4131    #[wasm_bindgen]
4132    pub fn compute_frequency_point(&self, freq_index: usize) -> Result<String, JsValue> {
4133        if freq_index >= self.frequencies.len() {
4134            return Err(JsValue::from_str("Frequency index out of bounds"));
4135        }
4136
4137        let freq = self.frequencies[freq_index];
4138        let pressure = self.calculate_direct_field(&self.listening_position, freq);
4139        let spl = pressure_to_spl(pressure);
4140
4141        let result = serde_json::json!({
4142            "frequency": freq,
4143            "spl": spl,
4144            "index": freq_index,
4145            "total": self.frequencies.len()
4146        });
4147
4148        serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4149    }
4150
4151    /// Compute a horizontal slice at a specific frequency
4152    #[wasm_bindgen]
4153    pub fn compute_horizontal_slice(&self, frequency: f64) -> Result<String, JsValue> {
4154        console_log!("Computing horizontal slice at {:.1} Hz", frequency);
4155
4156        let resolution = self.config.visualization.slice_resolution;
4157        let (room_width, room_depth, _) = self.get_room_dimensions();
4158
4159        let x_points = lin_space(0.0, room_width, resolution);
4160        let y_points = lin_space(0.0, room_depth, resolution);
4161
4162        let mut spl_values = Vec::with_capacity(resolution * resolution);
4163
4164        for &y in &y_points {
4165            for &x in &x_points {
4166                let point = Point3D::new(x, y, self.listening_position.z);
4167                let pressure = self.calculate_direct_field(&point, frequency);
4168                spl_values.push(pressure_to_spl(pressure));
4169            }
4170        }
4171
4172        let result = SliceOutput {
4173            frequency,
4174            x: x_points,
4175            y: y_points,
4176            z: None,
4177            spl: spl_values,
4178            shape: [resolution, resolution],
4179        };
4180
4181        serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4182    }
4183
4184    /// Compute a vertical slice at a specific frequency
4185    #[wasm_bindgen]
4186    pub fn compute_vertical_slice(&self, frequency: f64) -> Result<String, JsValue> {
4187        console_log!("Computing vertical slice at {:.1} Hz", frequency);
4188
4189        let resolution = self.config.visualization.slice_resolution;
4190        let (room_width, _, room_height) = self.get_room_dimensions();
4191
4192        let x_points = lin_space(0.0, room_width, resolution);
4193        let z_points = lin_space(0.0, room_height, resolution);
4194
4195        let mut spl_values = Vec::with_capacity(resolution * resolution);
4196
4197        for &z in &z_points {
4198            for &x in &x_points {
4199                let point = Point3D::new(x, self.listening_position.y, z);
4200                let pressure = self.calculate_direct_field(&point, frequency);
4201                spl_values.push(pressure_to_spl(pressure));
4202            }
4203        }
4204
4205        let result = SliceOutput {
4206            frequency,
4207            x: x_points,
4208            y: Vec::new(),
4209            z: Some(z_points),
4210            spl: spl_values,
4211            shape: [resolution, resolution],
4212        };
4213
4214        serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4215    }
4216
4217    /// Get room info as JSON
4218    #[wasm_bindgen]
4219    pub fn get_room_info(&self) -> String {
4220        let room_output = self.build_room_output();
4221        serde_json::to_string(&room_output).unwrap_or_else(|_| "{}".to_string())
4222    }
4223
4224    /// Get configuration as JSON
4225    #[wasm_bindgen]
4226    pub fn get_config(&self) -> String {
4227        serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
4228    }
4229
4230    /// Get number of frequencies
4231    #[wasm_bindgen]
4232    pub fn num_frequencies(&self) -> usize {
4233        self.frequencies.len()
4234    }
4235
4236    /// Get number of sources
4237    #[wasm_bindgen]
4238    pub fn num_sources(&self) -> usize {
4239        self.sources.len()
4240    }
4241
4242    fn get_room_dimensions(&self) -> (f64, f64, f64) {
4243        match &self.room_geometry {
4244            RoomGeometry::Rectangular(r) => (r.width, r.depth, r.height),
4245            RoomGeometry::LShaped(r) => (r.width1, r.depth1 + r.depth2, r.height),
4246        }
4247    }
4248
4249    fn build_room_output(&self) -> RoomOutput {
4250        let edges = self.room_geometry.get_edges();
4251        let edges_arrays: Vec<[[f64; 3]; 2]> = edges
4252            .iter()
4253            .map(|(p1, p2)| [[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]])
4254            .collect();
4255
4256        match &self.room_geometry {
4257            RoomGeometry::Rectangular(r) => RoomOutput {
4258                width: r.width,
4259                depth: r.depth,
4260                height: r.height,
4261                room_type: Some("rectangular".to_string()),
4262                edges: edges_arrays,
4263            },
4264            RoomGeometry::LShaped(r) => RoomOutput {
4265                width: r.width1,
4266                depth: r.depth1 + r.depth2,
4267                height: r.height,
4268                room_type: Some("lshaped".to_string()),
4269                edges: edges_arrays,
4270            },
4271        }
4272    }
4273
4274    fn compute_slices(&self) -> (Option<Vec<SliceOutput>>, Option<Vec<SliceOutput>>) {
4275        let resolution = self.config.visualization.slice_resolution;
4276        let (room_width, room_depth, room_height) = self.get_room_dimensions();
4277
4278        let x_points = lin_space(0.0, room_width, resolution);
4279        let y_points = lin_space(0.0, room_depth, resolution);
4280        let z_points = lin_space(0.0, room_height, resolution);
4281
4282        let freq_indices: Vec<usize> =
4283            if self.config.visualization.slice_frequency_indices.is_empty() {
4284                let step = (self.frequencies.len() / 10).max(1);
4285                (0..self.frequencies.len()).step_by(step).collect()
4286            } else {
4287                self.config
4288                    .visualization
4289                    .slice_frequency_indices
4290                    .iter()
4291                    .filter(|&&i| i < self.frequencies.len())
4292                    .copied()
4293                    .collect()
4294            };
4295
4296        console_log!(
4297            "Computing {} spatial slices in parallel...",
4298            freq_indices.len()
4299        );
4300
4301        // Parallel computation of slices - each frequency slice is independent
4302        let slices: Vec<(SliceOutput, SliceOutput)> = freq_indices
4303            .par_iter()
4304            .map(|&freq_idx| {
4305                let freq = self.frequencies[freq_idx];
4306
4307                // Horizontal slice - parallelize the grid computation
4308                // For L-shaped rooms, points outside the room boundary get very low SPL
4309                let h_spl: Vec<f64> = y_points
4310                    .iter()
4311                    .flat_map(|&y| {
4312                        x_points.iter().map(move |&x| {
4313                            let point = Point3D::new(x, y, self.listening_position.z);
4314                            // Check if point is inside the room
4315                            if !self.point_inside_room(&point) {
4316                                return -100.0; // Very low SPL for points outside room
4317                            }
4318                            let pressure = self.calculate_direct_field(&point, freq);
4319                            pressure_to_spl(pressure)
4320                        })
4321                    })
4322                    .collect();
4323
4324                let h_slice = SliceOutput {
4325                    frequency: freq,
4326                    x: x_points.clone(),
4327                    y: y_points.clone(),
4328                    z: None,
4329                    spl: h_spl,
4330                    shape: [resolution, resolution],
4331                };
4332
4333                // Vertical slice - parallelize the grid computation
4334                // For L-shaped rooms, points outside the room boundary get very low SPL
4335                let v_spl: Vec<f64> = z_points
4336                    .iter()
4337                    .flat_map(|&z| {
4338                        x_points.iter().map(move |&x| {
4339                            let point = Point3D::new(x, self.listening_position.y, z);
4340                            // Check if point is inside the room
4341                            if !self.point_inside_room(&point) {
4342                                return -100.0; // Very low SPL for points outside room
4343                            }
4344                            let pressure = self.calculate_direct_field(&point, freq);
4345                            pressure_to_spl(pressure)
4346                        })
4347                    })
4348                    .collect();
4349
4350                let v_slice = SliceOutput {
4351                    frequency: freq,
4352                    x: x_points.clone(),
4353                    y: Vec::new(),
4354                    z: Some(z_points.clone()),
4355                    spl: v_spl,
4356                    shape: [resolution, resolution],
4357                };
4358
4359                (h_slice, v_slice)
4360            })
4361            .collect();
4362
4363        // Unzip into separate vectors
4364        let (horizontal_slices, vertical_slices): (Vec<_>, Vec<_>) = slices.into_iter().unzip();
4365
4366        console_log!("Spatial slices computed");
4367
4368        (Some(horizontal_slices), Some(vertical_slices))
4369    }
4370}
4371
4372// ============================================================================
4373// Standalone utility functions for JS
4374// ============================================================================
4375
4376/// Create a default configuration JSON
4377#[wasm_bindgen]
4378pub fn create_default_config() -> String {
4379    let config = SimulationConfig {
4380        room: RoomGeometryConfig::Rectangular {
4381            width: 5.0,
4382            depth: 4.0,
4383            height: 2.5,
4384        },
4385        sources: vec![
4386            SourceConfig {
4387                name: "Left Speaker".to_string(),
4388                position: Point3DConfig {
4389                    x: 1.5,
4390                    y: 0.5,
4391                    z: 1.2,
4392                },
4393                amplitude: 1.0,
4394                directivity: DirectivityConfig::Omnidirectional,
4395                crossover: CrossoverConfig::FullRange,
4396                delay_ms: 0.0,
4397                invert_phase: false,
4398            },
4399            SourceConfig {
4400                name: "Right Speaker".to_string(),
4401                position: Point3DConfig {
4402                    x: 3.5,
4403                    y: 0.5,
4404                    z: 1.2,
4405                },
4406                amplitude: 1.0,
4407                directivity: DirectivityConfig::Omnidirectional,
4408                crossover: CrossoverConfig::FullRange,
4409                delay_ms: 0.0,
4410                invert_phase: false,
4411            },
4412        ],
4413        listening_positions: vec![Point3DConfig {
4414            x: 2.5,
4415            y: 2.5,
4416            z: 1.2,
4417        }],
4418        frequencies: FrequencyConfig {
4419            min_freq: 20.0,
4420            max_freq: 500.0,
4421            num_points: 100,
4422            spacing: "logarithmic".to_string(),
4423        },
4424        solver: SolverConfig::default(),
4425        visualization: VisualizationConfig::default(),
4426        wall_materials: WallMaterialsConfig::default(),
4427        metadata: MetadataConfig {
4428            description: "Default stereo setup".to_string(),
4429            author: "Room Simulator WASM".to_string(),
4430            date: String::new(),
4431            notes: String::new(),
4432        },
4433        scattering_objects: Vec::new(),
4434    };
4435
4436    serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".to_string())
4437}
4438
4439/// Get a list of available wall material presets
4440#[wasm_bindgen]
4441pub fn get_material_presets() -> String {
4442    let presets = vec![
4443        (
4444            "concrete",
4445            "Concrete/Brick (painted)",
4446            [0.01, 0.01, 0.02, 0.02, 0.02, 0.03],
4447        ),
4448        (
4449            "brick",
4450            "Unpainted Brick",
4451            [0.03, 0.03, 0.03, 0.04, 0.05, 0.07],
4452        ),
4453        (
4454            "drywall",
4455            "Drywall/Gypsum",
4456            [0.29, 0.10, 0.05, 0.04, 0.07, 0.09],
4457        ),
4458        (
4459            "plaster",
4460            "Plaster on Brick",
4461            [0.01, 0.02, 0.02, 0.03, 0.04, 0.05],
4462        ),
4463        (
4464            "glass",
4465            "Glass (large pane)",
4466            [0.18, 0.06, 0.04, 0.03, 0.02, 0.02],
4467        ),
4468        (
4469            "wood_thin",
4470            "Wood Paneling (thin)",
4471            [0.42, 0.21, 0.10, 0.08, 0.06, 0.06],
4472        ),
4473        (
4474            "wood_thick",
4475            "Heavy Wood/Door",
4476            [0.14, 0.10, 0.06, 0.08, 0.10, 0.10],
4477        ),
4478        (
4479            "carpet_thin",
4480            "Carpet (thin)",
4481            [0.02, 0.06, 0.14, 0.37, 0.60, 0.65],
4482        ),
4483        (
4484            "carpet_thick",
4485            "Carpet (heavy)",
4486            [0.08, 0.24, 0.57, 0.69, 0.71, 0.73],
4487        ),
4488        (
4489            "acoustic_tile",
4490            "Acoustic Ceiling Tiles",
4491            [0.50, 0.70, 0.60, 0.70, 0.70, 0.50],
4492        ),
4493        (
4494            "curtains",
4495            "Curtains/Drapes",
4496            [0.07, 0.31, 0.49, 0.75, 0.70, 0.60],
4497        ),
4498        (
4499            "acoustic_foam",
4500            "Acoustic Foam",
4501            [0.08, 0.25, 0.60, 0.90, 0.95, 0.90],
4502        ),
4503        (
4504            "hardwood",
4505            "Hardwood Floor",
4506            [0.15, 0.11, 0.10, 0.07, 0.06, 0.07],
4507        ),
4508        (
4509            "concrete_floor",
4510            "Concrete Floor",
4511            [0.01, 0.01, 0.02, 0.02, 0.02, 0.02],
4512        ),
4513    ];
4514
4515    let result: Vec<serde_json::Value> = presets
4516        .iter()
4517        .map(|(id, name, absorption)| {
4518            serde_json::json!({
4519                "id": id,
4520                "name": name,
4521                "absorption": {
4522                    "125": absorption[0],
4523                    "250": absorption[1],
4524                    "500": absorption[2],
4525                    "1000": absorption[3],
4526                    "2000": absorption[4],
4527                    "4000": absorption[5],
4528                }
4529            })
4530        })
4531        .collect();
4532
4533    serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string())
4534}
4535
4536/// Calculate room modes for a rectangular room (standalone WASM function)
4537///
4538/// Returns JSON with mode information:
4539/// - frequency: Resonant frequency in Hz
4540/// - indices: [n, m, p] mode indices
4541/// - mode_type: "axial", "tangential", or "oblique"
4542/// - description: Human-readable description
4543///
4544/// # Arguments
4545/// * `width` - Room width (X dimension) in meters
4546/// * `depth` - Room depth (Y dimension) in meters
4547/// * `height` - Room height (Z dimension) in meters
4548/// * `speed_of_sound` - Speed of sound in m/s (typically 343)
4549/// * `max_frequency` - Maximum frequency to calculate modes up to (Hz)
4550#[wasm_bindgen]
4551pub fn get_room_modes(
4552    width: f64,
4553    depth: f64,
4554    height: f64,
4555    speed_of_sound: f64,
4556    max_frequency: f64,
4557) -> String {
4558    let modes = calculate_room_modes(width, depth, height, speed_of_sound, max_frequency, 10);
4559    serde_json::to_string(&modes).unwrap_or_else(|_| "[]".to_string())
4560}
4561
4562/// Get the Schroeder frequency for a room
4563///
4564/// The Schroeder frequency marks the transition between modal behavior (below)
4565/// and statistical behavior (above). Below this frequency, individual room modes
4566/// dominate the response. Above it, the modal density is high enough for
4567/// statistical acoustics methods to be valid.
4568///
4569/// Formula: f_s = 2000 * sqrt(RT60 / V)
4570/// Where RT60 is reverberation time (seconds) and V is room volume (m³)
4571///
4572/// For rooms with unknown RT60, a rough estimate can be made:
4573/// f_s ≈ 2000 * sqrt(0.5 / V) for typical furnished rooms
4574#[wasm_bindgen]
4575pub fn get_schroeder_frequency(volume: f64, rt60: f64) -> f64 {
4576    2000.0 * (rt60 / volume).sqrt()
4577}
4578
4579/// Calculate RT60 and other room acoustics metrics
4580///
4581/// Returns JSON with:
4582/// - rt60_sabine: Sabine reverberation time (seconds)
4583/// - rt60_eyring: Eyring reverberation time (seconds) - more accurate for absorptive rooms
4584/// - volume: Room volume (m³)
4585/// - surface_area: Total surface area (m²)
4586/// - average_alpha: Average absorption coefficient
4587/// - total_absorption: Total absorption in sabins (m²)
4588/// - schroeder_frequency: Transition frequency from modal to statistical behavior (Hz)
4589/// - critical_distance: Distance where direct and reverberant fields are equal (m)
4590///
4591/// # Arguments
4592/// * `width` - Room width (X dimension) in meters
4593/// * `depth` - Room depth (Y dimension) in meters
4594/// * `height` - Room height (Z dimension) in meters
4595/// * `average_absorption` - Average absorption coefficient (0.0 to 1.0)
4596/// * `frequency` - Reference frequency for absorption (typically 1000 Hz)
4597#[wasm_bindgen]
4598pub fn get_rt60(width: f64, depth: f64, height: f64, average_absorption: f64) -> String {
4599    let volume = width * depth * height;
4600    let surface_area = 2.0 * (width * depth + width * height + depth * height);
4601    let total_absorption = average_absorption * surface_area;
4602
4603    let rt60_sab = rt60_sabine(volume, total_absorption);
4604    let rt60_eyr = rt60_eyring(volume, surface_area, average_absorption);
4605    let schroeder_freq = 2000.0 * (rt60_eyr / volume).sqrt();
4606    let crit_dist = critical_distance(volume, rt60_eyr);
4607
4608    let result = RoomAcoustics {
4609        rt60_sabine: rt60_sab,
4610        rt60_eyring: rt60_eyr,
4611        volume,
4612        surface_area,
4613        average_alpha: average_absorption,
4614        total_absorption,
4615        schroeder_frequency: schroeder_freq,
4616        critical_distance: crit_dist,
4617    };
4618
4619    serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
4620}
4621
4622/// Compute impulse response from frequency response data
4623///
4624/// Takes JSON with:
4625/// - frequencies: array of frequency values (Hz)
4626/// - magnitudes: array of magnitude values (linear, not dB)
4627/// - phases: array of phase values (radians)
4628/// - config: optional ImpulseResponseConfig object
4629///
4630/// Returns JSON with impulse response data
4631#[wasm_bindgen]
4632pub fn compute_impulse_response(input_json: &str) -> String {
4633    #[derive(Deserialize)]
4634    struct IrInput {
4635        frequencies: Vec<f64>,
4636        magnitudes: Vec<f64>,
4637        phases: Vec<f64>,
4638        #[serde(default)]
4639        config: Option<ImpulseResponseConfig>,
4640    }
4641
4642    let input: IrInput = match serde_json::from_str(input_json) {
4643        Ok(v) => v,
4644        Err(e) => return format!(r#"{{"error": "Invalid JSON: {}"}}"#, e),
4645    };
4646
4647    if input.frequencies.len() != input.magnitudes.len()
4648        || input.frequencies.len() != input.phases.len()
4649    {
4650        return r#"{"error": "frequencies, magnitudes, and phases arrays must have same length"}"#
4651            .to_string();
4652    }
4653
4654    // Build complex frequency response from magnitude and phase
4655    let complex_response: Vec<Complex64> = input
4656        .magnitudes
4657        .iter()
4658        .zip(input.phases.iter())
4659        .map(|(&mag, &phase)| Complex64::from_polar(mag, phase))
4660        .collect();
4661
4662    let config = input.config.unwrap_or_default();
4663    let ir = calculate_impulse_response(&input.frequencies, &complex_response, &config);
4664
4665    serde_json::to_string(&ir)
4666        .unwrap_or_else(|_| r#"{"error": "Serialization failed"}"#.to_string())
4667}
4668
4669/// Validate a configuration JSON and return any errors
4670#[wasm_bindgen]
4671pub fn validate_config(config_json: &str) -> String {
4672    match serde_json::from_str::<SimulationConfig>(config_json) {
4673        Ok(config) => {
4674            let mut warnings = Vec::new();
4675
4676            let (w, d, h) = match &config.room {
4677                RoomGeometryConfig::Rectangular {
4678                    width,
4679                    depth,
4680                    height,
4681                } => (*width, *depth, *height),
4682                RoomGeometryConfig::LShaped {
4683                    width1,
4684                    depth1,
4685                    depth2,
4686                    height,
4687                    ..
4688                } => (*width1, depth1 + depth2, *height),
4689            };
4690
4691            if w <= 0.0 || d <= 0.0 || h <= 0.0 {
4692                warnings.push("Room dimensions must be positive".to_string());
4693            }
4694
4695            if config.sources.is_empty() {
4696                warnings.push("At least one source is required".to_string());
4697            }
4698
4699            for (idx, source) in config.sources.iter().enumerate() {
4700                if source.position.x < 0.0
4701                    || source.position.x > w
4702                    || source.position.y < 0.0
4703                    || source.position.y > d
4704                    || source.position.z < 0.0
4705                    || source.position.z > h
4706                {
4707                    warnings.push(format!("Source {} is outside room bounds", idx + 1));
4708                }
4709            }
4710
4711            if config.listening_positions.is_empty() {
4712                warnings.push("At least one listening position is required".to_string());
4713            }
4714
4715            for (idx, lp) in config.listening_positions.iter().enumerate() {
4716                if lp.x < 0.0 || lp.x > w || lp.y < 0.0 || lp.y > d || lp.z < 0.0 || lp.z > h {
4717                    warnings.push(format!(
4718                        "Listening position {} is outside room bounds",
4719                        idx + 1
4720                    ));
4721                }
4722            }
4723
4724            if config.frequencies.min_freq <= 0.0 {
4725                warnings.push("Minimum frequency must be positive".to_string());
4726            }
4727            if config.frequencies.max_freq <= config.frequencies.min_freq {
4728                warnings.push("Maximum frequency must be greater than minimum".to_string());
4729            }
4730            if config.frequencies.num_points < 2 {
4731                warnings.push("At least 2 frequency points are required".to_string());
4732            }
4733
4734            if warnings.is_empty() {
4735                serde_json::json!({"valid": true, "warnings": []}).to_string()
4736            } else {
4737                serde_json::json!({"valid": false, "warnings": warnings}).to_string()
4738            }
4739        }
4740        Err(e) => serde_json::json!({"valid": false, "error": e.to_string()}).to_string(),
4741    }
4742}