Skip to main content

apex_io/
bal.rs

1//! Bundle Adjustment in the Large (BAL) dataset loader.
2//!
3//! This module provides functionality to load BAL format files, which are used
4//! for bundle adjustment benchmarks in computer vision.
5//!
6//! ## Format
7//!
8//! BAL files contain bundle adjustment problems with the following sequential structure:
9//! 1. **Header line**: `<num_cameras> <num_points> <num_observations>`
10//! 2. **Observations block**: One observation per line with format `<camera_idx> <point_idx> <x> <y>`
11//! 3. **Cameras block**: 9 sequential parameter lines per camera (one value per line)
12//! 4. **Points block**: 3 sequential coordinate lines per point (one value per line)
13//!
14//! ## Camera Model
15//!
16//! Uses Snavely's 9-parameter camera model from the Bundler structure-from-motion system:
17//! - **Rotation**: 3D axis-angle representation (rx, ry, rz) - 3 parameters
18//! - **Translation**: 3D vector (tx, ty, tz) - 3 parameters
19//! - **Focal length**: Single parameter (f) - 1 parameter
20//! - **Radial distortion**: Two coefficients (k1, k2) - 2 parameters
21//!
22//! For more details, see: <https://grail.cs.washington.edu/projects/bal/>
23//!
24//! ## Example
25//!
26//! ```no_run
27//! use apex_io::BalLoader;
28//!
29//! let dataset = BalLoader::load("data/bundle_adjustment/problem-21-11315-pre.txt")?;
30//! println!("Loaded {} cameras, {} points, {} observations",
31//!          dataset.cameras.len(),
32//!          dataset.points.len(),
33//!          dataset.observations.len());
34//! # Ok::<(), apex_io::IoError>(())
35//! ```
36
37use super::IoError;
38use nalgebra::Vector3;
39use std::path::Path;
40
41/// Represents a camera using Snavely's 9-parameter camera model.
42///
43/// The camera model from Bundler uses:
44/// - Axis-angle rotation representation (compact 3-parameter rotation)
45/// - 3D translation vector
46/// - Single focal length parameter
47/// - Two radial distortion coefficients
48#[derive(Debug, Clone)]
49pub struct BalCamera {
50    /// Rotation as axis-angle representation (rx, ry, rz)
51    pub rotation: Vector3<f64>,
52    /// Translation vector (tx, ty, tz)
53    pub translation: Vector3<f64>,
54    /// Focal length
55    pub focal_length: f64,
56    /// First radial distortion coefficient
57    pub k1: f64,
58    /// Second radial distortion coefficient
59    pub k2: f64,
60}
61
62/// Represents a 3D point (landmark) in the bundle adjustment problem.
63#[derive(Debug, Clone)]
64pub struct BalPoint {
65    /// 3D position (x, y, z)
66    pub position: Vector3<f64>,
67}
68
69/// Represents an observation of a 3D point by a camera.
70///
71/// Each observation links a camera to a 3D point via a 2D pixel measurement.
72#[derive(Debug, Clone)]
73pub struct BalObservation {
74    /// Index of the observing camera
75    pub camera_index: usize,
76    /// Index of the observed 3D point
77    pub point_index: usize,
78    /// Pixel x-coordinate
79    pub x: f64,
80    /// Pixel y-coordinate
81    pub y: f64,
82}
83
84/// Complete bundle adjustment dataset loaded from a BAL file.
85#[derive(Debug, Clone)]
86pub struct BalDataset {
87    /// All cameras in the dataset
88    pub cameras: Vec<BalCamera>,
89    /// All 3D points (landmarks) in the dataset
90    pub points: Vec<BalPoint>,
91    /// All observations (camera-point correspondences)
92    pub observations: Vec<BalObservation>,
93}
94
95/// Loader for BAL (Bundle Adjustment in the Large) dataset files.
96pub struct BalLoader;
97
98/// Default focal length used for cameras with negative or non-finite values.
99/// This value is used during BAL dataset loading to normalize invalid focal lengths.
100pub const DEFAULT_FOCAL_LENGTH: f64 = 500.0;
101
102impl BalCamera {
103    /// Normalizes the focal length to ensure it's valid for optimization.
104    ///
105    /// Replaces negative or non-finite focal lengths with DEFAULT_FOCAL_LENGTH,
106    /// while preserving all positive values regardless of magnitude.
107    fn normalize_focal_length(focal_length: f64) -> f64 {
108        if focal_length > 0.0 && focal_length.is_finite() {
109            focal_length
110        } else {
111            DEFAULT_FOCAL_LENGTH
112        }
113    }
114}
115
116impl BalLoader {
117    /// Loads a BAL dataset from a file.
118    ///
119    /// # Arguments
120    ///
121    /// * `path` - Path to the BAL format file
122    ///
123    /// # Returns
124    ///
125    /// Returns a `BalDataset` containing all cameras, points, and observations,
126    /// or an `IoError` if parsing fails.
127    ///
128    /// # Example
129    ///
130    /// ```no_run
131    /// use apex_io::BalLoader;
132    ///
133    /// let dataset = BalLoader::load("data/bundle_adjustment/problem-21-11315-pre.txt")?;
134    /// assert_eq!(dataset.cameras.len(), 21);
135    /// assert_eq!(dataset.points.len(), 11315);
136    /// # Ok::<(), apex_io::IoError>(())
137    /// ```
138    pub fn load(path: impl AsRef<Path>) -> Result<BalDataset, IoError> {
139        let path_ref = path.as_ref();
140        let content = std::fs::read_to_string(path_ref).map_err(|e| {
141            IoError::Io(e).log_with_source(format!("Failed to read BAL file: {:?}", path_ref))
142        })?;
143
144        // Create line iterator (skip empty lines, trim whitespace)
145        let mut lines = content
146            .lines()
147            .enumerate()
148            .map(|(idx, line)| (idx + 1, line.trim()))
149            .filter(|(_, line)| !line.is_empty());
150
151        // Parse header
152        let (num_cameras, num_points, num_observations) = Self::parse_header(&mut lines)?;
153
154        // Parse observations
155        let observations = Self::parse_observations(&mut lines, num_observations)?;
156
157        // Parse cameras
158        let cameras = Self::parse_cameras(&mut lines, num_cameras)?;
159
160        // Parse points
161        let points = Self::parse_points(&mut lines, num_points)?;
162
163        // Validate counts match header
164        if cameras.len() != num_cameras {
165            return Err(IoError::Parse {
166                line: 0,
167                message: format!(
168                    "Camera count mismatch: header says {}, got {}",
169                    num_cameras,
170                    cameras.len()
171                ),
172            });
173        }
174
175        if points.len() != num_points {
176            return Err(IoError::Parse {
177                line: 0,
178                message: format!(
179                    "Point count mismatch: header says {}, got {}",
180                    num_points,
181                    points.len()
182                ),
183            });
184        }
185
186        if observations.len() != num_observations {
187            return Err(IoError::Parse {
188                line: 0,
189                message: format!(
190                    "Observation count mismatch: header says {}, got {}",
191                    num_observations,
192                    observations.len()
193                ),
194            });
195        }
196
197        Ok(BalDataset {
198            cameras,
199            points,
200            observations,
201        })
202    }
203
204    /// Parses the header line containing dataset dimensions.
205    fn parse_header<'a>(
206        lines: &mut impl Iterator<Item = (usize, &'a str)>,
207    ) -> Result<(usize, usize, usize), IoError> {
208        let (line_num, header_line) = lines.next().ok_or(IoError::Parse {
209            line: 1,
210            message: "Missing header line".to_string(),
211        })?;
212
213        let parts: Vec<&str> = header_line.split_whitespace().collect();
214        if parts.len() != 3 {
215            return Err(IoError::MissingFields { line: line_num });
216        }
217
218        let num_cameras = parts[0]
219            .parse::<usize>()
220            .map_err(|_| IoError::InvalidNumber {
221                line: line_num,
222                value: parts[0].to_string(),
223            })?;
224
225        let num_points = parts[1]
226            .parse::<usize>()
227            .map_err(|_| IoError::InvalidNumber {
228                line: line_num,
229                value: parts[1].to_string(),
230            })?;
231
232        let num_observations = parts[2]
233            .parse::<usize>()
234            .map_err(|_| IoError::InvalidNumber {
235                line: line_num,
236                value: parts[2].to_string(),
237            })?;
238
239        Ok((num_cameras, num_points, num_observations))
240    }
241
242    /// Parses the observations block.
243    fn parse_observations<'a>(
244        lines: &mut impl Iterator<Item = (usize, &'a str)>,
245        num_observations: usize,
246    ) -> Result<Vec<BalObservation>, IoError> {
247        let mut observations = Vec::with_capacity(num_observations);
248
249        for _ in 0..num_observations {
250            let (line_num, line) = lines.next().ok_or(IoError::Parse {
251                line: 0,
252                message: "Unexpected end of file in observations section".to_string(),
253            })?;
254
255            let parts: Vec<&str> = line.split_whitespace().collect();
256            if parts.len() != 4 {
257                return Err(IoError::MissingFields { line: line_num });
258            }
259
260            let camera_index = parts[0]
261                .parse::<usize>()
262                .map_err(|_| IoError::InvalidNumber {
263                    line: line_num,
264                    value: parts[0].to_string(),
265                })?;
266
267            let point_index = parts[1]
268                .parse::<usize>()
269                .map_err(|_| IoError::InvalidNumber {
270                    line: line_num,
271                    value: parts[1].to_string(),
272                })?;
273
274            let x = parts[2]
275                .parse::<f64>()
276                .map_err(|_| IoError::InvalidNumber {
277                    line: line_num,
278                    value: parts[2].to_string(),
279                })?;
280
281            let y = parts[3]
282                .parse::<f64>()
283                .map_err(|_| IoError::InvalidNumber {
284                    line: line_num,
285                    value: parts[3].to_string(),
286                })?;
287
288            observations.push(BalObservation {
289                camera_index,
290                point_index,
291                x,
292                y,
293            });
294        }
295
296        Ok(observations)
297    }
298
299    /// Parses the cameras block.
300    ///
301    /// Each camera has 9 parameters on sequential lines:
302    /// - 3 lines for rotation (rx, ry, rz)
303    /// - 3 lines for translation (tx, ty, tz)
304    /// - 1 line for focal length (f)
305    /// - 2 lines for radial distortion (k1, k2)
306    fn parse_cameras<'a>(
307        lines: &mut impl Iterator<Item = (usize, &'a str)>,
308        num_cameras: usize,
309    ) -> Result<Vec<BalCamera>, IoError> {
310        let mut cameras = Vec::with_capacity(num_cameras);
311
312        for camera_idx in 0..num_cameras {
313            let mut params = Vec::with_capacity(9);
314
315            // Read 9 consecutive lines for camera parameters
316            for param_idx in 0..9 {
317                let (line_num, line) = lines.next().ok_or(IoError::Parse {
318                    line: 0,
319                    message: format!(
320                        "Unexpected end of file in camera {} parameter {}",
321                        camera_idx, param_idx
322                    ),
323                })?;
324
325                let value = line
326                    .trim()
327                    .parse::<f64>()
328                    .map_err(|_| IoError::InvalidNumber {
329                        line: line_num,
330                        value: line.to_string(),
331                    })?;
332
333                params.push(value);
334            }
335
336            cameras.push(BalCamera {
337                rotation: Vector3::new(params[0], params[1], params[2]),
338                translation: Vector3::new(params[3], params[4], params[5]),
339                focal_length: BalCamera::normalize_focal_length(params[6]),
340                k1: params[7],
341                k2: params[8],
342            });
343        }
344
345        Ok(cameras)
346    }
347
348    /// Parses the points block.
349    ///
350    /// Each point has 3 coordinates on sequential lines (x, y, z).
351    fn parse_points<'a>(
352        lines: &mut impl Iterator<Item = (usize, &'a str)>,
353        num_points: usize,
354    ) -> Result<Vec<BalPoint>, IoError> {
355        let mut points = Vec::with_capacity(num_points);
356
357        for point_idx in 0..num_points {
358            let mut coords = Vec::with_capacity(3);
359
360            // Read 3 consecutive lines for point coordinates
361            for coord_idx in 0..3 {
362                let (line_num, line) = lines.next().ok_or(IoError::Parse {
363                    line: 0,
364                    message: format!(
365                        "Unexpected end of file in point {} coordinate {}",
366                        point_idx, coord_idx
367                    ),
368                })?;
369
370                let value = line
371                    .trim()
372                    .parse::<f64>()
373                    .map_err(|_| IoError::InvalidNumber {
374                        line: line_num,
375                        value: line.to_string(),
376                    })?;
377
378                coords.push(value);
379            }
380
381            points.push(BalPoint {
382                position: Vector3::new(coords[0], coords[1], coords[2]),
383            });
384        }
385
386        Ok(points)
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::io::Write;
394    use tempfile::NamedTempFile;
395
396    type TestResult = Result<(), Box<dyn std::error::Error>>;
397
398    /// Writes a minimal BAL file: 1 camera, 1 point, 1 observation.
399    fn write_minimal_bal() -> Result<NamedTempFile, Box<dyn std::error::Error>> {
400        let mut f = NamedTempFile::new()?;
401        writeln!(f, "1 1 1")?; // header
402        writeln!(f, "0 0 -123.456 456.789")?; // observation
403        // Camera params (9 values, one per line): rx ry rz tx ty tz f k1 k2
404        for v in [0.1f64, 0.2, 0.3, 0.4, 0.5, 0.6, 500.0, -0.1, 0.05] {
405            writeln!(f, "{v}")?;
406        }
407        // Point coords (3 values, one per line): x y z
408        for v in [1.0f64, 2.0, 3.0] {
409            writeln!(f, "{v}")?;
410        }
411        f.flush()?;
412        Ok(f)
413    }
414
415    /// Writes a BAL file with a custom focal length value (all other params are zeros).
416    fn write_bal_with_focal(focal: f64) -> Result<NamedTempFile, Box<dyn std::error::Error>> {
417        let mut f = NamedTempFile::new()?;
418        writeln!(f, "1 1 1")?;
419        writeln!(f, "0 0 0.0 0.0")?; // observation
420        // Camera params: rx ry rz tx ty tz f k1 k2
421        for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, focal, 0.0, 0.0] {
422            writeln!(f, "{v}")?;
423        }
424        // Point coords
425        for v in [0.0f64, 0.0, 0.0] {
426            writeln!(f, "{v}")?;
427        }
428        f.flush()?;
429        Ok(f)
430    }
431
432    #[test]
433    fn test_load_minimal_dataset() -> TestResult {
434        let f = write_minimal_bal()?;
435        let ds = BalLoader::load(f.path())?;
436        assert_eq!(ds.cameras.len(), 1);
437        assert_eq!(ds.points.len(), 1);
438        assert_eq!(ds.observations.len(), 1);
439        Ok(())
440    }
441
442    #[test]
443    fn test_load_camera_values() -> TestResult {
444        let f = write_minimal_bal()?;
445        let ds = BalLoader::load(f.path())?;
446        let cam = &ds.cameras[0];
447        assert!((cam.rotation.x - 0.1).abs() < 1e-12);
448        assert!((cam.rotation.y - 0.2).abs() < 1e-12);
449        assert!((cam.rotation.z - 0.3).abs() < 1e-12);
450        assert!((cam.translation.x - 0.4).abs() < 1e-12);
451        assert!((cam.translation.y - 0.5).abs() < 1e-12);
452        assert!((cam.translation.z - 0.6).abs() < 1e-12);
453        assert!((cam.focal_length - 500.0).abs() < 1e-12);
454        assert!((cam.k1 - (-0.1)).abs() < 1e-12);
455        assert!((cam.k2 - 0.05).abs() < 1e-12);
456        Ok(())
457    }
458
459    #[test]
460    fn test_load_observation_values() -> TestResult {
461        let f = write_minimal_bal()?;
462        let ds = BalLoader::load(f.path())?;
463        let obs = &ds.observations[0];
464        assert_eq!(obs.camera_index, 0);
465        assert_eq!(obs.point_index, 0);
466        assert!((obs.x - (-123.456)).abs() < 1e-10);
467        assert!((obs.y - 456.789).abs() < 1e-10);
468        Ok(())
469    }
470
471    #[test]
472    fn test_load_point_values() -> TestResult {
473        let f = write_minimal_bal()?;
474        let ds = BalLoader::load(f.path())?;
475        let pt = &ds.points[0];
476        assert!((pt.position.x - 1.0).abs() < 1e-12);
477        assert!((pt.position.y - 2.0).abs() < 1e-12);
478        assert!((pt.position.z - 3.0).abs() < 1e-12);
479        Ok(())
480    }
481
482    #[test]
483    fn test_normalize_focal_length_negative_uses_default() -> TestResult {
484        let f = write_bal_with_focal(-100.0)?;
485        let ds = BalLoader::load(f.path())?;
486        assert!(
487            (ds.cameras[0].focal_length - DEFAULT_FOCAL_LENGTH).abs() < 1e-12,
488            "negative focal length should be replaced with DEFAULT_FOCAL_LENGTH"
489        );
490        Ok(())
491    }
492
493    #[test]
494    fn test_normalize_focal_length_zero_uses_default() -> TestResult {
495        let f = write_bal_with_focal(0.0)?;
496        let ds = BalLoader::load(f.path())?;
497        assert!(
498            (ds.cameras[0].focal_length - DEFAULT_FOCAL_LENGTH).abs() < 1e-12,
499            "zero focal length should be replaced with DEFAULT_FOCAL_LENGTH"
500        );
501        Ok(())
502    }
503
504    #[test]
505    fn test_normalize_focal_length_positive_preserved() -> TestResult {
506        let f = write_bal_with_focal(300.0)?;
507        let ds = BalLoader::load(f.path())?;
508        assert!(
509            (ds.cameras[0].focal_length - 300.0).abs() < 1e-12,
510            "positive focal length should be preserved"
511        );
512        Ok(())
513    }
514
515    #[test]
516    fn test_load_nonexistent_file() {
517        let result = BalLoader::load("/nonexistent/path/file.bal");
518        assert!(result.is_err(), "loading a missing file should return Err");
519    }
520
521    #[test]
522    fn test_load_empty_file() -> TestResult {
523        let f = NamedTempFile::new()?;
524        let result = BalLoader::load(f.path());
525        assert!(result.is_err(), "empty file should fail (missing header)");
526        Ok(())
527    }
528
529    #[test]
530    fn test_load_header_wrong_field_count() -> TestResult {
531        let mut f = NamedTempFile::new()?;
532        writeln!(f, "1 1")?; // only 2 fields, need 3
533        f.flush()?;
534        let result = BalLoader::load(f.path());
535        assert!(result.is_err(), "header with 2 fields should fail");
536        Ok(())
537    }
538
539    #[test]
540    fn test_load_header_invalid_number() -> TestResult {
541        let mut f = NamedTempFile::new()?;
542        writeln!(f, "1 abc 1")?;
543        f.flush()?;
544        let result = BalLoader::load(f.path());
545        assert!(result.is_err(), "non-numeric header field should fail");
546        Ok(())
547    }
548
549    #[test]
550    fn test_load_truncated_observations() -> TestResult {
551        let mut f = NamedTempFile::new()?;
552        writeln!(f, "1 1 2")?; // claims 2 observations
553        writeln!(f, "0 0 1.0 1.0")?; // only 1 provided
554        f.flush()?;
555        let result = BalLoader::load(f.path());
556        assert!(result.is_err(), "truncated observation block should fail");
557        Ok(())
558    }
559
560    #[test]
561    fn test_load_truncated_cameras() -> TestResult {
562        let mut f = NamedTempFile::new()?;
563        writeln!(f, "1 1 1")?;
564        writeln!(f, "0 0 1.0 1.0")?; // observation
565        // Only 5 of the 9 required camera params
566        for v in [0.0f64, 0.0, 0.0, 0.0, 0.0] {
567            writeln!(f, "{v}")?;
568        }
569        f.flush()?;
570        let result = BalLoader::load(f.path());
571        assert!(result.is_err(), "truncated camera block should fail");
572        Ok(())
573    }
574
575    #[test]
576    fn test_load_multiple_cameras_and_points() -> TestResult {
577        let mut f = NamedTempFile::new()?;
578        writeln!(f, "2 2 3")?; // 2 cameras, 2 points, 3 observations
579        writeln!(f, "0 0 1.0 1.0")?;
580        writeln!(f, "0 1 2.0 2.0")?;
581        writeln!(f, "1 0 3.0 3.0")?;
582        // Camera 0
583        for v in [0.0f64; 9] {
584            writeln!(f, "{v}")?;
585        }
586        // Camera 1
587        for _ in 0..8 {
588            writeln!(f, "0.0")?;
589        }
590        writeln!(f, "200.0")?; // focal_length = 200
591        // Point 0
592        for v in [1.0f64, 2.0, 3.0] {
593            writeln!(f, "{v}")?;
594        }
595        // Point 1
596        for v in [4.0f64, 5.0, 6.0] {
597            writeln!(f, "{v}")?;
598        }
599        f.flush()?;
600        let ds = BalLoader::load(f.path())?;
601        assert_eq!(ds.cameras.len(), 2);
602        assert_eq!(ds.points.len(), 2);
603        assert_eq!(ds.observations.len(), 3);
604        Ok(())
605    }
606
607    #[test]
608    fn test_load_observation_invalid_number() -> TestResult {
609        let mut f = NamedTempFile::new()?;
610        writeln!(f, "1 1 1")?;
611        writeln!(f, "0 0 bad_x 1.0")?; // bad x coordinate
612        f.flush()?;
613        let result = BalLoader::load(f.path());
614        assert!(
615            result.is_err(),
616            "invalid observation coordinate should fail"
617        );
618        Ok(())
619    }
620
621    // -------------------------------------------------------------------------
622    // parse_header additional error paths
623    // -------------------------------------------------------------------------
624
625    #[test]
626    fn test_load_header_invalid_num_cameras() -> TestResult {
627        let mut f = NamedTempFile::new()?;
628        writeln!(f, "bad 1 1")?;
629        f.flush()?;
630        let result = BalLoader::load(f.path());
631        assert!(
632            matches!(result, Err(IoError::InvalidNumber { .. })),
633            "invalid num_cameras should return InvalidNumber"
634        );
635        Ok(())
636    }
637
638    #[test]
639    fn test_load_header_invalid_num_observations() -> TestResult {
640        let mut f = NamedTempFile::new()?;
641        writeln!(f, "1 1 bad")?;
642        f.flush()?;
643        let result = BalLoader::load(f.path());
644        assert!(
645            matches!(result, Err(IoError::InvalidNumber { .. })),
646            "invalid num_observations should return InvalidNumber"
647        );
648        Ok(())
649    }
650
651    // -------------------------------------------------------------------------
652    // parse_observations additional error paths
653    // -------------------------------------------------------------------------
654
655    #[test]
656    fn test_load_observation_missing_fields() -> TestResult {
657        let mut f = NamedTempFile::new()?;
658        writeln!(f, "1 1 1")?;
659        writeln!(f, "0 1.0")?; // only 2 fields, need 4
660        f.flush()?;
661        let result = BalLoader::load(f.path());
662        assert!(
663            matches!(result, Err(IoError::MissingFields { .. })),
664            "observation with too few fields should return MissingFields"
665        );
666        Ok(())
667    }
668
669    #[test]
670    fn test_load_observation_invalid_camera_index() -> TestResult {
671        let mut f = NamedTempFile::new()?;
672        writeln!(f, "1 1 1")?;
673        writeln!(f, "bad 0 1.0 2.0")?;
674        f.flush()?;
675        let result = BalLoader::load(f.path());
676        assert!(
677            matches!(result, Err(IoError::InvalidNumber { .. })),
678            "invalid camera_index in observation should return InvalidNumber"
679        );
680        Ok(())
681    }
682
683    #[test]
684    fn test_load_observation_invalid_point_index() -> TestResult {
685        let mut f = NamedTempFile::new()?;
686        writeln!(f, "1 1 1")?;
687        writeln!(f, "0 bad 1.0 2.0")?;
688        f.flush()?;
689        let result = BalLoader::load(f.path());
690        assert!(
691            matches!(result, Err(IoError::InvalidNumber { .. })),
692            "invalid point_index in observation should return InvalidNumber"
693        );
694        Ok(())
695    }
696
697    #[test]
698    fn test_load_observation_invalid_y() -> TestResult {
699        let mut f = NamedTempFile::new()?;
700        writeln!(f, "1 1 1")?;
701        writeln!(f, "0 0 1.0 bad")?;
702        f.flush()?;
703        let result = BalLoader::load(f.path());
704        assert!(
705            matches!(result, Err(IoError::InvalidNumber { .. })),
706            "invalid y in observation should return InvalidNumber"
707        );
708        Ok(())
709    }
710
711    // -------------------------------------------------------------------------
712    // parse_cameras and parse_points additional error paths
713    // -------------------------------------------------------------------------
714
715    #[test]
716    fn test_load_camera_invalid_parameter() -> TestResult {
717        let mut f = NamedTempFile::new()?;
718        writeln!(f, "1 1 1")?;
719        writeln!(f, "0 0 1.0 1.0")?; // observation
720        writeln!(f, "bad")?; // invalid first camera parameter (rx)
721        f.flush()?;
722        let result = BalLoader::load(f.path());
723        assert!(
724            matches!(result, Err(IoError::InvalidNumber { .. })),
725            "invalid camera parameter should return InvalidNumber"
726        );
727        Ok(())
728    }
729
730    #[test]
731    fn test_load_truncated_points() -> TestResult {
732        let mut f = NamedTempFile::new()?;
733        writeln!(f, "1 1 1")?;
734        writeln!(f, "0 0 1.0 1.0")?; // observation
735        // Full camera block
736        for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0, 0.0, 0.0] {
737            writeln!(f, "{v}")?;
738        }
739        // Only 2 of 3 point coordinates
740        writeln!(f, "1.0")?;
741        writeln!(f, "2.0")?;
742        // missing z
743        f.flush()?;
744        let result = BalLoader::load(f.path());
745        assert!(result.is_err(), "truncated point block should fail");
746        Ok(())
747    }
748
749    #[test]
750    fn test_load_point_invalid_coordinate() -> TestResult {
751        let mut f = NamedTempFile::new()?;
752        writeln!(f, "1 1 1")?;
753        writeln!(f, "0 0 1.0 1.0")?; // observation
754        // Full camera block
755        for v in [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0, 500.0, 0.0, 0.0] {
756            writeln!(f, "{v}")?;
757        }
758        writeln!(f, "bad")?; // invalid point x
759        f.flush()?;
760        let result = BalLoader::load(f.path());
761        assert!(
762            matches!(result, Err(IoError::InvalidNumber { .. })),
763            "invalid point coordinate should return InvalidNumber"
764        );
765        Ok(())
766    }
767}