Skip to main content

oxiphysics_geometry/
error.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for oxiphysics-geometry
5//!
6//! This module provides structured error handling for geometry operations,
7//! including mesh validation, ray-cast diagnostics, parameter checking,
8//! and I/O errors.  All public types implement `std::error::Error` via
9//! `thiserror`.
10
11use thiserror::Error;
12
13// ── Core error type ──────────────────────────────────────────────────────────
14
15/// Main error type for the geometry module.
16#[derive(Debug, Error)]
17pub enum Error {
18    /// Generic catch-all error with a human-readable message.
19    #[error("{0}")]
20    General(String),
21
22    /// A required parameter was out of its valid range.
23    #[error("parameter '{name}' = {value} is out of range [{min}, {max}]")]
24    OutOfRange {
25        /// Name of the parameter.
26        name: &'static str,
27        /// Actual value supplied.
28        value: f64,
29        /// Inclusive lower bound.
30        min: f64,
31        /// Inclusive upper bound.
32        max: f64,
33    },
34
35    /// A mesh is topologically or geometrically invalid.
36    #[error("mesh validation failed: {reason}")]
37    InvalidMesh {
38        /// Human-readable description of the problem.
39        reason: String,
40    },
41
42    /// A buffer had the wrong length.
43    #[error("buffer length mismatch: expected {expected}, got {actual}")]
44    LengthMismatch {
45        /// Expected length.
46        expected: usize,
47        /// Actual length.
48        actual: usize,
49    },
50
51    /// Numerical computation did not converge.
52    #[error(
53        "numerical computation '{operation}' did not converge after {iterations} iterations (residual {residual:.3e})"
54    )]
55    ConvergenceFailure {
56        /// Name of the operation that failed.
57        operation: &'static str,
58        /// Number of iterations attempted.
59        iterations: usize,
60        /// Final residual value.
61        residual: f64,
62    },
63
64    /// An index was out of bounds for the given container.
65    #[error("index out of bounds: {index} >= {len}")]
66    IndexOutOfBounds {
67        /// The invalid index.
68        index: usize,
69        /// The length of the container.
70        len: usize,
71    },
72
73    /// A shape requires at least a minimum number of vertices/points.
74    #[error("shape requires at least {required} vertices/points, got {actual}")]
75    TooFewPoints {
76        /// Minimum required.
77        required: usize,
78        /// Actual count.
79        actual: usize,
80    },
81
82    /// A degenerate geometry was encountered (zero area, zero volume, etc.).
83    #[error("degenerate geometry: {details}")]
84    DegenerateGeometry {
85        /// Description of the degenerate case.
86        details: String,
87    },
88
89    /// Two arrays that must have equal length do not.
90    #[error("array dimension mismatch: '{lhs}' has {lhs_len} elements, '{rhs}' has {rhs_len}")]
91    DimensionMismatch {
92        /// Name of the first array.
93        lhs: &'static str,
94        /// Length of the first array.
95        lhs_len: usize,
96        /// Name of the second array.
97        rhs: &'static str,
98        /// Length of the second array.
99        rhs_len: usize,
100    },
101
102    /// A requested feature or algorithm is not supported for this input.
103    #[error("unsupported operation '{operation}': {reason}")]
104    Unsupported {
105        /// Name of the operation.
106        operation: &'static str,
107        /// Reason it is unsupported.
108        reason: String,
109    },
110
111    /// An I/O error occurred during geometry serialization or deserialization.
112    #[error("I/O error in '{context}': {message}")]
113    Io {
114        /// The operation context (e.g. "serialize heightfield").
115        context: &'static str,
116        /// Human-readable message.
117        message: String,
118    },
119}
120
121/// Result type alias for geometry operations.
122pub type Result<T> = std::result::Result<T, Error>;
123
124// ── Convenience constructors ─────────────────────────────────────────────────
125
126impl Error {
127    /// Create a `General` error from any string-like value.
128    #[allow(dead_code)]
129    pub fn general(msg: impl Into<String>) -> Self {
130        Self::General(msg.into())
131    }
132
133    /// Create an `OutOfRange` error.
134    #[allow(dead_code)]
135    pub fn out_of_range(name: &'static str, value: f64, min: f64, max: f64) -> Self {
136        Self::OutOfRange {
137            name,
138            value,
139            min,
140            max,
141        }
142    }
143
144    /// Create an `InvalidMesh` error.
145    #[allow(dead_code)]
146    pub fn invalid_mesh(reason: impl Into<String>) -> Self {
147        Self::InvalidMesh {
148            reason: reason.into(),
149        }
150    }
151
152    /// Create a `LengthMismatch` error.
153    #[allow(dead_code)]
154    pub fn length_mismatch(expected: usize, actual: usize) -> Self {
155        Self::LengthMismatch { expected, actual }
156    }
157
158    /// Create a `ConvergenceFailure` error.
159    #[allow(dead_code)]
160    pub fn convergence_failure(operation: &'static str, iterations: usize, residual: f64) -> Self {
161        Self::ConvergenceFailure {
162            operation,
163            iterations,
164            residual,
165        }
166    }
167
168    /// Create an `IndexOutOfBounds` error.
169    #[allow(dead_code)]
170    pub fn index_out_of_bounds(index: usize, len: usize) -> Self {
171        Self::IndexOutOfBounds { index, len }
172    }
173
174    /// Create a `TooFewPoints` error.
175    #[allow(dead_code)]
176    pub fn too_few_points(required: usize, actual: usize) -> Self {
177        Self::TooFewPoints { required, actual }
178    }
179
180    /// Create a `DegenerateGeometry` error.
181    #[allow(dead_code)]
182    pub fn degenerate_geometry(details: impl Into<String>) -> Self {
183        Self::DegenerateGeometry {
184            details: details.into(),
185        }
186    }
187
188    /// Create a `DimensionMismatch` error.
189    #[allow(dead_code)]
190    pub fn dimension_mismatch(
191        lhs: &'static str,
192        lhs_len: usize,
193        rhs: &'static str,
194        rhs_len: usize,
195    ) -> Self {
196        Self::DimensionMismatch {
197            lhs,
198            lhs_len,
199            rhs,
200            rhs_len,
201        }
202    }
203
204    /// Create an `Unsupported` error.
205    #[allow(dead_code)]
206    pub fn unsupported(operation: &'static str, reason: impl Into<String>) -> Self {
207        Self::Unsupported {
208            operation,
209            reason: reason.into(),
210        }
211    }
212
213    /// Create an `Io` error.
214    #[allow(dead_code)]
215    pub fn io(context: &'static str, message: impl Into<String>) -> Self {
216        Self::Io {
217            context,
218            message: message.into(),
219        }
220    }
221
222    /// Returns `true` if this is a `General` error.
223    #[allow(dead_code)]
224    pub fn is_general(&self) -> bool {
225        matches!(self, Self::General(_))
226    }
227
228    /// Returns `true` if this is a `LengthMismatch` error.
229    #[allow(dead_code)]
230    pub fn is_length_mismatch(&self) -> bool {
231        matches!(self, Self::LengthMismatch { .. })
232    }
233
234    /// Returns `true` if this is a convergence failure.
235    #[allow(dead_code)]
236    pub fn is_convergence_failure(&self) -> bool {
237        matches!(self, Self::ConvergenceFailure { .. })
238    }
239
240    /// Returns `true` if this is an index-out-of-bounds error.
241    #[allow(dead_code)]
242    pub fn is_index_out_of_bounds(&self) -> bool {
243        matches!(self, Self::IndexOutOfBounds { .. })
244    }
245
246    /// Returns `true` if this error indicates degenerate geometry.
247    #[allow(dead_code)]
248    pub fn is_degenerate(&self) -> bool {
249        matches!(self, Self::DegenerateGeometry { .. })
250    }
251}
252
253// ── Validation helpers ───────────────────────────────────────────────────────
254
255/// Assert that `value` lies in `[min, max]`, returning `Err(Error::OutOfRange)`
256/// if it does not.
257#[allow(dead_code)]
258pub fn check_range(name: &'static str, value: f64, min: f64, max: f64) -> Result<()> {
259    if value >= min && value <= max {
260        Ok(())
261    } else {
262        Err(Error::out_of_range(name, value, min, max))
263    }
264}
265
266/// Assert that `len == expected`, returning `Err(Error::LengthMismatch)` if not.
267#[allow(dead_code)]
268pub fn check_len(expected: usize, actual: usize) -> Result<()> {
269    if actual == expected {
270        Ok(())
271    } else {
272        Err(Error::length_mismatch(expected, actual))
273    }
274}
275
276/// Assert that `index < len`, returning `Err(Error::IndexOutOfBounds)` if not.
277#[allow(dead_code)]
278pub fn check_index(index: usize, len: usize) -> Result<()> {
279    if index < len {
280        Ok(())
281    } else {
282        Err(Error::index_out_of_bounds(index, len))
283    }
284}
285
286/// Assert that `count >= required`, returning `Err(Error::TooFewPoints)` if not.
287#[allow(dead_code)]
288pub fn check_min_points(required: usize, actual: usize) -> Result<()> {
289    if actual >= required {
290        Ok(())
291    } else {
292        Err(Error::too_few_points(required, actual))
293    }
294}
295
296/// Assert that `lhs_len == rhs_len`, returning `Err(Error::DimensionMismatch)`.
297#[allow(dead_code)]
298pub fn check_dim_match(
299    lhs: &'static str,
300    lhs_len: usize,
301    rhs: &'static str,
302    rhs_len: usize,
303) -> Result<()> {
304    if lhs_len == rhs_len {
305        Ok(())
306    } else {
307        Err(Error::dimension_mismatch(lhs, lhs_len, rhs, rhs_len))
308    }
309}
310
311/// Assert that a value is strictly positive, returning `Err(Error::OutOfRange)`.
312#[allow(dead_code)]
313pub fn check_positive(name: &'static str, value: f64) -> Result<()> {
314    if value > 0.0 {
315        Ok(())
316    } else {
317        Err(Error::out_of_range(name, value, f64::EPSILON, f64::MAX))
318    }
319}
320
321/// Assert that a value is non-negative, returning `Err(Error::OutOfRange)`.
322#[allow(dead_code)]
323pub fn check_non_negative(name: &'static str, value: f64) -> Result<()> {
324    if value >= 0.0 {
325        Ok(())
326    } else {
327        Err(Error::out_of_range(name, value, 0.0, f64::MAX))
328    }
329}
330
331/// Assert that a value is finite (not NaN, not infinite).
332#[allow(dead_code)]
333pub fn check_finite(name: &'static str, value: f64) -> Result<()> {
334    if value.is_finite() {
335        Ok(())
336    } else {
337        Err(Error::general(format!(
338            "parameter '{name}' is not finite: {value}"
339        )))
340    }
341}
342
343/// Check all values in a slice are finite.
344#[allow(dead_code)]
345pub fn check_finite_slice(name: &'static str, values: &[f64]) -> Result<()> {
346    for (i, &v) in values.iter().enumerate() {
347        if !v.is_finite() {
348            return Err(Error::general(format!(
349                "parameter '{name}[{i}]' is not finite: {v}"
350            )));
351        }
352    }
353    Ok(())
354}
355
356// ── Mesh validation helpers ──────────────────────────────────────────────────
357
358/// Validate that a triangle mesh has consistent vertex/index arrays.
359///
360/// Checks:
361/// 1. `vertices` is non-empty.
362/// 2. All indices in `triangles` are less than `vertices.len()`.
363/// 3. No triangle has repeated vertex indices (degenerate triangles).
364#[allow(dead_code)]
365pub fn validate_mesh(vertices: &[[f64; 3]], triangles: &[[usize; 3]]) -> Result<()> {
366    check_min_points(1, vertices.len())?;
367    for (i, tri) in triangles.iter().enumerate() {
368        for &idx in tri {
369            if idx >= vertices.len() {
370                return Err(Error::InvalidMesh {
371                    reason: format!(
372                        "triangle {i}: index {idx} >= vertex count {}",
373                        vertices.len()
374                    ),
375                });
376            }
377        }
378        if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] {
379            return Err(Error::DegenerateGeometry {
380                details: format!(
381                    "triangle {i} has repeated indices: [{}, {}, {}]",
382                    tri[0], tri[1], tri[2]
383                ),
384            });
385        }
386    }
387    Ok(())
388}
389
390/// Validate a height-field descriptor.
391///
392/// Checks that `rows >= 2`, `cols >= 2`, `scale_x > 0`, `scale_z > 0`, and
393/// `heights.len() == rows * cols`.
394#[allow(dead_code)]
395pub fn validate_heightfield(
396    heights: &[f64],
397    rows: usize,
398    cols: usize,
399    scale_x: f64,
400    scale_z: f64,
401) -> Result<()> {
402    if rows < 2 {
403        return Err(Error::TooFewPoints {
404            required: 2,
405            actual: rows,
406        });
407    }
408    if cols < 2 {
409        return Err(Error::TooFewPoints {
410            required: 2,
411            actual: cols,
412        });
413    }
414    check_positive("scale_x", scale_x)?;
415    check_positive("scale_z", scale_z)?;
416    check_len(rows * cols, heights.len())?;
417    check_finite_slice("heights", heights)?;
418    Ok(())
419}
420
421/// Validate that a ray direction is non-zero and finite.
422#[allow(dead_code)]
423pub fn validate_ray_dir(dir: [f64; 3]) -> Result<()> {
424    check_finite_slice("ray_dir", &dir)?;
425    let len_sq = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2];
426    if len_sq < 1e-30 {
427        return Err(Error::DegenerateGeometry {
428            details: "ray direction is zero (or near-zero)".into(),
429        });
430    }
431    Ok(())
432}
433
434/// Validate a point cloud: non-empty, all coordinates finite.
435#[allow(dead_code)]
436pub fn validate_point_cloud(points: &[[f64; 3]]) -> Result<()> {
437    check_min_points(1, points.len())?;
438    for (i, p) in points.iter().enumerate() {
439        if !p[0].is_finite() || !p[1].is_finite() || !p[2].is_finite() {
440            return Err(Error::general(format!(
441                "point[{i}] contains non-finite coordinate: {p:?}"
442            )));
443        }
444    }
445    Ok(())
446}
447
448// ── Error context helpers ────────────────────────────────────────────────────
449
450/// Attach a context string to a `Result`, wrapping the error in a new
451/// `General` message that includes the original error's display text.
452#[allow(dead_code)]
453pub trait WithContext<T> {
454    /// Wrap any error with an additional context prefix.
455    fn with_context(self, ctx: &str) -> Result<T>;
456}
457
458impl<T, E: std::fmt::Display> WithContext<T> for std::result::Result<T, E> {
459    fn with_context(self, ctx: &str) -> Result<T> {
460        self.map_err(|e| Error::General(format!("{ctx}: {e}")))
461    }
462}
463
464// ── Iterative-solver convergence tracker ────────────────────────────────────
465
466/// Tracks residual progress for an iterative solver and raises
467/// `Error::ConvergenceFailure` when the iteration limit is exceeded.
468#[derive(Debug, Clone)]
469#[allow(dead_code)]
470pub struct ConvergenceTracker {
471    operation: &'static str,
472    max_iterations: usize,
473    tolerance: f64,
474    current_iteration: usize,
475    last_residual: f64,
476}
477
478#[allow(dead_code)]
479impl ConvergenceTracker {
480    /// Create a new tracker.
481    ///
482    /// # Arguments
483    /// - `operation` — human-readable name of the algorithm.
484    /// - `max_iterations` — maximum allowed iterations before failure.
485    /// - `tolerance` — convergence criterion (residual < tolerance ⟹ converged).
486    pub fn new(operation: &'static str, max_iterations: usize, tolerance: f64) -> Self {
487        Self {
488            operation,
489            max_iterations,
490            tolerance,
491            current_iteration: 0,
492            last_residual: f64::INFINITY,
493        }
494    }
495
496    /// Record a residual for the current iteration and advance the counter.
497    ///
498    /// Returns `Ok(true)` if converged, `Ok(false)` if still iterating,
499    /// or `Err(Error::ConvergenceFailure)` if the limit was reached.
500    pub fn update(&mut self, residual: f64) -> Result<bool> {
501        self.last_residual = residual;
502        self.current_iteration += 1;
503        if residual < self.tolerance {
504            return Ok(true);
505        }
506        if self.current_iteration >= self.max_iterations {
507            return Err(Error::convergence_failure(
508                self.operation,
509                self.current_iteration,
510                residual,
511            ));
512        }
513        Ok(false)
514    }
515
516    /// Current iteration count.
517    pub fn iterations(&self) -> usize {
518        self.current_iteration
519    }
520
521    /// Last recorded residual.
522    pub fn residual(&self) -> f64 {
523        self.last_residual
524    }
525
526    /// Reset the tracker for reuse.
527    pub fn reset(&mut self) {
528        self.current_iteration = 0;
529        self.last_residual = f64::INFINITY;
530    }
531}
532
533// ── Tests ────────────────────────────────────────────────────────────────────
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    // ── Error construction ────────────────────────────────────────────────────
540
541    #[test]
542    fn test_general_error_display() {
543        let e = Error::general("something went wrong");
544        assert!(e.to_string().contains("something went wrong"));
545    }
546
547    #[test]
548    fn test_out_of_range_display() {
549        let e = Error::out_of_range("radius", -1.0, 0.0, 100.0);
550        let s = e.to_string();
551        assert!(s.contains("radius"), "should mention parameter name");
552        assert!(s.contains("-1"), "should mention actual value");
553    }
554
555    #[test]
556    fn test_length_mismatch_display() {
557        let e = Error::length_mismatch(10, 7);
558        let s = e.to_string();
559        assert!(s.contains("10"));
560        assert!(s.contains("7"));
561    }
562
563    #[test]
564    fn test_convergence_failure_display() {
565        let e = Error::convergence_failure("smoothing", 100, 0.001);
566        let s = e.to_string();
567        assert!(s.contains("smoothing"));
568        assert!(s.contains("100"));
569    }
570
571    #[test]
572    fn test_index_out_of_bounds_display() {
573        let e = Error::index_out_of_bounds(5, 3);
574        let s = e.to_string();
575        assert!(s.contains('5'));
576        assert!(s.contains('3'));
577    }
578
579    #[test]
580    fn test_too_few_points_display() {
581        let e = Error::too_few_points(3, 1);
582        let s = e.to_string();
583        assert!(s.contains('3'));
584        assert!(s.contains('1'));
585    }
586
587    #[test]
588    fn test_degenerate_geometry_display() {
589        let e = Error::degenerate_geometry("zero area triangle");
590        assert!(e.to_string().contains("zero area triangle"));
591    }
592
593    #[test]
594    fn test_dimension_mismatch_display() {
595        let e = Error::dimension_mismatch("positions", 10, "normals", 8);
596        let s = e.to_string();
597        assert!(s.contains("positions"));
598        assert!(s.contains("normals"));
599    }
600
601    #[test]
602    fn test_unsupported_display() {
603        let e = Error::unsupported("CSG union", "non-manifold mesh");
604        let s = e.to_string();
605        assert!(s.contains("CSG union"));
606        assert!(s.contains("non-manifold mesh"));
607    }
608
609    #[test]
610    fn test_io_error_display() {
611        let e = Error::io("serialize heightfield", "disk full");
612        let s = e.to_string();
613        assert!(s.contains("serialize heightfield"));
614        assert!(s.contains("disk full"));
615    }
616
617    // ── is_* predicates ───────────────────────────────────────────────────────
618
619    #[test]
620    fn test_is_general() {
621        assert!(Error::general("x").is_general());
622        assert!(!Error::length_mismatch(1, 2).is_general());
623    }
624
625    #[test]
626    fn test_is_length_mismatch() {
627        assert!(Error::length_mismatch(3, 4).is_length_mismatch());
628        assert!(!Error::general("x").is_length_mismatch());
629    }
630
631    #[test]
632    fn test_is_convergence_failure() {
633        assert!(Error::convergence_failure("op", 10, 0.1).is_convergence_failure());
634        assert!(!Error::general("x").is_convergence_failure());
635    }
636
637    #[test]
638    fn test_is_degenerate() {
639        assert!(Error::degenerate_geometry("zero vol").is_degenerate());
640        assert!(!Error::general("x").is_degenerate());
641    }
642
643    // ── Validation helpers ────────────────────────────────────────────────────
644
645    #[test]
646    fn test_check_range_ok() {
647        assert!(check_range("r", 5.0, 0.0, 10.0).is_ok());
648    }
649
650    #[test]
651    fn test_check_range_below_min() {
652        let r = check_range("r", -1.0, 0.0, 10.0);
653        assert!(r.is_err());
654        assert!(matches!(r.unwrap_err(), Error::OutOfRange { .. }));
655    }
656
657    #[test]
658    fn test_check_range_above_max() {
659        let r = check_range("r", 11.0, 0.0, 10.0);
660        assert!(r.is_err());
661    }
662
663    #[test]
664    fn test_check_len_ok() {
665        assert!(check_len(5, 5).is_ok());
666    }
667
668    #[test]
669    fn test_check_len_mismatch() {
670        assert!(check_len(5, 4).is_err());
671    }
672
673    #[test]
674    fn test_check_index_ok() {
675        assert!(check_index(0, 1).is_ok());
676        assert!(check_index(4, 5).is_ok());
677    }
678
679    #[test]
680    fn test_check_index_equal_to_len_fails() {
681        assert!(check_index(5, 5).is_err());
682    }
683
684    #[test]
685    fn test_check_min_points_ok() {
686        assert!(check_min_points(3, 3).is_ok());
687        assert!(check_min_points(3, 10).is_ok());
688    }
689
690    #[test]
691    fn test_check_min_points_too_few() {
692        assert!(check_min_points(4, 2).is_err());
693    }
694
695    #[test]
696    fn test_check_positive_ok() {
697        assert!(check_positive("s", 0.001).is_ok());
698    }
699
700    #[test]
701    fn test_check_positive_zero_fails() {
702        assert!(check_positive("s", 0.0).is_err());
703    }
704
705    #[test]
706    fn test_check_non_negative_ok() {
707        assert!(check_non_negative("v", 0.0).is_ok());
708        assert!(check_non_negative("v", 1.5).is_ok());
709    }
710
711    #[test]
712    fn test_check_non_negative_negative_fails() {
713        assert!(check_non_negative("v", -0.1).is_err());
714    }
715
716    #[test]
717    fn test_check_finite_ok() {
718        assert!(check_finite("x", 3.125).is_ok());
719    }
720
721    #[test]
722    fn test_check_finite_nan_fails() {
723        assert!(check_finite("x", f64::NAN).is_err());
724    }
725
726    #[test]
727    fn test_check_finite_inf_fails() {
728        assert!(check_finite("x", f64::INFINITY).is_err());
729    }
730
731    #[test]
732    fn test_check_finite_slice_ok() {
733        assert!(check_finite_slice("pts", &[1.0, 2.0, 3.0]).is_ok());
734    }
735
736    #[test]
737    fn test_check_finite_slice_nan_fails() {
738        assert!(check_finite_slice("pts", &[1.0, f64::NAN, 3.0]).is_err());
739    }
740
741    #[test]
742    fn test_check_dim_match_ok() {
743        assert!(check_dim_match("pos", 5, "nrm", 5).is_ok());
744    }
745
746    #[test]
747    fn test_check_dim_match_fail() {
748        let r = check_dim_match("pos", 5, "nrm", 3);
749        assert!(r.is_err());
750        assert!(matches!(r.unwrap_err(), Error::DimensionMismatch { .. }));
751    }
752
753    // ── Mesh validation ───────────────────────────────────────────────────────
754
755    #[test]
756    fn test_validate_mesh_ok() {
757        let verts = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
758        let tris = [[0, 1, 2]];
759        assert!(validate_mesh(&verts, &tris).is_ok());
760    }
761
762    #[test]
763    fn test_validate_mesh_index_out_of_range() {
764        let verts = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
765        let tris = [[0, 1, 5]]; // index 5 >= len 2
766        assert!(validate_mesh(&verts, &tris).is_err());
767    }
768
769    #[test]
770    fn test_validate_mesh_degenerate_triangle() {
771        let verts = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
772        let tris = [[0, 0, 2]]; // repeated index
773        let r = validate_mesh(&verts, &tris);
774        assert!(r.is_err());
775        assert!(matches!(r.unwrap_err(), Error::DegenerateGeometry { .. }));
776    }
777
778    #[test]
779    fn test_validate_mesh_empty_vertices() {
780        let r = validate_mesh(&[], &[[0, 1, 2]]);
781        assert!(r.is_err());
782    }
783
784    // ── HeightField validation ────────────────────────────────────────────────
785
786    #[test]
787    fn test_validate_heightfield_ok() {
788        let heights = vec![0.0f64; 4 * 4];
789        assert!(validate_heightfield(&heights, 4, 4, 1.0, 1.0).is_ok());
790    }
791
792    #[test]
793    fn test_validate_heightfield_too_few_rows() {
794        let heights = vec![0.0f64; 4];
795        assert!(validate_heightfield(&heights, 1, 4, 1.0, 1.0).is_err());
796    }
797
798    #[test]
799    fn test_validate_heightfield_bad_scale() {
800        let heights = vec![0.0f64; 4 * 4];
801        assert!(validate_heightfield(&heights, 4, 4, 0.0, 1.0).is_err());
802    }
803
804    #[test]
805    fn test_validate_heightfield_len_mismatch() {
806        let heights = vec![0.0f64; 10]; // wrong length
807        assert!(validate_heightfield(&heights, 4, 4, 1.0, 1.0).is_err());
808    }
809
810    #[test]
811    fn test_validate_heightfield_nan_height() {
812        let mut heights = vec![0.0f64; 4 * 4];
813        heights[5] = f64::NAN;
814        assert!(validate_heightfield(&heights, 4, 4, 1.0, 1.0).is_err());
815    }
816
817    // ── Ray validation ────────────────────────────────────────────────────────
818
819    #[test]
820    fn test_validate_ray_dir_ok() {
821        assert!(validate_ray_dir([0.0, -1.0, 0.0]).is_ok());
822    }
823
824    #[test]
825    fn test_validate_ray_dir_zero_fails() {
826        assert!(validate_ray_dir([0.0, 0.0, 0.0]).is_err());
827    }
828
829    #[test]
830    fn test_validate_ray_dir_nan_fails() {
831        assert!(validate_ray_dir([f64::NAN, 0.0, 0.0]).is_err());
832    }
833
834    // ── Point cloud validation ────────────────────────────────────────────────
835
836    #[test]
837    fn test_validate_point_cloud_ok() {
838        let pts = [[0.0, 0.0, 0.0], [1.0, 2.0, 3.0]];
839        assert!(validate_point_cloud(&pts).is_ok());
840    }
841
842    #[test]
843    fn test_validate_point_cloud_empty_fails() {
844        assert!(validate_point_cloud(&[]).is_err());
845    }
846
847    #[test]
848    fn test_validate_point_cloud_nan_fails() {
849        let pts = [[f64::NAN, 0.0, 0.0]];
850        assert!(validate_point_cloud(&pts).is_err());
851    }
852
853    // ── WithContext ───────────────────────────────────────────────────────────
854
855    #[test]
856    fn test_with_context_ok_passes_through() {
857        let r: std::result::Result<i32, &str> = Ok(42);
858        let r2: Result<i32> = r.with_context("test");
859        assert_eq!(r2.unwrap(), 42);
860    }
861
862    #[test]
863    fn test_with_context_wraps_error() {
864        let r: std::result::Result<i32, &str> = Err("original error");
865        let r2: Result<i32> = r.with_context("loading mesh");
866        let e = r2.unwrap_err();
867        let s = e.to_string();
868        assert!(s.contains("loading mesh"), "context missing: {s}");
869        assert!(s.contains("original error"), "original missing: {s}");
870    }
871
872    // ── ConvergenceTracker ────────────────────────────────────────────────────
873
874    #[test]
875    fn test_convergence_tracker_converges() {
876        let mut tracker = ConvergenceTracker::new("test_op", 100, 1e-6);
877        // First update with small residual should converge
878        let result = tracker.update(1e-8);
879        assert!(result.is_ok());
880        assert!(result.unwrap(), "should report converged");
881        assert_eq!(tracker.iterations(), 1);
882    }
883
884    #[test]
885    fn test_convergence_tracker_not_yet_converged() {
886        let mut tracker = ConvergenceTracker::new("test_op", 100, 1e-6);
887        let result = tracker.update(0.5);
888        assert!(result.is_ok());
889        assert!(!result.unwrap(), "should not report converged yet");
890    }
891
892    #[test]
893    fn test_convergence_tracker_failure() {
894        let mut tracker = ConvergenceTracker::new("mesh_smooth", 3, 1e-10);
895        let _ = tracker.update(1.0);
896        let _ = tracker.update(0.5);
897        let r = tracker.update(0.3); // 3rd update exceeds limit
898        assert!(r.is_err());
899        assert!(matches!(r.unwrap_err(), Error::ConvergenceFailure { .. }));
900    }
901
902    #[test]
903    fn test_convergence_tracker_residual_tracked() {
904        let mut tracker = ConvergenceTracker::new("op", 100, 1e-6);
905        let _ = tracker.update(0.7);
906        assert!((tracker.residual() - 0.7).abs() < 1e-12);
907    }
908
909    #[test]
910    fn test_convergence_tracker_reset() {
911        let mut tracker = ConvergenceTracker::new("op", 100, 1e-6);
912        let _ = tracker.update(0.5);
913        tracker.reset();
914        assert_eq!(tracker.iterations(), 0);
915        assert!(tracker.residual().is_infinite());
916    }
917
918    #[test]
919    fn test_convergence_tracker_iterations_counted() {
920        let mut tracker = ConvergenceTracker::new("op", 100, 1e-6);
921        for _ in 0..5 {
922            let _ = tracker.update(1.0);
923        }
924        assert_eq!(tracker.iterations(), 5);
925    }
926}