Skip to main content

apex_io/
g2o.rs

1use crate::{EdgeSE2, EdgeSE3, Graph, GraphLoader, IoError, VertexSE2, VertexSE3};
2use memmap2;
3use rayon::prelude::*;
4use std::collections::HashMap;
5use std::{fs::File, io::Write, path::Path};
6
7/// High-performance G2O file loader
8pub struct G2oLoader;
9
10impl GraphLoader for G2oLoader {
11    fn load<P: AsRef<Path>>(path: P) -> Result<Graph, IoError> {
12        let path_ref = path.as_ref();
13        let file = File::open(path_ref).map_err(|e| {
14            IoError::Io(e).log_with_source(format!("Failed to open G2O file: {:?}", path_ref))
15        })?;
16        // SAFETY: The file is opened read-only and the handle remains valid for the
17        // lifetime of `mmap`. No other thread modifies the file during this scope.
18        let mmap = unsafe {
19            memmap2::Mmap::map(&file).map_err(|e| {
20                IoError::Io(e)
21                    .log_with_source(format!("Failed to memory-map G2O file: {:?}", path_ref))
22            })?
23        };
24        let content = std::str::from_utf8(&mmap).map_err(|e| {
25            IoError::Parse {
26                line: 0,
27                message: format!("Invalid UTF-8: {e}"),
28            }
29            .log()
30        })?;
31
32        Self::parse_content(content)
33    }
34
35    fn write<P: AsRef<Path>>(graph: &Graph, path: P) -> Result<(), IoError> {
36        let path_ref = path.as_ref();
37        let mut file = File::create(path_ref).map_err(|e| {
38            IoError::Io(e).log_with_source(format!("Failed to create G2O file: {:?}", path_ref))
39        })?;
40
41        // Write header comment
42        writeln!(file, "# G2O file written by Apex Solver")
43            .map_err(|e| IoError::Io(e).log_with_source("Failed to write G2O header"))?;
44        writeln!(
45            file,
46            "# Timestamp: {}",
47            chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
48        )
49        .map_err(|e| IoError::Io(e).log_with_source("Failed to write G2O timestamp"))?;
50        writeln!(
51            file,
52            "# SE2 vertices: {}, SE3 vertices: {}, SE2 edges: {}, SE3 edges: {}",
53            graph.vertices_se2.len(),
54            graph.vertices_se3.len(),
55            graph.edges_se2.len(),
56            graph.edges_se3.len()
57        )
58        .map_err(|e| IoError::Io(e).log_with_source("Failed to write G2O statistics"))?;
59        writeln!(file)
60            .map_err(|e| IoError::Io(e).log_with_source("Failed to write G2O header newline"))?;
61
62        // Write SE2 vertices (sorted by ID)
63        let mut se2_ids: Vec<_> = graph.vertices_se2.keys().collect();
64        se2_ids.sort();
65
66        for id in se2_ids {
67            let vertex = &graph.vertices_se2[id];
68            writeln!(
69                file,
70                "VERTEX_SE2 {} {:.17e} {:.17e} {:.17e}",
71                vertex.id,
72                vertex.x(),
73                vertex.y(),
74                vertex.theta()
75            )
76            .map_err(|e| {
77                IoError::Io(e).log_with_source(format!("Failed to write SE2 vertex {}", vertex.id))
78            })?;
79        }
80
81        // Write SE3 vertices (sorted by ID)
82        let mut se3_ids: Vec<_> = graph.vertices_se3.keys().collect();
83        se3_ids.sort();
84
85        for id in se3_ids {
86            let vertex = &graph.vertices_se3[id];
87            let trans = vertex.translation();
88            let quat = vertex.rotation();
89            writeln!(
90                file,
91                "VERTEX_SE3:QUAT {} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e}",
92                vertex.id, trans.x, trans.y, trans.z, quat.i, quat.j, quat.k, quat.w
93            )
94            .map_err(|e| {
95                IoError::Io(e).log_with_source(format!("Failed to write SE3 vertex {}", vertex.id))
96            })?;
97        }
98
99        // Write SE2 edges
100        for edge in &graph.edges_se2 {
101            let meas = &edge.measurement;
102            let info = &edge.information;
103
104            // G2O SE2 information matrix order: i11, i12, i22, i33, i13, i23
105            writeln!(
106                file,
107                "EDGE_SE2 {} {} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e}",
108                edge.from,
109                edge.to,
110                meas.x(),
111                meas.y(),
112                meas.angle(),
113                info[(0, 0)],
114                info[(0, 1)],
115                info[(1, 1)],
116                info[(2, 2)],
117                info[(0, 2)],
118                info[(1, 2)]
119            )
120            .map_err(|e| {
121                IoError::Io(e).log_with_source(format!(
122                    "Failed to write SE2 edge {} -> {}",
123                    edge.from, edge.to
124                ))
125            })?;
126        }
127
128        // Write SE3 edges
129        for edge in &graph.edges_se3 {
130            let trans = edge.measurement.translation();
131            let quat = edge.measurement.rotation_quaternion();
132            let info = &edge.information;
133
134            // Write EDGE_SE3:QUAT with full 6x6 upper triangular information matrix (21 values)
135            write!(
136                file,
137                "EDGE_SE3:QUAT {} {} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e} {:.17e}",
138                edge.from, edge.to, trans.x, trans.y, trans.z, quat.i, quat.j, quat.k, quat.w
139            )
140            .map_err(|e| {
141                IoError::Io(e).log_with_source(format!(
142                    "Failed to write SE3 edge {} -> {}",
143                    edge.from, edge.to
144                ))
145            })?;
146
147            // Write upper triangular information matrix (21 values)
148            for i in 0..6 {
149                for j in i..6 {
150                    write!(file, " {:.17e}", info[(i, j)]).map_err(|e| {
151                        IoError::Io(e).log_with_source(format!(
152                            "Failed to write SE3 edge {} -> {} information matrix",
153                            edge.from, edge.to
154                        ))
155                    })?;
156                }
157            }
158            writeln!(file).map_err(|e| {
159                IoError::Io(e).log_with_source(format!(
160                    "Failed to write SE3 edge {} -> {} newline",
161                    edge.from, edge.to
162                ))
163            })?;
164        }
165
166        Ok(())
167    }
168}
169
170impl G2oLoader {
171    /// Parse G2O content with performance optimizations
172    fn parse_content(content: &str) -> Result<Graph, IoError> {
173        let lines: Vec<&str> = content.lines().collect();
174        let minimum_lines_for_parallel = 1000;
175
176        // Pre-allocate collections based on estimated size
177        let estimated_vertices = lines.len() / 4;
178        let estimated_edges = estimated_vertices * 3;
179        let mut graph = Graph {
180            vertices_se2: HashMap::with_capacity(estimated_vertices),
181            vertices_se3: HashMap::with_capacity(estimated_vertices),
182            edges_se2: Vec::with_capacity(estimated_edges),
183            edges_se3: Vec::with_capacity(estimated_edges),
184        };
185
186        // For large files, use parallel processing
187        if lines.len() > minimum_lines_for_parallel {
188            Self::parse_parallel(&lines, &mut graph)?;
189        } else {
190            Self::parse_sequential(&lines, &mut graph)?;
191        }
192
193        Ok(graph)
194    }
195
196    /// Sequential parsing for smaller files
197    fn parse_sequential(lines: &[&str], graph: &mut Graph) -> Result<(), IoError> {
198        for (line_num, line) in lines.iter().enumerate() {
199            Self::parse_line(line, line_num + 1, graph)?;
200        }
201        Ok(())
202    }
203
204    /// Parallel parsing for larger files
205    fn parse_parallel(lines: &[&str], graph: &mut Graph) -> Result<(), IoError> {
206        // Collect parse results in parallel
207        let results: Result<Vec<_>, IoError> = lines
208            .par_iter()
209            .enumerate()
210            .map(|(line_num, line)| Self::parse_line_to_enum(line, line_num + 1))
211            .collect();
212
213        let parsed_items = results?;
214
215        // Sequential insertion to avoid concurrent modification
216        for item in parsed_items.into_iter().flatten() {
217            match item {
218                ParsedItem::VertexSE2(vertex) => {
219                    let id = vertex.id;
220                    if graph.vertices_se2.insert(id, vertex).is_some() {
221                        return Err(IoError::DuplicateVertex { id });
222                    }
223                }
224                ParsedItem::VertexSE3(vertex) => {
225                    let id = vertex.id;
226                    if graph.vertices_se3.insert(id, vertex).is_some() {
227                        return Err(IoError::DuplicateVertex { id });
228                    }
229                }
230                ParsedItem::EdgeSE2(edge) => {
231                    graph.edges_se2.push(edge);
232                }
233                ParsedItem::EdgeSE3(edge) => {
234                    graph.edges_se3.push(*edge);
235                }
236            }
237        }
238
239        Ok(())
240    }
241
242    /// Parse a single line (for sequential processing)
243    fn parse_line(line: &str, line_num: usize, graph: &mut Graph) -> Result<(), IoError> {
244        let line = line.trim();
245
246        // Skip empty lines and comments
247        if line.is_empty() || line.starts_with('#') {
248            return Ok(());
249        }
250
251        let parts: Vec<&str> = line.split_whitespace().collect();
252        if parts.is_empty() {
253            return Ok(());
254        }
255
256        match parts[0] {
257            "VERTEX_SE2" => {
258                let vertex = Self::parse_vertex_se2(&parts, line_num)?;
259                let id = vertex.id;
260                if graph.vertices_se2.insert(id, vertex).is_some() {
261                    return Err(IoError::DuplicateVertex { id });
262                }
263            }
264            "VERTEX_SE3:QUAT" => {
265                let vertex = Self::parse_vertex_se3(&parts, line_num)?;
266                let id = vertex.id;
267                if graph.vertices_se3.insert(id, vertex).is_some() {
268                    return Err(IoError::DuplicateVertex { id });
269                }
270            }
271            "EDGE_SE2" => {
272                let edge = Self::parse_edge_se2(&parts, line_num)?;
273                graph.edges_se2.push(edge);
274            }
275            "EDGE_SE3:QUAT" => {
276                let edge = Self::parse_edge_se3(&parts, line_num)?;
277                graph.edges_se3.push(edge);
278            }
279            _ => {
280                // Skip unknown types silently for compatibility
281            }
282        }
283
284        Ok(())
285    }
286
287    /// Parse a single line to enum (for parallel processing)
288    fn parse_line_to_enum(line: &str, line_num: usize) -> Result<Option<ParsedItem>, IoError> {
289        let line = line.trim();
290
291        // Skip empty lines and comments
292        if line.is_empty() || line.starts_with('#') {
293            return Ok(None);
294        }
295
296        let parts: Vec<&str> = line.split_whitespace().collect();
297        if parts.is_empty() {
298            return Ok(None);
299        }
300
301        let item = match parts[0] {
302            "VERTEX_SE2" => Some(ParsedItem::VertexSE2(Self::parse_vertex_se2(
303                &parts, line_num,
304            )?)),
305            "VERTEX_SE3:QUAT" => Some(ParsedItem::VertexSE3(Self::parse_vertex_se3(
306                &parts, line_num,
307            )?)),
308            "EDGE_SE2" => Some(ParsedItem::EdgeSE2(Self::parse_edge_se2(&parts, line_num)?)),
309            "EDGE_SE3:QUAT" => Some(ParsedItem::EdgeSE3(Box::new(Self::parse_edge_se3(
310                &parts, line_num,
311            )?))),
312            _ => None, // Skip unknown types
313        };
314
315        Ok(item)
316    }
317
318    /// Parse VERTEX_SE2 line
319    pub fn parse_vertex_se2(parts: &[&str], line_num: usize) -> Result<VertexSE2, IoError> {
320        if parts.len() < 5 {
321            return Err(IoError::MissingFields { line: line_num });
322        }
323
324        let id = parts[1]
325            .parse::<usize>()
326            .map_err(|_| IoError::InvalidNumber {
327                line: line_num,
328                value: parts[1].to_string(),
329            })?;
330
331        let x = parts[2]
332            .parse::<f64>()
333            .map_err(|_| IoError::InvalidNumber {
334                line: line_num,
335                value: parts[2].to_string(),
336            })?;
337
338        let y = parts[3]
339            .parse::<f64>()
340            .map_err(|_| IoError::InvalidNumber {
341                line: line_num,
342                value: parts[3].to_string(),
343            })?;
344
345        let theta = parts[4]
346            .parse::<f64>()
347            .map_err(|_| IoError::InvalidNumber {
348                line: line_num,
349                value: parts[4].to_string(),
350            })?;
351
352        Ok(VertexSE2::new(id, x, y, theta))
353    }
354
355    /// Parse VERTEX_SE3:QUAT line
356    pub fn parse_vertex_se3(parts: &[&str], line_num: usize) -> Result<VertexSE3, IoError> {
357        if parts.len() < 9 {
358            return Err(IoError::MissingFields { line: line_num });
359        }
360
361        let id = parts[1]
362            .parse::<usize>()
363            .map_err(|_| IoError::InvalidNumber {
364                line: line_num,
365                value: parts[1].to_string(),
366            })?;
367
368        let x = parts[2]
369            .parse::<f64>()
370            .map_err(|_| IoError::InvalidNumber {
371                line: line_num,
372                value: parts[2].to_string(),
373            })?;
374
375        let y = parts[3]
376            .parse::<f64>()
377            .map_err(|_| IoError::InvalidNumber {
378                line: line_num,
379                value: parts[3].to_string(),
380            })?;
381
382        let z = parts[4]
383            .parse::<f64>()
384            .map_err(|_| IoError::InvalidNumber {
385                line: line_num,
386                value: parts[4].to_string(),
387            })?;
388
389        let qx = parts[5]
390            .parse::<f64>()
391            .map_err(|_| IoError::InvalidNumber {
392                line: line_num,
393                value: parts[5].to_string(),
394            })?;
395
396        let qy = parts[6]
397            .parse::<f64>()
398            .map_err(|_| IoError::InvalidNumber {
399                line: line_num,
400                value: parts[6].to_string(),
401            })?;
402
403        let qz = parts[7]
404            .parse::<f64>()
405            .map_err(|_| IoError::InvalidNumber {
406                line: line_num,
407                value: parts[7].to_string(),
408            })?;
409
410        let qw = parts[8]
411            .parse::<f64>()
412            .map_err(|_| IoError::InvalidNumber {
413                line: line_num,
414                value: parts[8].to_string(),
415            })?;
416
417        let translation = nalgebra::Vector3::new(x, y, z);
418        let quaternion = nalgebra::Quaternion::new(qw, qx, qy, qz);
419
420        // Validate quaternion normalization
421        let quat_norm = (qw * qw + qx * qx + qy * qy + qz * qz).sqrt();
422        if (quat_norm - 1.0).abs() > 0.01 {
423            return Err(IoError::InvalidQuaternion {
424                line: line_num,
425                norm: quat_norm,
426            });
427        }
428
429        // Always normalize for numerical safety
430        let quaternion = quaternion.normalize();
431
432        Ok(VertexSE3::from_translation_quaternion(
433            id,
434            translation,
435            quaternion,
436        ))
437    }
438
439    /// Parse EDGE_SE2 line
440    fn parse_edge_se2(parts: &[&str], line_num: usize) -> Result<EdgeSE2, IoError> {
441        if parts.len() < 12 {
442            return Err(IoError::MissingFields { line: line_num });
443        }
444
445        let from = parts[1]
446            .parse::<usize>()
447            .map_err(|_| IoError::InvalidNumber {
448                line: line_num,
449                value: parts[1].to_string(),
450            })?;
451
452        let to = parts[2]
453            .parse::<usize>()
454            .map_err(|_| IoError::InvalidNumber {
455                line: line_num,
456                value: parts[2].to_string(),
457            })?;
458
459        // Parse measurement (dx, dy, dtheta)
460        let dx = parts[3]
461            .parse::<f64>()
462            .map_err(|_| IoError::InvalidNumber {
463                line: line_num,
464                value: parts[3].to_string(),
465            })?;
466        let dy = parts[4]
467            .parse::<f64>()
468            .map_err(|_| IoError::InvalidNumber {
469                line: line_num,
470                value: parts[4].to_string(),
471            })?;
472        let dtheta = parts[5]
473            .parse::<f64>()
474            .map_err(|_| IoError::InvalidNumber {
475                line: line_num,
476                value: parts[5].to_string(),
477            })?;
478
479        // Parse information matrix (upper triangular: i11, i12, i13, i22, i23, i33)
480        let info_values: Result<Vec<f64>, _> =
481            parts[6..12].iter().map(|s| s.parse::<f64>()).collect();
482
483        let info_values = info_values.map_err(|_| IoError::Parse {
484            line: line_num,
485            message: "Invalid information matrix values".to_string(),
486        })?;
487
488        let information = nalgebra::Matrix3::new(
489            info_values[0],
490            info_values[1],
491            info_values[2],
492            info_values[1],
493            info_values[3],
494            info_values[4],
495            info_values[2],
496            info_values[4],
497            info_values[5],
498        );
499
500        Ok(EdgeSE2::new(from, to, dx, dy, dtheta, information))
501    }
502
503    /// Parse EDGE_SE3:QUAT line (placeholder implementation)
504    fn parse_edge_se3(parts: &[&str], line_num: usize) -> Result<EdgeSE3, IoError> {
505        // EDGE_SE3:QUAT from_id to_id tx ty tz qx qy qz qw [information matrix values]
506        if parts.len() < 10 {
507            return Err(IoError::MissingFields { line: line_num });
508        }
509
510        // Parse vertex IDs
511        let from = parts[1]
512            .parse::<usize>()
513            .map_err(|_| IoError::InvalidNumber {
514                line: line_num,
515                value: parts[1].to_string(),
516            })?;
517
518        let to = parts[2]
519            .parse::<usize>()
520            .map_err(|_| IoError::InvalidNumber {
521                line: line_num,
522                value: parts[2].to_string(),
523            })?;
524
525        // Parse translation (tx, ty, tz)
526        let tx = parts[3]
527            .parse::<f64>()
528            .map_err(|_| IoError::InvalidNumber {
529                line: line_num,
530                value: parts[3].to_string(),
531            })?;
532
533        let ty = parts[4]
534            .parse::<f64>()
535            .map_err(|_| IoError::InvalidNumber {
536                line: line_num,
537                value: parts[4].to_string(),
538            })?;
539
540        let tz = parts[5]
541            .parse::<f64>()
542            .map_err(|_| IoError::InvalidNumber {
543                line: line_num,
544                value: parts[5].to_string(),
545            })?;
546
547        let translation = nalgebra::Vector3::new(tx, ty, tz);
548
549        // Parse rotation quaternion (qx, qy, qz, qw)
550        let qx = parts[6]
551            .parse::<f64>()
552            .map_err(|_| IoError::InvalidNumber {
553                line: line_num,
554                value: parts[6].to_string(),
555            })?;
556
557        let qy = parts[7]
558            .parse::<f64>()
559            .map_err(|_| IoError::InvalidNumber {
560                line: line_num,
561                value: parts[7].to_string(),
562            })?;
563
564        let qz = parts[8]
565            .parse::<f64>()
566            .map_err(|_| IoError::InvalidNumber {
567                line: line_num,
568                value: parts[8].to_string(),
569            })?;
570
571        let qw = parts[9]
572            .parse::<f64>()
573            .map_err(|_| IoError::InvalidNumber {
574                line: line_num,
575                value: parts[9].to_string(),
576            })?;
577
578        let rotation =
579            nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(qw, qx, qy, qz));
580
581        // Parse information matrix (upper triangular: i11, i12, i13, i14, i15, i16, i22, i23, i24, i25, i26, i33, i34, i35, i36, i44, i45, i46, i55, i56, i66)
582        let info_values: Result<Vec<f64>, _> =
583            parts[10..31].iter().map(|s| s.parse::<f64>()).collect();
584
585        let info_values = info_values.map_err(|_| IoError::Parse {
586            line: line_num,
587            message: "Invalid information matrix values".to_string(),
588        })?;
589
590        let information = nalgebra::Matrix6::new(
591            info_values[0],
592            info_values[1],
593            info_values[2],
594            info_values[3],
595            info_values[4],
596            info_values[5],
597            info_values[1],
598            info_values[6],
599            info_values[7],
600            info_values[8],
601            info_values[9],
602            info_values[10],
603            info_values[2],
604            info_values[7],
605            info_values[11],
606            info_values[12],
607            info_values[13],
608            info_values[14],
609            info_values[3],
610            info_values[8],
611            info_values[12],
612            info_values[15],
613            info_values[16],
614            info_values[17],
615            info_values[4],
616            info_values[9],
617            info_values[13],
618            info_values[16],
619            info_values[18],
620            info_values[19],
621            info_values[5],
622            info_values[10],
623            info_values[14],
624            info_values[17],
625            info_values[19],
626            info_values[20],
627        );
628
629        Ok(EdgeSE3::new(from, to, translation, rotation, information))
630    }
631}
632
633/// Enum for parsed items (used in parallel processing)
634enum ParsedItem {
635    VertexSE2(VertexSE2),
636    VertexSE3(VertexSE3),
637    EdgeSE2(EdgeSE2),
638    EdgeSE3(Box<EdgeSE3>),
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use nalgebra::{Matrix3, Matrix6, UnitQuaternion, Vector3};
645    use std::io::Write;
646    use tempfile::NamedTempFile;
647
648    type TestResult = Result<(), Box<dyn std::error::Error>>;
649
650    #[test]
651    fn test_parse_vertex_se2() -> TestResult {
652        let parts = vec!["VERTEX_SE2", "0", "1.0", "2.0", "0.5"];
653        let vertex = G2oLoader::parse_vertex_se2(&parts, 1)?;
654
655        assert_eq!(vertex.id(), 0);
656        assert_eq!(vertex.x(), 1.0);
657        assert_eq!(vertex.y(), 2.0);
658        assert_eq!(vertex.theta(), 0.5);
659
660        Ok(())
661    }
662
663    #[test]
664    fn test_parse_vertex_se3() -> TestResult {
665        let parts = vec![
666            "VERTEX_SE3:QUAT",
667            "1",
668            "1.0",
669            "2.0",
670            "3.0",
671            "0.0",
672            "0.0",
673            "0.0",
674            "1.0",
675        ];
676        let vertex = G2oLoader::parse_vertex_se3(&parts, 1)?;
677
678        assert_eq!(vertex.id(), 1);
679        assert_eq!(vertex.translation(), nalgebra::Vector3::new(1.0, 2.0, 3.0));
680        assert!(vertex.rotation().quaternion().w > 0.99); // Should be identity quaternion
681
682        Ok(())
683    }
684
685    #[test]
686    fn test_error_handling() {
687        // Test invalid number
688        let parts = vec!["VERTEX_SE2", "invalid", "1.0", "2.0", "0.5"];
689        let result = G2oLoader::parse_vertex_se2(&parts, 1);
690        assert!(matches!(result, Err(IoError::InvalidNumber { .. })));
691
692        // Test missing fields
693        let parts = vec!["VERTEX_SE2", "0"];
694        let result = G2oLoader::parse_vertex_se2(&parts, 1);
695        assert!(matches!(result, Err(IoError::MissingFields { .. })));
696    }
697
698    #[test]
699    fn test_write_se2_graph_round_trip() -> TestResult {
700        let mut graph = Graph::new();
701        graph
702            .vertices_se2
703            .insert(0, VertexSE2::new(0, 1.0, 2.0, 0.5));
704        graph
705            .vertices_se2
706            .insert(1, VertexSE2::new(1, 3.0, 4.0, 1.0));
707        let info = Matrix3::new(500.0, 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, 0.0, 200.0);
708        graph
709            .edges_se2
710            .push(EdgeSE2::new(0, 1, 0.5, 0.3, 0.1, info));
711
712        let f = NamedTempFile::new()?;
713        G2oLoader::write(&graph, f.path())?;
714        let loaded = G2oLoader::load(f.path())?;
715
716        assert_eq!(loaded.vertices_se2.len(), 2);
717        assert_eq!(loaded.edges_se2.len(), 1);
718        let v0 = &loaded.vertices_se2[&0];
719        assert!((v0.x() - 1.0).abs() < 1e-10);
720        assert!((v0.y() - 2.0).abs() < 1e-10);
721        let e = &loaded.edges_se2[0];
722        assert_eq!(e.from, 0);
723        assert_eq!(e.to, 1);
724        assert!((e.information[(0, 0)] - 500.0).abs() < 1e-10);
725        Ok(())
726    }
727
728    #[test]
729    fn test_write_se3_graph_round_trip() -> TestResult {
730        let trans = Vector3::new(1.0, 2.0, 3.0);
731        let rot = UnitQuaternion::identity();
732        let mut graph = Graph::new();
733        graph.vertices_se3.insert(0, VertexSE3::new(0, trans, rot));
734        graph
735            .vertices_se3
736            .insert(1, VertexSE3::new(1, Vector3::zeros(), rot));
737        graph
738            .edges_se3
739            .push(EdgeSE3::new(0, 1, trans, rot, Matrix6::identity()));
740
741        let f = NamedTempFile::new()?;
742        G2oLoader::write(&graph, f.path())?;
743        let loaded = G2oLoader::load(f.path())?;
744
745        assert_eq!(loaded.vertices_se3.len(), 2);
746        assert_eq!(loaded.edges_se3.len(), 1);
747        let v0 = &loaded.vertices_se3[&0];
748        assert!((v0.x() - 1.0).abs() < 1e-10);
749        assert!((v0.y() - 2.0).abs() < 1e-10);
750        assert!((v0.z() - 3.0).abs() < 1e-10);
751        let e = &loaded.edges_se3[0];
752        assert!((e.information[(0, 0)] - 1.0).abs() < 1e-10);
753        Ok(())
754    }
755
756    #[test]
757    fn test_write_mixed_graph_round_trip() -> TestResult {
758        let mut graph = Graph::new();
759        graph
760            .vertices_se2
761            .insert(0, VertexSE2::new(0, 1.0, 0.0, 0.0));
762        graph.vertices_se3.insert(
763            1,
764            VertexSE3::new(1, Vector3::new(0.0, 0.0, 1.0), UnitQuaternion::identity()),
765        );
766
767        let f = NamedTempFile::new()?;
768        G2oLoader::write(&graph, f.path())?;
769        let loaded = G2oLoader::load(f.path())?;
770
771        assert_eq!(loaded.vertices_se2.len(), 1);
772        assert_eq!(loaded.vertices_se3.len(), 1);
773        Ok(())
774    }
775
776    #[test]
777    fn test_write_empty_graph() -> TestResult {
778        let graph = Graph::new();
779        let f = NamedTempFile::new()?;
780        G2oLoader::write(&graph, f.path())?;
781        let loaded = G2oLoader::load(f.path())?;
782        assert_eq!(loaded.vertices_se2.len(), 0);
783        assert_eq!(loaded.vertices_se3.len(), 0);
784        assert_eq!(loaded.edges_se2.len(), 0);
785        assert_eq!(loaded.edges_se3.len(), 0);
786        Ok(())
787    }
788
789    #[test]
790    fn test_load_nonexistent_file() {
791        let result = G2oLoader::load("/nonexistent/path/file.g2o");
792        assert!(result.is_err(), "loading a missing file should return Err");
793    }
794
795    #[test]
796    fn test_parse_vertex_se3_invalid_quaternion_norm() -> TestResult {
797        // qw=0.1 gives norm ≈ 0.1, which is far from 1.0 (threshold: |norm-1| > 0.01)
798        let mut f = NamedTempFile::new()?;
799        writeln!(f, "VERTEX_SE3:QUAT 0 0.0 0.0 0.0 0.0 0.0 0.0 0.1")?;
800        f.flush()?;
801        let result = G2oLoader::load(f.path());
802        assert!(
803            matches!(result, Err(IoError::InvalidQuaternion { .. })),
804            "far-from-unit quaternion should return InvalidQuaternion"
805        );
806        Ok(())
807    }
808
809    #[test]
810    fn test_parse_edge_se2_information_matrix() -> TestResult {
811        // parse_edge_se2 reads info values as: i11, i12, i13, i22, i23, i33
812        let mut f = NamedTempFile::new()?;
813        writeln!(f, "VERTEX_SE2 0 0.0 0.0 0.0")?;
814        writeln!(f, "VERTEX_SE2 1 1.0 0.0 0.0")?;
815        writeln!(f, "EDGE_SE2 0 1 1.0 0.0 0.0 500.0 0.0 0.0 300.0 0.0 200.0")?;
816        f.flush()?;
817        let graph = G2oLoader::load(f.path())?;
818        assert_eq!(graph.edges_se2.len(), 1);
819        let e = &graph.edges_se2[0];
820        assert_eq!(e.from, 0);
821        assert_eq!(e.to, 1);
822        assert!(
823            (e.information[(0, 0)] - 500.0).abs() < 1e-10,
824            "i11={}",
825            e.information[(0, 0)]
826        );
827        assert!(
828            (e.information[(1, 1)] - 300.0).abs() < 1e-10,
829            "i22={}",
830            e.information[(1, 1)]
831        );
832        assert!(
833            (e.information[(2, 2)] - 200.0).abs() < 1e-10,
834            "i33={}",
835            e.information[(2, 2)]
836        );
837        Ok(())
838    }
839
840    #[test]
841    fn test_parse_edge_se3_information_matrix() -> TestResult {
842        // EDGE_SE3:QUAT: from to tx ty tz qx qy qz qw + 21 upper-triangular values
843        let mut f = NamedTempFile::new()?;
844        writeln!(f, "VERTEX_SE3:QUAT 0 0.0 0.0 0.0 0.0 0.0 0.0 1.0")?;
845        writeln!(f, "VERTEX_SE3:QUAT 1 1.0 0.0 0.0 0.0 0.0 0.0 1.0")?;
846        // Information matrix: identity (diagonal 1.0, off-diagonal 0.0), upper triangular = 21 values
847        let info_vals = "100.0 0.0 0.0 0.0 0.0 0.0 100.0 0.0 0.0 0.0 0.0 100.0 0.0 0.0 0.0 100.0 0.0 0.0 100.0 0.0 100.0";
848        writeln!(
849            f,
850            "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 0.0 0.0 1.0 {}",
851            info_vals
852        )?;
853        f.flush()?;
854        let graph = G2oLoader::load(f.path())?;
855        assert_eq!(graph.edges_se3.len(), 1);
856        let e = &graph.edges_se3[0];
857        assert!((e.information[(0, 0)] - 100.0).abs() < 1e-10);
858        assert!((e.information[(1, 1)] - 100.0).abs() < 1e-10);
859        Ok(())
860    }
861
862    // -------------------------------------------------------------------------
863    // parse_vertex_se2 error paths
864    // -------------------------------------------------------------------------
865
866    #[test]
867    fn test_parse_vertex_se2_invalid_x() -> TestResult {
868        let mut f = NamedTempFile::new()?;
869        writeln!(f, "VERTEX_SE2 0 bad 2.0 0.5")?;
870        f.flush()?;
871        let result = G2oLoader::load(f.path());
872        assert!(
873            matches!(result, Err(IoError::InvalidNumber { .. })),
874            "invalid x in VERTEX_SE2 should return InvalidNumber"
875        );
876        Ok(())
877    }
878
879    #[test]
880    fn test_parse_vertex_se2_invalid_y() -> TestResult {
881        let mut f = NamedTempFile::new()?;
882        writeln!(f, "VERTEX_SE2 0 1.0 bad 0.5")?;
883        f.flush()?;
884        let result = G2oLoader::load(f.path());
885        assert!(
886            matches!(result, Err(IoError::InvalidNumber { .. })),
887            "invalid y in VERTEX_SE2 should return InvalidNumber"
888        );
889        Ok(())
890    }
891
892    #[test]
893    fn test_parse_vertex_se2_invalid_theta() -> TestResult {
894        let mut f = NamedTempFile::new()?;
895        writeln!(f, "VERTEX_SE2 0 1.0 2.0 bad")?;
896        f.flush()?;
897        let result = G2oLoader::load(f.path());
898        assert!(
899            matches!(result, Err(IoError::InvalidNumber { .. })),
900            "invalid theta in VERTEX_SE2 should return InvalidNumber"
901        );
902        Ok(())
903    }
904
905    #[test]
906    fn test_parse_vertex_se2_missing_fields() -> TestResult {
907        let mut f = NamedTempFile::new()?;
908        writeln!(f, "VERTEX_SE2 0 1.0")?; // only 3 parts (needs 5)
909        f.flush()?;
910        let result = G2oLoader::load(f.path());
911        assert!(
912            matches!(result, Err(IoError::MissingFields { .. })),
913            "VERTEX_SE2 with too few fields should return MissingFields"
914        );
915        Ok(())
916    }
917
918    #[test]
919    fn test_parse_duplicate_vertex_se2() -> TestResult {
920        let mut f = NamedTempFile::new()?;
921        writeln!(f, "VERTEX_SE2 3 1.0 2.0 0.0")?;
922        writeln!(f, "VERTEX_SE2 3 3.0 4.0 0.0")?;
923        f.flush()?;
924        let result = G2oLoader::load(f.path());
925        assert!(
926            matches!(result, Err(IoError::DuplicateVertex { id: 3 })),
927            "duplicate VERTEX_SE2 ID should return DuplicateVertex"
928        );
929        Ok(())
930    }
931
932    // -------------------------------------------------------------------------
933    // parse_vertex_se3 error paths
934    // -------------------------------------------------------------------------
935
936    #[test]
937    fn test_parse_vertex_se3_missing_fields() -> TestResult {
938        let mut f = NamedTempFile::new()?;
939        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0")?; // only 4 parts (needs 9)
940        f.flush()?;
941        let result = G2oLoader::load(f.path());
942        assert!(
943            matches!(result, Err(IoError::MissingFields { .. })),
944            "VERTEX_SE3:QUAT with too few fields should return MissingFields"
945        );
946        Ok(())
947    }
948
949    #[test]
950    fn test_parse_vertex_se3_invalid_translation() -> TestResult {
951        let mut f = NamedTempFile::new()?;
952        writeln!(f, "VERTEX_SE3:QUAT 0 bad 2.0 3.0 0.0 0.0 0.0 1.0")?;
953        f.flush()?;
954        let result = G2oLoader::load(f.path());
955        assert!(
956            matches!(result, Err(IoError::InvalidNumber { .. })),
957            "invalid translation in VERTEX_SE3:QUAT should return InvalidNumber"
958        );
959        Ok(())
960    }
961
962    #[test]
963    fn test_parse_vertex_se3_invalid_quaternion_field() -> TestResult {
964        let mut f = NamedTempFile::new()?;
965        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0 3.0 bad 0.0 0.0 1.0")?;
966        f.flush()?;
967        let result = G2oLoader::load(f.path());
968        assert!(
969            matches!(result, Err(IoError::InvalidNumber { .. })),
970            "invalid quaternion field should return InvalidNumber"
971        );
972        Ok(())
973    }
974
975    #[test]
976    fn test_parse_duplicate_vertex_se3() -> TestResult {
977        let mut f = NamedTempFile::new()?;
978        writeln!(f, "VERTEX_SE3:QUAT 7 1.0 0.0 0.0 0.0 0.0 0.0 1.0")?;
979        writeln!(f, "VERTEX_SE3:QUAT 7 2.0 0.0 0.0 0.0 0.0 0.0 1.0")?;
980        f.flush()?;
981        let result = G2oLoader::load(f.path());
982        assert!(
983            matches!(result, Err(IoError::DuplicateVertex { id: 7 })),
984            "duplicate VERTEX_SE3:QUAT ID should return DuplicateVertex"
985        );
986        Ok(())
987    }
988
989    #[test]
990    fn test_parse_vertex_se3_invalid_id() -> TestResult {
991        let mut f = NamedTempFile::new()?;
992        writeln!(f, "VERTEX_SE3:QUAT bad 1.0 2.0 3.0 0.0 0.0 0.0 1.0")?;
993        f.flush()?;
994        let result = G2oLoader::load(f.path());
995        assert!(
996            matches!(result, Err(IoError::InvalidNumber { .. })),
997            "invalid id in VERTEX_SE3:QUAT should return InvalidNumber"
998        );
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn test_parse_vertex_se3_invalid_y() -> TestResult {
1004        let mut f = NamedTempFile::new()?;
1005        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 bad 3.0 0.0 0.0 0.0 1.0")?;
1006        f.flush()?;
1007        let result = G2oLoader::load(f.path());
1008        assert!(
1009            matches!(result, Err(IoError::InvalidNumber { .. })),
1010            "invalid y in VERTEX_SE3:QUAT should return InvalidNumber"
1011        );
1012        Ok(())
1013    }
1014
1015    #[test]
1016    fn test_parse_vertex_se3_invalid_z() -> TestResult {
1017        let mut f = NamedTempFile::new()?;
1018        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0 bad 0.0 0.0 0.0 1.0")?;
1019        f.flush()?;
1020        let result = G2oLoader::load(f.path());
1021        assert!(
1022            matches!(result, Err(IoError::InvalidNumber { .. })),
1023            "invalid z in VERTEX_SE3:QUAT should return InvalidNumber"
1024        );
1025        Ok(())
1026    }
1027
1028    #[test]
1029    fn test_parse_vertex_se3_invalid_qy() -> TestResult {
1030        let mut f = NamedTempFile::new()?;
1031        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0 3.0 0.0 bad 0.0 1.0")?;
1032        f.flush()?;
1033        let result = G2oLoader::load(f.path());
1034        assert!(
1035            matches!(result, Err(IoError::InvalidNumber { .. })),
1036            "invalid qy in VERTEX_SE3:QUAT should return InvalidNumber"
1037        );
1038        Ok(())
1039    }
1040
1041    #[test]
1042    fn test_parse_vertex_se3_invalid_qz() -> TestResult {
1043        let mut f = NamedTempFile::new()?;
1044        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0 3.0 0.0 0.0 bad 1.0")?;
1045        f.flush()?;
1046        let result = G2oLoader::load(f.path());
1047        assert!(
1048            matches!(result, Err(IoError::InvalidNumber { .. })),
1049            "invalid qz in VERTEX_SE3:QUAT should return InvalidNumber"
1050        );
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_parse_vertex_se3_invalid_qw() -> TestResult {
1056        let mut f = NamedTempFile::new()?;
1057        writeln!(f, "VERTEX_SE3:QUAT 0 1.0 2.0 3.0 0.0 0.0 0.0 bad")?;
1058        f.flush()?;
1059        let result = G2oLoader::load(f.path());
1060        assert!(
1061            matches!(result, Err(IoError::InvalidNumber { .. })),
1062            "invalid qw in VERTEX_SE3:QUAT should return InvalidNumber"
1063        );
1064        Ok(())
1065    }
1066
1067    // -------------------------------------------------------------------------
1068    // parse_edge_se2 error paths
1069    // -------------------------------------------------------------------------
1070
1071    #[test]
1072    fn test_parse_edge_se2_missing_fields() -> TestResult {
1073        let mut f = NamedTempFile::new()?;
1074        writeln!(f, "EDGE_SE2 0 1 1.0 0.0")?; // only 5 parts (needs 12)
1075        f.flush()?;
1076        let result = G2oLoader::load(f.path());
1077        assert!(
1078            matches!(result, Err(IoError::MissingFields { .. })),
1079            "EDGE_SE2 with too few fields should return MissingFields"
1080        );
1081        Ok(())
1082    }
1083
1084    #[test]
1085    fn test_parse_edge_se2_invalid_from_id() -> TestResult {
1086        let mut f = NamedTempFile::new()?;
1087        writeln!(
1088            f,
1089            "EDGE_SE2 bad 1 1.0 0.0 0.0 500.0 0.0 0.0 500.0 0.0 200.0"
1090        )?;
1091        f.flush()?;
1092        let result = G2oLoader::load(f.path());
1093        assert!(
1094            matches!(result, Err(IoError::InvalidNumber { .. })),
1095            "invalid from-ID in EDGE_SE2 should return InvalidNumber"
1096        );
1097        Ok(())
1098    }
1099
1100    #[test]
1101    fn test_parse_edge_se2_invalid_measurement() -> TestResult {
1102        let mut f = NamedTempFile::new()?;
1103        writeln!(f, "EDGE_SE2 0 1 bad 0.0 0.0 500.0 0.0 0.0 500.0 0.0 200.0")?;
1104        f.flush()?;
1105        let result = G2oLoader::load(f.path());
1106        assert!(
1107            result.is_err(),
1108            "invalid measurement in EDGE_SE2 should return error"
1109        );
1110        Ok(())
1111    }
1112
1113    #[test]
1114    fn test_parse_edge_se2_invalid_to_id() -> TestResult {
1115        let mut f = NamedTempFile::new()?;
1116        writeln!(
1117            f,
1118            "EDGE_SE2 0 bad 1.0 0.0 0.0 500.0 0.0 0.0 500.0 0.0 200.0"
1119        )?;
1120        f.flush()?;
1121        let result = G2oLoader::load(f.path());
1122        assert!(
1123            matches!(result, Err(IoError::InvalidNumber { .. })),
1124            "invalid to-ID in EDGE_SE2 should return InvalidNumber"
1125        );
1126        Ok(())
1127    }
1128
1129    #[test]
1130    fn test_parse_edge_se2_invalid_dy() -> TestResult {
1131        let mut f = NamedTempFile::new()?;
1132        writeln!(f, "EDGE_SE2 0 1 1.0 bad 0.0 500.0 0.0 0.0 500.0 0.0 200.0")?;
1133        f.flush()?;
1134        let result = G2oLoader::load(f.path());
1135        assert!(
1136            result.is_err(),
1137            "invalid dy in EDGE_SE2 should return error"
1138        );
1139        Ok(())
1140    }
1141
1142    #[test]
1143    fn test_parse_edge_se2_invalid_dtheta() -> TestResult {
1144        let mut f = NamedTempFile::new()?;
1145        writeln!(f, "EDGE_SE2 0 1 1.0 0.0 bad 500.0 0.0 0.0 500.0 0.0 200.0")?;
1146        f.flush()?;
1147        let result = G2oLoader::load(f.path());
1148        assert!(
1149            result.is_err(),
1150            "invalid dtheta in EDGE_SE2 should return error"
1151        );
1152        Ok(())
1153    }
1154
1155    #[test]
1156    fn test_parse_edge_se2_invalid_info_matrix() -> TestResult {
1157        let mut f = NamedTempFile::new()?;
1158        writeln!(f, "EDGE_SE2 0 1 1.0 0.0 0.0 bad 0.0 0.0 500.0 0.0 200.0")?;
1159        f.flush()?;
1160        let result = G2oLoader::load(f.path());
1161        assert!(
1162            result.is_err(),
1163            "invalid info-matrix value in EDGE_SE2 should return error"
1164        );
1165        Ok(())
1166    }
1167
1168    // -------------------------------------------------------------------------
1169    // parse_edge_se3 error paths
1170    // -------------------------------------------------------------------------
1171
1172    #[test]
1173    fn test_parse_edge_se3_missing_fields() -> TestResult {
1174        let mut f = NamedTempFile::new()?;
1175        writeln!(f, "EDGE_SE3:QUAT 0 1 1.0 0.0")?; // only 5 parts (needs >= 10)
1176        f.flush()?;
1177        let result = G2oLoader::load(f.path());
1178        assert!(
1179            matches!(result, Err(IoError::MissingFields { .. })),
1180            "EDGE_SE3:QUAT with too few fields should return MissingFields"
1181        );
1182        Ok(())
1183    }
1184
1185    #[test]
1186    fn test_parse_edge_se3_invalid_translation() -> TestResult {
1187        let mut f = NamedTempFile::new()?;
1188        // bad tx
1189        writeln!(f, "EDGE_SE3:QUAT 0 1 bad 0.0 0.0 0.0 0.0 0.0 1.0")?;
1190        f.flush()?;
1191        let result = G2oLoader::load(f.path());
1192        assert!(
1193            matches!(result, Err(IoError::InvalidNumber { .. })),
1194            "invalid translation in EDGE_SE3:QUAT should return InvalidNumber"
1195        );
1196        Ok(())
1197    }
1198
1199    #[test]
1200    fn test_parse_edge_se3_invalid_quaternion_field() -> TestResult {
1201        let mut f = NamedTempFile::new()?;
1202        // bad qz field
1203        let info_vals =
1204            "1.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0";
1205        writeln!(
1206            f,
1207            "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 0.0 bad 1.0 {}",
1208            info_vals
1209        )?;
1210        f.flush()?;
1211        let result = G2oLoader::load(f.path());
1212        assert!(
1213            matches!(result, Err(IoError::InvalidNumber { .. })),
1214            "invalid quaternion field in EDGE_SE3:QUAT should return InvalidNumber"
1215        );
1216        Ok(())
1217    }
1218
1219    #[test]
1220    fn test_parse_edge_se3_invalid_from_id() -> TestResult {
1221        let mut f = NamedTempFile::new()?;
1222        let info_vals =
1223            "1.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0";
1224        writeln!(
1225            f,
1226            "EDGE_SE3:QUAT bad 1 1.0 0.0 0.0 0.0 0.0 0.0 1.0 {}",
1227            info_vals
1228        )?;
1229        f.flush()?;
1230        let result = G2oLoader::load(f.path());
1231        assert!(
1232            matches!(result, Err(IoError::InvalidNumber { .. })),
1233            "invalid from-id in EDGE_SE3:QUAT should return InvalidNumber"
1234        );
1235        Ok(())
1236    }
1237
1238    #[test]
1239    fn test_parse_edge_se3_invalid_to_id() -> TestResult {
1240        let mut f = NamedTempFile::new()?;
1241        let info_vals =
1242            "1.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0";
1243        writeln!(
1244            f,
1245            "EDGE_SE3:QUAT 0 bad 1.0 0.0 0.0 0.0 0.0 0.0 1.0 {}",
1246            info_vals
1247        )?;
1248        f.flush()?;
1249        let result = G2oLoader::load(f.path());
1250        assert!(
1251            matches!(result, Err(IoError::InvalidNumber { .. })),
1252            "invalid to-id in EDGE_SE3:QUAT should return InvalidNumber"
1253        );
1254        Ok(())
1255    }
1256
1257    #[test]
1258    fn test_parse_edge_se3_invalid_ty() -> TestResult {
1259        let mut f = NamedTempFile::new()?;
1260        writeln!(f, "EDGE_SE3:QUAT 0 1 1.0 bad 0.0 0.0 0.0 0.0 1.0")?;
1261        f.flush()?;
1262        let result = G2oLoader::load(f.path());
1263        assert!(
1264            matches!(result, Err(IoError::InvalidNumber { .. })),
1265            "invalid ty in EDGE_SE3:QUAT should return InvalidNumber"
1266        );
1267        Ok(())
1268    }
1269
1270    #[test]
1271    fn test_parse_edge_se3_invalid_tz() -> TestResult {
1272        let mut f = NamedTempFile::new()?;
1273        writeln!(f, "EDGE_SE3:QUAT 0 1 1.0 0.0 bad 0.0 0.0 0.0 1.0")?;
1274        f.flush()?;
1275        let result = G2oLoader::load(f.path());
1276        assert!(
1277            matches!(result, Err(IoError::InvalidNumber { .. })),
1278            "invalid tz in EDGE_SE3:QUAT should return InvalidNumber"
1279        );
1280        Ok(())
1281    }
1282
1283    #[test]
1284    fn test_parse_edge_se3_invalid_qx() -> TestResult {
1285        let mut f = NamedTempFile::new()?;
1286        writeln!(f, "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 bad 0.0 0.0 1.0")?;
1287        f.flush()?;
1288        let result = G2oLoader::load(f.path());
1289        assert!(
1290            matches!(result, Err(IoError::InvalidNumber { .. })),
1291            "invalid qx in EDGE_SE3:QUAT should return InvalidNumber"
1292        );
1293        Ok(())
1294    }
1295
1296    #[test]
1297    fn test_parse_edge_se3_invalid_qy() -> TestResult {
1298        let mut f = NamedTempFile::new()?;
1299        writeln!(f, "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 bad 0.0 1.0")?;
1300        f.flush()?;
1301        let result = G2oLoader::load(f.path());
1302        assert!(
1303            matches!(result, Err(IoError::InvalidNumber { .. })),
1304            "invalid qy in EDGE_SE3:QUAT should return InvalidNumber"
1305        );
1306        Ok(())
1307    }
1308
1309    #[test]
1310    fn test_parse_edge_se3_invalid_qw() -> TestResult {
1311        let mut f = NamedTempFile::new()?;
1312        let info_vals =
1313            "1.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0";
1314        writeln!(
1315            f,
1316            "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 0.0 0.0 bad {}",
1317            info_vals
1318        )?;
1319        f.flush()?;
1320        let result = G2oLoader::load(f.path());
1321        assert!(
1322            matches!(result, Err(IoError::InvalidNumber { .. })),
1323            "invalid qw in EDGE_SE3:QUAT should return InvalidNumber"
1324        );
1325        Ok(())
1326    }
1327
1328    #[test]
1329    fn test_parse_edge_se3_invalid_info_matrix() -> TestResult {
1330        let mut f = NamedTempFile::new()?;
1331        let info_vals = "bad 0.0 0.0 0.0 0.0 0.0 100.0 0.0 0.0 0.0 0.0 100.0 0.0 0.0 0.0 100.0 0.0 0.0 100.0 0.0 100.0";
1332        writeln!(
1333            f,
1334            "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 0.0 0.0 1.0 {}",
1335            info_vals
1336        )?;
1337        f.flush()?;
1338        let result = G2oLoader::load(f.path());
1339        assert!(
1340            result.is_err(),
1341            "invalid info-matrix values in EDGE_SE3:QUAT should return error"
1342        );
1343        Ok(())
1344    }
1345
1346    // -------------------------------------------------------------------------
1347    // parse_line: comment / empty / unknown token paths
1348    // -------------------------------------------------------------------------
1349
1350    #[test]
1351    fn test_parse_comment_and_empty_lines_ignored() -> TestResult {
1352        let mut f = NamedTempFile::new()?;
1353        writeln!(f, "# This is a comment")?;
1354        writeln!(f, "VERTEX_SE2 0 1.0 2.0 0.0")?;
1355        writeln!(f)?; // empty line
1356        writeln!(f, "VERTEX_SE2 1 3.0 4.0 0.0")?;
1357        f.flush()?;
1358        let graph = G2oLoader::load(f.path())?;
1359        assert_eq!(
1360            graph.vertices_se2.len(),
1361            2,
1362            "comments and empty lines should be ignored"
1363        );
1364        Ok(())
1365    }
1366
1367    #[test]
1368    fn test_parse_unknown_token_ignored() -> TestResult {
1369        let mut f = NamedTempFile::new()?;
1370        writeln!(f, "FIX 0")?; // unknown token
1371        writeln!(f, "VERTEX_SE2 0 0.0 0.0 0.0")?;
1372        f.flush()?;
1373        let graph = G2oLoader::load(f.path())?;
1374        assert_eq!(graph.vertices_se2.len(), 1);
1375        Ok(())
1376    }
1377
1378    // -------------------------------------------------------------------------
1379    // parse_parallel (> 1000 lines triggers parallel path)
1380    // -------------------------------------------------------------------------
1381
1382    #[test]
1383    fn test_parse_parallel_large_se2_file() -> TestResult {
1384        let mut f = NamedTempFile::new()?;
1385        for i in 0..1001usize {
1386            writeln!(f, "VERTEX_SE2 {} {} {} 0.0", i, i as f64, i as f64)?;
1387        }
1388        f.flush()?;
1389        let graph = G2oLoader::load(f.path())?;
1390        assert_eq!(graph.vertices_se2.len(), 1001);
1391        Ok(())
1392    }
1393
1394    #[test]
1395    fn test_parse_parallel_with_se2_edges() -> TestResult {
1396        let mut f = NamedTempFile::new()?;
1397        for i in 0..1001usize {
1398            writeln!(f, "VERTEX_SE2 {} 0.0 0.0 0.0", i)?;
1399        }
1400        writeln!(f, "EDGE_SE2 0 1 1.0 0.0 0.0 500.0 0.0 0.0 500.0 0.0 200.0")?;
1401        f.flush()?;
1402        let graph = G2oLoader::load(f.path())?;
1403        assert_eq!(graph.vertices_se2.len(), 1001);
1404        assert_eq!(graph.edges_se2.len(), 1);
1405        Ok(())
1406    }
1407
1408    #[test]
1409    fn test_parse_parallel_with_se3_vertices() -> TestResult {
1410        let mut f = NamedTempFile::new()?;
1411        for i in 0..1001usize {
1412            writeln!(
1413                f,
1414                "VERTEX_SE3:QUAT {} 0.0 0.0 {} 0.0 0.0 0.0 1.0",
1415                i, i as f64
1416            )?;
1417        }
1418        f.flush()?;
1419        let graph = G2oLoader::load(f.path())?;
1420        assert_eq!(graph.vertices_se3.len(), 1001);
1421        Ok(())
1422    }
1423
1424    #[test]
1425    fn test_parse_parallel_with_se3_edges() -> TestResult {
1426        let mut f = NamedTempFile::new()?;
1427        for i in 0..1001usize {
1428            writeln!(f, "VERTEX_SE3:QUAT {} 0.0 0.0 0.0 0.0 0.0 0.0 1.0", i)?;
1429        }
1430        let info_vals =
1431            "1.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 1.0 0.0 1.0";
1432        writeln!(
1433            f,
1434            "EDGE_SE3:QUAT 0 1 1.0 0.0 0.0 0.0 0.0 0.0 1.0 {}",
1435            info_vals
1436        )?;
1437        f.flush()?;
1438        let graph = G2oLoader::load(f.path())?;
1439        assert_eq!(graph.vertices_se3.len(), 1001);
1440        assert_eq!(graph.edges_se3.len(), 1);
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn test_parse_parallel_duplicate_vertex_returns_error() -> TestResult {
1446        let mut f = NamedTempFile::new()?;
1447        for i in 0..1000usize {
1448            writeln!(f, "VERTEX_SE2 {} 0.0 0.0 0.0", i)?;
1449        }
1450        writeln!(f, "VERTEX_SE2 0 9.0 9.0 0.0")?; // duplicate of ID 0
1451        f.flush()?;
1452        let result = G2oLoader::load(f.path());
1453        assert!(
1454            matches!(result, Err(IoError::DuplicateVertex { id: 0 })),
1455            "duplicate vertex in parallel parse should return DuplicateVertex"
1456        );
1457        Ok(())
1458    }
1459
1460    #[test]
1461    fn test_parse_parallel_comment_and_empty_lines_ignored() -> TestResult {
1462        let mut f = NamedTempFile::new()?;
1463        writeln!(f, "# parallel parse comment test")?;
1464        for i in 0..1000usize {
1465            writeln!(f, "VERTEX_SE2 {} 0.0 0.0 0.0", i)?;
1466        }
1467        writeln!(f)?; // empty line at end
1468        f.flush()?;
1469        let graph = G2oLoader::load(f.path())?;
1470        assert_eq!(graph.vertices_se2.len(), 1000);
1471        Ok(())
1472    }
1473}