Skip to main content

threecrate_io/
xyz_csv.rs

1//! XYZ/CSV point cloud format support
2//!
3//! This module provides comprehensive XYZ and CSV point cloud reading capabilities including:
4//! - Auto-detection of delimiters (comma, space, tab)
5//! - Header detection and parsing
6//! - Support for x,y,z coordinates (required) and optional intensity, r,g,b, nx,ny,nz
7//! - Schema hints for flexible parsing
8//! - Streaming support for large files
9
10use std::fs::File;
11use std::io::{BufRead, BufReader, BufWriter, Write};
12use std::path::Path;
13use threecrate_core::{Error, Point3f, PointCloud, Result, Vector3f};
14
15/// Supported delimiters for CSV/XYZ files
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Delimiter {
18    Comma,
19    Space,
20    Tab,
21    Semicolon,
22}
23
24impl Delimiter {
25    /// Get the character representation of the delimiter
26    pub fn as_char(&self) -> char {
27        match self {
28            Delimiter::Comma => ',',
29            Delimiter::Space => ' ',
30            Delimiter::Tab => '\t',
31            Delimiter::Semicolon => ';',
32        }
33    }
34
35    /// Detect delimiter from a line of text
36    pub fn detect_from_line(line: &str) -> Option<Self> {
37        let comma_count = line.matches(',').count();
38        let space_count = line.matches(' ').count();
39        let tab_count = line.matches('\t').count();
40        let semicolon_count = line.matches(';').count();
41
42        // Find the delimiter with the highest count
43        let counts = [
44            (comma_count, Delimiter::Comma),
45            (space_count, Delimiter::Space),
46            (tab_count, Delimiter::Tab),
47            (semicolon_count, Delimiter::Semicolon),
48        ];
49
50        counts
51            .iter()
52            .max_by_key(|(count, _)| count)
53            .filter(|(count, _)| *count > 0)
54            .map(|(_, delimiter)| *delimiter)
55    }
56}
57
58/// Column types that can be parsed from XYZ/CSV files
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ColumnType {
61    X,
62    Y,
63    Z,
64    Intensity,
65    Red,
66    Green,
67    Blue,
68    NormalX,
69    NormalY,
70    NormalZ,
71    Unknown,
72}
73
74impl ColumnType {
75    /// Parse column type from header name
76    pub fn from_header(header: &str) -> Self {
77        let header_lower = header.to_lowercase();
78        let header_trimmed = header_lower.trim();
79        match header_trimmed {
80            "x" | "px" | "pos_x" | "position_x" => ColumnType::X,
81            "y" | "py" | "pos_y" | "position_y" => ColumnType::Y,
82            "z" | "pz" | "pos_z" | "position_z" => ColumnType::Z,
83            "i" | "intensity" | "int" => ColumnType::Intensity,
84            "r" | "red" | "color_r" => ColumnType::Red,
85            "g" | "green" | "color_g" => ColumnType::Green,
86            "b" | "blue" | "color_b" => ColumnType::Blue,
87            "nx" | "normal_x" | "n_x" => ColumnType::NormalX,
88            "ny" | "normal_y" | "n_y" => ColumnType::NormalY,
89            "nz" | "normal_z" | "n_z" => ColumnType::NormalZ,
90            _ => ColumnType::Unknown,
91        }
92    }
93}
94
95/// Schema definition for parsing XYZ/CSV files
96#[derive(Debug, Clone)]
97pub struct XyzCsvSchema {
98    pub columns: Vec<ColumnType>,
99    pub has_header: bool,
100    pub delimiter: Delimiter,
101}
102
103impl XyzCsvSchema {
104    /// Create a new schema
105    pub fn new(columns: Vec<ColumnType>, has_header: bool, delimiter: Delimiter) -> Self {
106        Self {
107            columns,
108            has_header,
109            delimiter,
110        }
111    }
112
113    /// Auto-detect schema from file content
114    pub fn detect_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
115        let path_ref = path.as_ref();
116        let file = File::open(path_ref)?;
117        let mut reader = BufReader::new(file);
118        let mut first_line = String::new();
119        reader.read_line(&mut first_line)?;
120
121        // Detect delimiter
122        let delimiter = Delimiter::detect_from_line(&first_line)
123            .ok_or_else(|| Error::InvalidData("Could not detect delimiter".to_string()))?;
124
125        // Determine if this is a header by checking if first line contains non-numeric data
126        let has_header = Self::is_header_line(&first_line, &[], delimiter);
127
128        let columns = if has_header {
129            // Parse columns from header
130            Self::parse_columns(&first_line, delimiter)?
131        } else {
132            // For files without headers, assume x,y,z format
133            vec![ColumnType::X, ColumnType::Y, ColumnType::Z]
134        };
135
136        Ok(Self::new(columns, has_header, delimiter))
137    }
138
139    /// Parse columns from a header line
140    fn parse_columns(line: &str, delimiter: Delimiter) -> Result<Vec<ColumnType>> {
141        let parts: Vec<&str> = line.split(delimiter.as_char()).map(|s| s.trim()).collect();
142
143        let columns: Vec<ColumnType> = parts
144            .iter()
145            .map(|header| ColumnType::from_header(header))
146            .collect();
147
148        // Validate that we have at least x, y, z columns
149        let has_x = columns.contains(&ColumnType::X);
150        let has_y = columns.contains(&ColumnType::Y);
151        let has_z = columns.contains(&ColumnType::Z);
152
153        if !has_x || !has_y || !has_z {
154            return Err(Error::InvalidData(
155                "XYZ/CSV file must contain x, y, z coordinates".to_string(),
156            ));
157        }
158
159        Ok(columns)
160    }
161
162    /// Determine if a line is a header by checking for non-numeric content
163    fn is_header_line(line: &str, columns: &[ColumnType], delimiter: Delimiter) -> bool {
164        let parts: Vec<&str> = line.split(delimiter.as_char()).map(|s| s.trim()).collect();
165
166        // If we have fewer than 3 parts, it's likely not a valid point cloud line
167        if parts.len() < 3 {
168            return false;
169        }
170
171        // If we have columns defined, check against them
172        if !columns.is_empty() {
173            // If we have fewer parts than expected columns, it's likely not a header
174            if parts.len() < columns.len() {
175                return false;
176            }
177
178            // Check if any part that should be numeric is not numeric
179            for (i, part) in parts.iter().enumerate() {
180                if i < columns.len() {
181                    match columns[i] {
182                        ColumnType::X
183                        | ColumnType::Y
184                        | ColumnType::Z
185                        | ColumnType::Intensity
186                        | ColumnType::Red
187                        | ColumnType::Green
188                        | ColumnType::Blue
189                        | ColumnType::NormalX
190                        | ColumnType::NormalY
191                        | ColumnType::NormalZ => {
192                            if part.parse::<f32>().is_err() {
193                                return true; // Non-numeric data suggests this is a header
194                            }
195                        }
196                        ColumnType::Unknown => {
197                            // For unknown columns, try to parse as float
198                            if part.parse::<f32>().is_err() {
199                                return true;
200                            }
201                        }
202                    }
203                }
204            }
205        } else {
206            // No columns defined, check if first 3 parts are numeric
207            for (_i, part) in parts.iter().enumerate().take(3) {
208                if part.parse::<f32>().is_err() {
209                    return true; // Non-numeric data suggests this is a header
210                }
211            }
212        }
213
214        false
215    }
216}
217
218/// Point data parsed from XYZ/CSV file
219#[derive(Debug, Clone)]
220pub struct XyzCsvPoint {
221    pub position: Point3f,
222    pub intensity: Option<f32>,
223    pub color: Option<[u8; 3]>,
224    pub normal: Option<Vector3f>,
225}
226
227impl XyzCsvPoint {
228    /// Create a new point with only position
229    pub fn new(position: Point3f) -> Self {
230        Self {
231            position,
232            intensity: None,
233            color: None,
234            normal: None,
235        }
236    }
237
238    /// Create a point with position and intensity
239    pub fn with_intensity(position: Point3f, intensity: f32) -> Self {
240        Self {
241            position,
242            intensity: Some(intensity),
243            color: None,
244            normal: None,
245        }
246    }
247
248    /// Create a point with position and color
249    pub fn with_color(position: Point3f, color: [u8; 3]) -> Self {
250        Self {
251            position,
252            intensity: None,
253            color: Some(color),
254            normal: None,
255        }
256    }
257
258    /// Create a point with position and normal
259    pub fn with_normal(position: Point3f, normal: Vector3f) -> Self {
260        Self {
261            position,
262            intensity: None,
263            color: None,
264            normal: Some(normal),
265        }
266    }
267}
268
269/// XYZ/CSV reader implementation
270pub struct XyzCsvReader;
271
272impl XyzCsvReader {
273    /// Read a point cloud from an XYZ/CSV file with auto-detection
274    pub fn read_point_cloud<P: AsRef<Path>>(path: P) -> Result<PointCloud<Point3f>> {
275        let schema = XyzCsvSchema::detect_from_file(&path)?;
276        Self::read_point_cloud_with_schema(path, &schema)
277    }
278
279    /// Read a point cloud with a specific schema
280    pub fn read_point_cloud_with_schema<P: AsRef<Path>>(
281        path: P,
282        schema: &XyzCsvSchema,
283    ) -> Result<PointCloud<Point3f>> {
284        let file = File::open(path)?;
285        let reader = BufReader::new(file);
286        let mut lines = reader.lines();
287
288        // Skip header if present
289        if schema.has_header {
290            lines.next();
291        }
292
293        let mut cloud = PointCloud::new();
294
295        for line_result in lines {
296            let line = line_result?;
297            if line.trim().is_empty() {
298                continue;
299            }
300
301            let point = Self::parse_line(&line, schema)?;
302            cloud.push(point.position);
303        }
304
305        Ok(cloud)
306    }
307
308    /// Read detailed point data (with colors, normals, etc.)
309    pub fn read_detailed_points<P: AsRef<Path>>(path: P) -> Result<Vec<XyzCsvPoint>> {
310        let schema = XyzCsvSchema::detect_from_file(&path)?;
311        Self::read_detailed_points_with_schema(path, &schema)
312    }
313
314    /// Read detailed point data with a specific schema
315    pub fn read_detailed_points_with_schema<P: AsRef<Path>>(
316        path: P,
317        schema: &XyzCsvSchema,
318    ) -> Result<Vec<XyzCsvPoint>> {
319        let file = File::open(path)?;
320        let reader = BufReader::new(file);
321        let mut lines = reader.lines();
322
323        // Skip header if present
324        if schema.has_header {
325            lines.next();
326        }
327
328        let mut points = Vec::new();
329
330        for line_result in lines {
331            let line = line_result?;
332            if line.trim().is_empty() {
333                continue;
334            }
335
336            let point = Self::parse_line(&line, schema)?;
337            points.push(point);
338        }
339
340        Ok(points)
341    }
342
343    /// Parse a single line into a point
344    fn parse_line(line: &str, schema: &XyzCsvSchema) -> Result<XyzCsvPoint> {
345        let parts: Vec<&str> = line
346            .split(schema.delimiter.as_char())
347            .map(|s| s.trim())
348            .collect();
349
350        if parts.len() < 3 {
351            return Err(Error::InvalidData(
352                "Line must have at least 3 columns (x, y, z)".to_string(),
353            ));
354        }
355
356        // Find x, y, z indices
357        let mut x_idx = None;
358        let mut y_idx = None;
359        let mut z_idx = None;
360        let mut intensity_idx = None;
361        let mut red_idx = None;
362        let mut green_idx = None;
363        let mut blue_idx = None;
364        let mut nx_idx = None;
365        let mut ny_idx = None;
366        let mut nz_idx = None;
367
368        for (i, col_type) in schema.columns.iter().enumerate() {
369            match *col_type {
370                ColumnType::X => x_idx = Some(i),
371                ColumnType::Y => y_idx = Some(i),
372                ColumnType::Z => z_idx = Some(i),
373                ColumnType::Intensity => intensity_idx = Some(i),
374                ColumnType::Red => red_idx = Some(i),
375                ColumnType::Green => green_idx = Some(i),
376                ColumnType::Blue => blue_idx = Some(i),
377                ColumnType::NormalX => nx_idx = Some(i),
378                ColumnType::NormalY => ny_idx = Some(i),
379                ColumnType::NormalZ => nz_idx = Some(i),
380                ColumnType::Unknown => {}
381            }
382        }
383
384        // Parse position (required). A row can be shorter than the header.
385        let coordinate = |idx: Option<usize>, axis: &str| -> Result<f32> {
386            let text = idx.and_then(|i| parts.get(i)).ok_or_else(|| {
387                Error::InvalidData(format!("Missing {axis} coordinate in row: {line}"))
388            })?;
389            text.parse::<f32>()
390                .map_err(|_| Error::InvalidData(format!("Invalid {axis} coordinate: {text}")))
391        };
392        let x = coordinate(x_idx, "x")?;
393        let y = coordinate(y_idx, "y")?;
394        let z = coordinate(z_idx, "z")?;
395
396        let position = Point3f::new(x, y, z);
397
398        // Parse optional attributes
399        let intensity = if let Some(idx) = intensity_idx {
400            parts.get(idx).and_then(|s| s.parse::<f32>().ok())
401        } else {
402            None
403        };
404
405        let color = if let (Some(r_idx), Some(g_idx), Some(b_idx)) = (red_idx, green_idx, blue_idx)
406        {
407            if let (Some(r), Some(g), Some(b)) = (
408                parts.get(r_idx).and_then(|s| s.parse::<f32>().ok()),
409                parts.get(g_idx).and_then(|s| s.parse::<f32>().ok()),
410                parts.get(b_idx).and_then(|s| s.parse::<f32>().ok()),
411            ) {
412                Some([
413                    (r.clamp(0.0, 255.0) as u8),
414                    (g.clamp(0.0, 255.0) as u8),
415                    (b.clamp(0.0, 255.0) as u8),
416                ])
417            } else {
418                None
419            }
420        } else {
421            None
422        };
423
424        let normal = if let (Some(nx_idx), Some(ny_idx), Some(nz_idx)) = (nx_idx, ny_idx, nz_idx) {
425            if let (Some(nx), Some(ny), Some(nz)) = (
426                parts.get(nx_idx).and_then(|s| s.parse::<f32>().ok()),
427                parts.get(ny_idx).and_then(|s| s.parse::<f32>().ok()),
428                parts.get(nz_idx).and_then(|s| s.parse::<f32>().ok()),
429            ) {
430                Some(Vector3f::new(nx, ny, nz))
431            } else {
432                None
433            }
434        } else {
435            None
436        };
437
438        Ok(XyzCsvPoint {
439            position,
440            intensity,
441            color,
442            normal,
443        })
444    }
445}
446
447/// XYZ/CSV streaming reader for large files
448pub struct XyzCsvStreamingReader {
449    reader: BufReader<File>,
450    schema: XyzCsvSchema,
451    buffer: Vec<String>,
452    buffer_index: usize,
453    header_skipped: bool,
454}
455
456impl XyzCsvStreamingReader {
457    /// Create a new streaming reader
458    pub fn new<P: AsRef<Path>>(path: P, chunk_size: usize) -> Result<Self> {
459        let path_ref = path.as_ref();
460        let file = File::open(path_ref)?;
461        let reader = BufReader::with_capacity(chunk_size, file);
462        let schema = XyzCsvSchema::detect_from_file(path_ref)?;
463
464        Ok(Self {
465            reader,
466            schema,
467            buffer: Vec::new(),
468            buffer_index: 0,
469            header_skipped: false,
470        })
471    }
472
473    /// Fill the buffer with the next chunk of lines
474    fn fill_buffer(&mut self) -> Result<bool> {
475        self.buffer.clear();
476        self.buffer_index = 0;
477
478        for _ in 0..1000 {
479            // Read up to 1000 lines at a time
480            let mut line = String::new();
481            match self.reader.read_line(&mut line)? {
482                0 => break, // EOF
483                _ => {
484                    if !line.trim().is_empty() {
485                        self.buffer.push(line);
486                    }
487                }
488            }
489        }
490
491        Ok(!self.buffer.is_empty())
492    }
493}
494
495impl Iterator for XyzCsvStreamingReader {
496    type Item = Result<Point3f>;
497
498    fn next(&mut self) -> Option<Self::Item> {
499        // Skip header on first read
500        if !self.header_skipped && self.schema.has_header {
501            let mut line = String::new();
502            if self.reader.read_line(&mut line).is_err() {
503                return None;
504            }
505            self.header_skipped = true;
506        }
507
508        // Fill buffer if needed
509        if self.buffer_index >= self.buffer.len() {
510            match self.fill_buffer() {
511                Ok(true) => {}            // Buffer filled successfully
512                Ok(false) => return None, // EOF
513                Err(e) => return Some(Err(e)),
514            }
515        }
516
517        // Get next line from buffer
518        if self.buffer_index < self.buffer.len() {
519            let line = &self.buffer[self.buffer_index];
520            self.buffer_index += 1;
521
522            match XyzCsvReader::parse_line(line, &self.schema) {
523                Ok(point) => Some(Ok(point.position)),
524                Err(e) => Some(Err(e)),
525            }
526        } else {
527            None
528        }
529    }
530}
531
532/// XYZ/CSV writer implementation
533pub struct XyzCsvWriter;
534
535impl XyzCsvWriter {
536    /// Write a point cloud to an XYZ/CSV file
537    pub fn write_point_cloud<P: AsRef<Path>>(
538        cloud: &PointCloud<Point3f>,
539        path: P,
540        options: &XyzCsvWriteOptions,
541    ) -> Result<()> {
542        let file = File::create(path)?;
543        let mut writer = BufWriter::new(file);
544
545        // Write header if requested
546        if options.include_header {
547            let header = Self::generate_header(&options.schema);
548            writeln!(writer, "{}", header)?;
549        }
550
551        // Write points
552        for point in cloud.iter() {
553            let line = Self::format_point(point, &options.schema);
554            writeln!(writer, "{}", line)?;
555        }
556
557        writer.flush()?;
558        Ok(())
559    }
560
561    /// Write detailed points to an XYZ/CSV file
562    pub fn write_detailed_points<P: AsRef<Path>>(
563        points: &[XyzCsvPoint],
564        path: P,
565        options: &XyzCsvWriteOptions,
566    ) -> Result<()> {
567        let file = File::create(path)?;
568        let mut writer = BufWriter::new(file);
569
570        // Write header if requested
571        if options.include_header {
572            let header = Self::generate_header(&options.schema);
573            writeln!(writer, "{}", header)?;
574        }
575
576        // Write points
577        for point in points {
578            let line = Self::format_detailed_point(point, &options.schema);
579            writeln!(writer, "{}", line)?;
580        }
581
582        writer.flush()?;
583        Ok(())
584    }
585
586    /// Generate header line
587    fn generate_header(schema: &XyzCsvSchema) -> String {
588        let headers: Vec<&str> = schema
589            .columns
590            .iter()
591            .map(|col| match col {
592                ColumnType::X => "x",
593                ColumnType::Y => "y",
594                ColumnType::Z => "z",
595                ColumnType::Intensity => "intensity",
596                ColumnType::Red => "r",
597                ColumnType::Green => "g",
598                ColumnType::Blue => "b",
599                ColumnType::NormalX => "nx",
600                ColumnType::NormalY => "ny",
601                ColumnType::NormalZ => "nz",
602                ColumnType::Unknown => "unknown",
603            })
604            .collect();
605
606        headers.join(&schema.delimiter.as_char().to_string())
607    }
608
609    /// Format a basic point
610    fn format_point(point: &Point3f, schema: &XyzCsvSchema) -> String {
611        let mut values = Vec::new();
612
613        for col_type in &schema.columns {
614            match col_type {
615                ColumnType::X => values.push(point.x.to_string()),
616                ColumnType::Y => values.push(point.y.to_string()),
617                ColumnType::Z => values.push(point.z.to_string()),
618                _ => values.push("0".to_string()), // Default value for missing columns
619            }
620        }
621
622        values.join(&schema.delimiter.as_char().to_string())
623    }
624
625    /// Format a detailed point
626    fn format_detailed_point(point: &XyzCsvPoint, schema: &XyzCsvSchema) -> String {
627        let mut values = Vec::new();
628
629        for col_type in &schema.columns {
630            let value = match col_type {
631                ColumnType::X => point.position.x.to_string(),
632                ColumnType::Y => point.position.y.to_string(),
633                ColumnType::Z => point.position.z.to_string(),
634                ColumnType::Intensity => point.intensity.unwrap_or(0.0).to_string(),
635                ColumnType::Red => point.color.map(|c| c[0] as f32).unwrap_or(0.0).to_string(),
636                ColumnType::Green => point.color.map(|c| c[1] as f32).unwrap_or(0.0).to_string(),
637                ColumnType::Blue => point.color.map(|c| c[2] as f32).unwrap_or(0.0).to_string(),
638                ColumnType::NormalX => point.normal.map(|n| n.x).unwrap_or(0.0).to_string(),
639                ColumnType::NormalY => point.normal.map(|n| n.y).unwrap_or(0.0).to_string(),
640                ColumnType::NormalZ => point.normal.map(|n| n.z).unwrap_or(0.0).to_string(),
641                ColumnType::Unknown => "0".to_string(),
642            };
643            values.push(value);
644        }
645
646        values.join(&schema.delimiter.as_char().to_string())
647    }
648}
649
650/// Write options for XYZ/CSV files
651#[derive(Debug, Clone)]
652pub struct XyzCsvWriteOptions {
653    pub schema: XyzCsvSchema,
654    pub include_header: bool,
655}
656
657impl XyzCsvWriteOptions {
658    /// Create basic options for XYZ format
659    pub fn xyz() -> Self {
660        Self {
661            schema: XyzCsvSchema::new(
662                vec![ColumnType::X, ColumnType::Y, ColumnType::Z],
663                false,
664                Delimiter::Space,
665            ),
666            include_header: false,
667        }
668    }
669
670    /// Create options for CSV format with header
671    pub fn csv_with_header() -> Self {
672        Self {
673            schema: XyzCsvSchema::new(
674                vec![ColumnType::X, ColumnType::Y, ColumnType::Z],
675                true,
676                Delimiter::Comma,
677            ),
678            include_header: true,
679        }
680    }
681
682    /// Create options for CSV with colors
683    pub fn csv_with_colors() -> Self {
684        Self {
685            schema: XyzCsvSchema::new(
686                vec![
687                    ColumnType::X,
688                    ColumnType::Y,
689                    ColumnType::Z,
690                    ColumnType::Red,
691                    ColumnType::Green,
692                    ColumnType::Blue,
693                ],
694                true,
695                Delimiter::Comma,
696            ),
697            include_header: true,
698        }
699    }
700
701    /// Create options for CSV with normals
702    pub fn csv_with_normals() -> Self {
703        Self {
704            schema: XyzCsvSchema::new(
705                vec![
706                    ColumnType::X,
707                    ColumnType::Y,
708                    ColumnType::Z,
709                    ColumnType::NormalX,
710                    ColumnType::NormalY,
711                    ColumnType::NormalZ,
712                ],
713                true,
714                Delimiter::Comma,
715            ),
716            include_header: true,
717        }
718    }
719
720    /// Create options for CSV with all attributes
721    pub fn csv_complete() -> Self {
722        Self {
723            schema: XyzCsvSchema::new(
724                vec![
725                    ColumnType::X,
726                    ColumnType::Y,
727                    ColumnType::Z,
728                    ColumnType::Intensity,
729                    ColumnType::Red,
730                    ColumnType::Green,
731                    ColumnType::Blue,
732                    ColumnType::NormalX,
733                    ColumnType::NormalY,
734                    ColumnType::NormalZ,
735                ],
736                true,
737                Delimiter::Comma,
738            ),
739            include_header: true,
740        }
741    }
742}
743
744// Implement the registry traits
745impl crate::registry::PointCloudReader for XyzCsvReader {
746    fn read_point_cloud(&self, path: &Path) -> Result<PointCloud<Point3f>> {
747        Self::read_point_cloud(path)
748    }
749
750    fn can_read(&self, path: &Path) -> bool {
751        // Check file extension
752        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
753            matches!(ext.to_lowercase().as_str(), "xyz" | "csv" | "txt")
754        } else {
755            false
756        }
757    }
758
759    fn format_name(&self) -> &'static str {
760        "xyz_csv"
761    }
762}
763
764impl crate::registry::PointCloudWriter for XyzCsvWriter {
765    fn write_point_cloud(&self, cloud: &PointCloud<Point3f>, path: &Path) -> Result<()> {
766        // Auto-detect format based on extension
767        let options = if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
768            match ext.to_lowercase().as_str() {
769                "xyz" => XyzCsvWriteOptions::xyz(),
770                "csv" => XyzCsvWriteOptions::csv_with_header(),
771                _ => XyzCsvWriteOptions::xyz(),
772            }
773        } else {
774            XyzCsvWriteOptions::xyz()
775        };
776
777        Self::write_point_cloud(cloud, path, &options)
778    }
779
780    fn format_name(&self) -> &'static str {
781        "xyz_csv"
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn test_short_row_is_an_error_not_a_panic() {
791        let path = std::env::temp_dir().join(format!("tc_short_row_{}.csv", std::process::id()));
792        std::fs::write(
793            &path,
794            "id,a,b,x,y,z
7951,2,3
796",
797        )
798        .unwrap();
799        let result = XyzCsvReader::read_point_cloud(&path);
800        std::fs::remove_file(&path).ok();
801        assert!(result
802            .unwrap_err()
803            .to_string()
804            .contains("Missing x coordinate"));
805    }
806    use std::fs;
807
808    #[test]
809    fn test_delimiter_detection() {
810        assert_eq!(Delimiter::detect_from_line("1,2,3"), Some(Delimiter::Comma));
811        assert_eq!(Delimiter::detect_from_line("1 2 3"), Some(Delimiter::Space));
812        assert_eq!(Delimiter::detect_from_line("1\t2\t3"), Some(Delimiter::Tab));
813        assert_eq!(
814            Delimiter::detect_from_line("1;2;3"),
815            Some(Delimiter::Semicolon)
816        );
817    }
818
819    #[test]
820    fn test_column_type_detection() {
821        assert_eq!(ColumnType::from_header("x"), ColumnType::X);
822        assert_eq!(ColumnType::from_header("X"), ColumnType::X);
823        assert_eq!(ColumnType::from_header("position_x"), ColumnType::X);
824        assert_eq!(ColumnType::from_header("intensity"), ColumnType::Intensity);
825        assert_eq!(ColumnType::from_header("red"), ColumnType::Red);
826        assert_eq!(ColumnType::from_header("nx"), ColumnType::NormalX);
827        assert_eq!(ColumnType::from_header("unknown"), ColumnType::Unknown);
828    }
829
830    #[test]
831    fn test_xyz_reader_basic() {
832        let temp_file = "test_basic.xyz";
833        let content = "1.0 2.0 3.0\n4.0 5.0 6.0\n7.0 8.0 9.0\n";
834        fs::write(temp_file, content).unwrap();
835
836        let cloud = XyzCsvReader::read_point_cloud(temp_file).unwrap();
837        assert_eq!(cloud.len(), 3);
838        assert_eq!(cloud[0], Point3f::new(1.0, 2.0, 3.0));
839        assert_eq!(cloud[1], Point3f::new(4.0, 5.0, 6.0));
840        assert_eq!(cloud[2], Point3f::new(7.0, 8.0, 9.0));
841
842        fs::remove_file(temp_file).unwrap();
843    }
844
845    #[test]
846    fn test_csv_reader_with_header() {
847        let temp_file = "test_header.csv";
848        let content = "x,y,z\n1.0,2.0,3.0\n4.0,5.0,6.0\n";
849        fs::write(temp_file, content).unwrap();
850
851        let cloud = XyzCsvReader::read_point_cloud(temp_file).unwrap();
852        assert_eq!(cloud.len(), 2);
853        assert_eq!(cloud[0], Point3f::new(1.0, 2.0, 3.0));
854        assert_eq!(cloud[1], Point3f::new(4.0, 5.0, 6.0));
855
856        fs::remove_file(temp_file).unwrap();
857    }
858
859    #[test]
860    fn test_csv_reader_with_colors() {
861        let temp_file = "test_colors.csv";
862        let content = "x,y,z,r,g,b\n1.0,2.0,3.0,255,0,0\n4.0,5.0,6.0,0,255,0\n";
863        fs::write(temp_file, content).unwrap();
864
865        let points = XyzCsvReader::read_detailed_points(temp_file).unwrap();
866        assert_eq!(points.len(), 2);
867        assert_eq!(points[0].position, Point3f::new(1.0, 2.0, 3.0));
868        assert_eq!(points[0].color, Some([255, 0, 0]));
869        assert_eq!(points[1].position, Point3f::new(4.0, 5.0, 6.0));
870        assert_eq!(points[1].color, Some([0, 255, 0]));
871
872        fs::remove_file(temp_file).unwrap();
873    }
874
875    #[test]
876    fn test_csv_reader_with_normals() {
877        let temp_file = "test_normals.csv";
878        let content = "x,y,z,nx,ny,nz\n1.0,2.0,3.0,0.0,0.0,1.0\n4.0,5.0,6.0,0.0,1.0,0.0\n";
879        fs::write(temp_file, content).unwrap();
880
881        let points = XyzCsvReader::read_detailed_points(temp_file).unwrap();
882        assert_eq!(points.len(), 2);
883        assert_eq!(points[0].position, Point3f::new(1.0, 2.0, 3.0));
884        assert_eq!(points[0].normal, Some(Vector3f::new(0.0, 0.0, 1.0)));
885        assert_eq!(points[1].position, Point3f::new(4.0, 5.0, 6.0));
886        assert_eq!(points[1].normal, Some(Vector3f::new(0.0, 1.0, 0.0)));
887
888        fs::remove_file(temp_file).unwrap();
889    }
890
891    #[test]
892    fn test_xyz_writer() {
893        let temp_file = "test_write.xyz";
894        let cloud = PointCloud::from_points(vec![
895            Point3f::new(1.0, 2.0, 3.0),
896            Point3f::new(4.0, 5.0, 6.0),
897        ]);
898
899        let options = XyzCsvWriteOptions::xyz();
900        XyzCsvWriter::write_point_cloud(&cloud, temp_file, &options).unwrap();
901
902        let content = fs::read_to_string(temp_file).unwrap();
903        assert!(content.contains("1 2 3"));
904        assert!(content.contains("4 5 6"));
905
906        fs::remove_file(temp_file).unwrap();
907    }
908
909    #[test]
910    fn test_csv_writer_with_header() {
911        let temp_file = "test_write_header.csv";
912        let cloud = PointCloud::from_points(vec![
913            Point3f::new(1.0, 2.0, 3.0),
914            Point3f::new(4.0, 5.0, 6.0),
915        ]);
916
917        let options = XyzCsvWriteOptions::csv_with_header();
918        XyzCsvWriter::write_point_cloud(&cloud, temp_file, &options).unwrap();
919
920        let content = fs::read_to_string(temp_file).unwrap();
921        assert!(content.starts_with("x,y,z"));
922        assert!(content.contains("1,2,3"));
923        assert!(content.contains("4,5,6"));
924
925        fs::remove_file(temp_file).unwrap();
926    }
927
928    #[test]
929    fn test_detailed_points_writer() {
930        let temp_file = "test_detailed.csv";
931        let points = vec![
932            XyzCsvPoint::with_color(Point3f::new(1.0, 2.0, 3.0), [255, 0, 0]),
933            XyzCsvPoint::with_intensity(Point3f::new(4.0, 5.0, 6.0), 0.8),
934        ];
935
936        let options = XyzCsvWriteOptions::csv_complete();
937        XyzCsvWriter::write_detailed_points(&points, temp_file, &options).unwrap();
938
939        let content = fs::read_to_string(temp_file).unwrap();
940        assert!(content.starts_with("x,y,z,intensity,r,g,b,nx,ny,nz"));
941
942        fs::remove_file(temp_file).unwrap();
943    }
944
945    #[test]
946    fn test_schema_detection() {
947        let temp_file = "test_schema.csv";
948        let content = "x,y,z,intensity\n1.0,2.0,3.0,0.5\n4.0,5.0,6.0,0.8\n";
949        fs::write(temp_file, content).unwrap();
950
951        let schema = XyzCsvSchema::detect_from_file(temp_file).unwrap();
952        assert_eq!(schema.delimiter, Delimiter::Comma);
953        assert!(schema.has_header);
954        assert!(schema.columns.contains(&ColumnType::X));
955        assert!(schema.columns.contains(&ColumnType::Y));
956        assert!(schema.columns.contains(&ColumnType::Z));
957        assert!(schema.columns.contains(&ColumnType::Intensity));
958
959        fs::remove_file(temp_file).unwrap();
960    }
961
962    #[test]
963    fn test_error_handling() {
964        // Test missing coordinates
965        let temp_file = "test_error.xyz";
966        let content = "1.0 2.0\n"; // Missing z coordinate
967        fs::write(temp_file, content).unwrap();
968
969        let result = XyzCsvReader::read_point_cloud(temp_file);
970        assert!(result.is_err());
971
972        let _ = fs::remove_file(temp_file);
973    }
974
975    #[test]
976    fn test_registry_traits() {
977        use crate::registry::{PointCloudReader, PointCloudWriter};
978
979        let reader = XyzCsvReader;
980        let writer = XyzCsvWriter;
981
982        assert_eq!(reader.format_name(), "xyz_csv");
983        assert_eq!(writer.format_name(), "xyz_csv");
984
985        // Test can_read
986        assert!(reader.can_read(Path::new("test.xyz")));
987        assert!(reader.can_read(Path::new("test.csv")));
988        assert!(reader.can_read(Path::new("test.txt")));
989        assert!(!reader.can_read(Path::new("test.ply")));
990    }
991}