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