Skip to main content

apex_io/
g2o.rs

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