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)
385        let x = parts
386            [x_idx.ok_or_else(|| Error::InvalidData("Missing x coordinate".to_string()))?]
387        .parse::<f32>()
388        .map_err(|_| Error::InvalidData("Invalid x coordinate".to_string()))?;
389        let y = parts
390            [y_idx.ok_or_else(|| Error::InvalidData("Missing y coordinate".to_string()))?]
391        .parse::<f32>()
392        .map_err(|_| Error::InvalidData("Invalid y coordinate".to_string()))?;
393        let z = parts
394            [z_idx.ok_or_else(|| Error::InvalidData("Missing z coordinate".to_string()))?]
395        .parse::<f32>()
396        .map_err(|_| Error::InvalidData("Invalid z coordinate".to_string()))?;
397
398        let position = Point3f::new(x, y, z);
399
400        // Parse optional attributes
401        let intensity = if let Some(idx) = intensity_idx {
402            parts.get(idx).and_then(|s| s.parse::<f32>().ok())
403        } else {
404            None
405        };
406
407        let color = if let (Some(r_idx), Some(g_idx), Some(b_idx)) = (red_idx, green_idx, blue_idx)
408        {
409            if let (Some(r), Some(g), Some(b)) = (
410                parts.get(r_idx).and_then(|s| s.parse::<f32>().ok()),
411                parts.get(g_idx).and_then(|s| s.parse::<f32>().ok()),
412                parts.get(b_idx).and_then(|s| s.parse::<f32>().ok()),
413            ) {
414                Some([
415                    (r.clamp(0.0, 255.0) as u8),
416                    (g.clamp(0.0, 255.0) as u8),
417                    (b.clamp(0.0, 255.0) as u8),
418                ])
419            } else {
420                None
421            }
422        } else {
423            None
424        };
425
426        let normal = if let (Some(nx_idx), Some(ny_idx), Some(nz_idx)) = (nx_idx, ny_idx, nz_idx) {
427            if let (Some(nx), Some(ny), Some(nz)) = (
428                parts.get(nx_idx).and_then(|s| s.parse::<f32>().ok()),
429                parts.get(ny_idx).and_then(|s| s.parse::<f32>().ok()),
430                parts.get(nz_idx).and_then(|s| s.parse::<f32>().ok()),
431            ) {
432                Some(Vector3f::new(nx, ny, nz))
433            } else {
434                None
435            }
436        } else {
437            None
438        };
439
440        Ok(XyzCsvPoint {
441            position,
442            intensity,
443            color,
444            normal,
445        })
446    }
447}
448
449/// XYZ/CSV streaming reader for large files
450pub struct XyzCsvStreamingReader {
451    reader: BufReader<File>,
452    schema: XyzCsvSchema,
453    buffer: Vec<String>,
454    buffer_index: usize,
455    header_skipped: bool,
456}
457
458impl XyzCsvStreamingReader {
459    /// Create a new streaming reader
460    pub fn new<P: AsRef<Path>>(path: P, chunk_size: usize) -> Result<Self> {
461        let path_ref = path.as_ref();
462        let file = File::open(path_ref)?;
463        let reader = BufReader::with_capacity(chunk_size, file);
464        let schema = XyzCsvSchema::detect_from_file(path_ref)?;
465
466        Ok(Self {
467            reader,
468            schema,
469            buffer: Vec::new(),
470            buffer_index: 0,
471            header_skipped: false,
472        })
473    }
474
475    /// Fill the buffer with the next chunk of lines
476    fn fill_buffer(&mut self) -> Result<bool> {
477        self.buffer.clear();
478        self.buffer_index = 0;
479
480        for _ in 0..1000 {
481            // Read up to 1000 lines at a time
482            let mut line = String::new();
483            match self.reader.read_line(&mut line)? {
484                0 => break, // EOF
485                _ => {
486                    if !line.trim().is_empty() {
487                        self.buffer.push(line);
488                    }
489                }
490            }
491        }
492
493        Ok(!self.buffer.is_empty())
494    }
495}
496
497impl Iterator for XyzCsvStreamingReader {
498    type Item = Result<Point3f>;
499
500    fn next(&mut self) -> Option<Self::Item> {
501        // Skip header on first read
502        if !self.header_skipped && self.schema.has_header {
503            let mut line = String::new();
504            if self.reader.read_line(&mut line).is_err() {
505                return None;
506            }
507            self.header_skipped = true;
508        }
509
510        // Fill buffer if needed
511        if self.buffer_index >= self.buffer.len() {
512            match self.fill_buffer() {
513                Ok(true) => {}            // Buffer filled successfully
514                Ok(false) => return None, // EOF
515                Err(e) => return Some(Err(e)),
516            }
517        }
518
519        // Get next line from buffer
520        if self.buffer_index < self.buffer.len() {
521            let line = &self.buffer[self.buffer_index];
522            self.buffer_index += 1;
523
524            match XyzCsvReader::parse_line(line, &self.schema) {
525                Ok(point) => Some(Ok(point.position)),
526                Err(e) => Some(Err(e)),
527            }
528        } else {
529            None
530        }
531    }
532}
533
534/// XYZ/CSV writer implementation
535pub struct XyzCsvWriter;
536
537impl XyzCsvWriter {
538    /// Write a point cloud to an XYZ/CSV file
539    pub fn write_point_cloud<P: AsRef<Path>>(
540        cloud: &PointCloud<Point3f>,
541        path: P,
542        options: &XyzCsvWriteOptions,
543    ) -> Result<()> {
544        let file = File::create(path)?;
545        let mut writer = BufWriter::new(file);
546
547        // Write header if requested
548        if options.include_header {
549            let header = Self::generate_header(&options.schema);
550            writeln!(writer, "{}", header)?;
551        }
552
553        // Write points
554        for point in cloud.iter() {
555            let line = Self::format_point(point, &options.schema);
556            writeln!(writer, "{}", line)?;
557        }
558
559        writer.flush()?;
560        Ok(())
561    }
562
563    /// Write detailed points to an XYZ/CSV file
564    pub fn write_detailed_points<P: AsRef<Path>>(
565        points: &[XyzCsvPoint],
566        path: P,
567        options: &XyzCsvWriteOptions,
568    ) -> Result<()> {
569        let file = File::create(path)?;
570        let mut writer = BufWriter::new(file);
571
572        // Write header if requested
573        if options.include_header {
574            let header = Self::generate_header(&options.schema);
575            writeln!(writer, "{}", header)?;
576        }
577
578        // Write points
579        for point in points {
580            let line = Self::format_detailed_point(point, &options.schema);
581            writeln!(writer, "{}", line)?;
582        }
583
584        writer.flush()?;
585        Ok(())
586    }
587
588    /// Generate header line
589    fn generate_header(schema: &XyzCsvSchema) -> String {
590        let headers: Vec<&str> = schema
591            .columns
592            .iter()
593            .map(|col| match col {
594                ColumnType::X => "x",
595                ColumnType::Y => "y",
596                ColumnType::Z => "z",
597                ColumnType::Intensity => "intensity",
598                ColumnType::Red => "r",
599                ColumnType::Green => "g",
600                ColumnType::Blue => "b",
601                ColumnType::NormalX => "nx",
602                ColumnType::NormalY => "ny",
603                ColumnType::NormalZ => "nz",
604                ColumnType::Unknown => "unknown",
605            })
606            .collect();
607
608        headers.join(&schema.delimiter.as_char().to_string())
609    }
610
611    /// Format a basic point
612    fn format_point(point: &Point3f, schema: &XyzCsvSchema) -> String {
613        let mut values = Vec::new();
614
615        for col_type in &schema.columns {
616            match col_type {
617                ColumnType::X => values.push(point.x.to_string()),
618                ColumnType::Y => values.push(point.y.to_string()),
619                ColumnType::Z => values.push(point.z.to_string()),
620                _ => values.push("0".to_string()), // Default value for missing columns
621            }
622        }
623
624        values.join(&schema.delimiter.as_char().to_string())
625    }
626
627    /// Format a detailed point
628    fn format_detailed_point(point: &XyzCsvPoint, schema: &XyzCsvSchema) -> String {
629        let mut values = Vec::new();
630
631        for col_type in &schema.columns {
632            let value = match col_type {
633                ColumnType::X => point.position.x.to_string(),
634                ColumnType::Y => point.position.y.to_string(),
635                ColumnType::Z => point.position.z.to_string(),
636                ColumnType::Intensity => point.intensity.unwrap_or(0.0).to_string(),
637                ColumnType::Red => point.color.map(|c| c[0] as f32).unwrap_or(0.0).to_string(),
638                ColumnType::Green => point.color.map(|c| c[1] as f32).unwrap_or(0.0).to_string(),
639                ColumnType::Blue => point.color.map(|c| c[2] as f32).unwrap_or(0.0).to_string(),
640                ColumnType::NormalX => point.normal.map(|n| n.x).unwrap_or(0.0).to_string(),
641                ColumnType::NormalY => point.normal.map(|n| n.y).unwrap_or(0.0).to_string(),
642                ColumnType::NormalZ => point.normal.map(|n| n.z).unwrap_or(0.0).to_string(),
643                ColumnType::Unknown => "0".to_string(),
644            };
645            values.push(value);
646        }
647
648        values.join(&schema.delimiter.as_char().to_string())
649    }
650}
651
652/// Write options for XYZ/CSV files
653#[derive(Debug, Clone)]
654pub struct XyzCsvWriteOptions {
655    pub schema: XyzCsvSchema,
656    pub include_header: bool,
657}
658
659impl XyzCsvWriteOptions {
660    /// Create basic options for XYZ format
661    pub fn xyz() -> Self {
662        Self {
663            schema: XyzCsvSchema::new(
664                vec![ColumnType::X, ColumnType::Y, ColumnType::Z],
665                false,
666                Delimiter::Space,
667            ),
668            include_header: false,
669        }
670    }
671
672    /// Create options for CSV format with header
673    pub fn csv_with_header() -> Self {
674        Self {
675            schema: XyzCsvSchema::new(
676                vec![ColumnType::X, ColumnType::Y, ColumnType::Z],
677                true,
678                Delimiter::Comma,
679            ),
680            include_header: true,
681        }
682    }
683
684    /// Create options for CSV with colors
685    pub fn csv_with_colors() -> Self {
686        Self {
687            schema: XyzCsvSchema::new(
688                vec![
689                    ColumnType::X,
690                    ColumnType::Y,
691                    ColumnType::Z,
692                    ColumnType::Red,
693                    ColumnType::Green,
694                    ColumnType::Blue,
695                ],
696                true,
697                Delimiter::Comma,
698            ),
699            include_header: true,
700        }
701    }
702
703    /// Create options for CSV with normals
704    pub fn csv_with_normals() -> Self {
705        Self {
706            schema: XyzCsvSchema::new(
707                vec![
708                    ColumnType::X,
709                    ColumnType::Y,
710                    ColumnType::Z,
711                    ColumnType::NormalX,
712                    ColumnType::NormalY,
713                    ColumnType::NormalZ,
714                ],
715                true,
716                Delimiter::Comma,
717            ),
718            include_header: true,
719        }
720    }
721
722    /// Create options for CSV with all attributes
723    pub fn csv_complete() -> Self {
724        Self {
725            schema: XyzCsvSchema::new(
726                vec![
727                    ColumnType::X,
728                    ColumnType::Y,
729                    ColumnType::Z,
730                    ColumnType::Intensity,
731                    ColumnType::Red,
732                    ColumnType::Green,
733                    ColumnType::Blue,
734                    ColumnType::NormalX,
735                    ColumnType::NormalY,
736                    ColumnType::NormalZ,
737                ],
738                true,
739                Delimiter::Comma,
740            ),
741            include_header: true,
742        }
743    }
744}
745
746// Implement the registry traits
747impl crate::registry::PointCloudReader for XyzCsvReader {
748    fn read_point_cloud(&self, path: &Path) -> Result<PointCloud<Point3f>> {
749        Self::read_point_cloud(path)
750    }
751
752    fn can_read(&self, path: &Path) -> bool {
753        // Check file extension
754        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
755            matches!(ext.to_lowercase().as_str(), "xyz" | "csv" | "txt")
756        } else {
757            false
758        }
759    }
760
761    fn format_name(&self) -> &'static str {
762        "xyz_csv"
763    }
764}
765
766impl crate::registry::PointCloudWriter for XyzCsvWriter {
767    fn write_point_cloud(&self, cloud: &PointCloud<Point3f>, path: &Path) -> Result<()> {
768        // Auto-detect format based on extension
769        let options = if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
770            match ext.to_lowercase().as_str() {
771                "xyz" => XyzCsvWriteOptions::xyz(),
772                "csv" => XyzCsvWriteOptions::csv_with_header(),
773                _ => XyzCsvWriteOptions::xyz(),
774            }
775        } else {
776            XyzCsvWriteOptions::xyz()
777        };
778
779        Self::write_point_cloud(cloud, path, &options)
780    }
781
782    fn format_name(&self) -> &'static str {
783        "xyz_csv"
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use std::fs;
791
792    #[test]
793    fn test_delimiter_detection() {
794        assert_eq!(Delimiter::detect_from_line("1,2,3"), Some(Delimiter::Comma));
795        assert_eq!(Delimiter::detect_from_line("1 2 3"), Some(Delimiter::Space));
796        assert_eq!(Delimiter::detect_from_line("1\t2\t3"), Some(Delimiter::Tab));
797        assert_eq!(
798            Delimiter::detect_from_line("1;2;3"),
799            Some(Delimiter::Semicolon)
800        );
801    }
802
803    #[test]
804    fn test_column_type_detection() {
805        assert_eq!(ColumnType::from_header("x"), ColumnType::X);
806        assert_eq!(ColumnType::from_header("X"), ColumnType::X);
807        assert_eq!(ColumnType::from_header("position_x"), ColumnType::X);
808        assert_eq!(ColumnType::from_header("intensity"), ColumnType::Intensity);
809        assert_eq!(ColumnType::from_header("red"), ColumnType::Red);
810        assert_eq!(ColumnType::from_header("nx"), ColumnType::NormalX);
811        assert_eq!(ColumnType::from_header("unknown"), ColumnType::Unknown);
812    }
813
814    #[test]
815    fn test_xyz_reader_basic() {
816        let temp_file = "test_basic.xyz";
817        let content = "1.0 2.0 3.0\n4.0 5.0 6.0\n7.0 8.0 9.0\n";
818        fs::write(temp_file, content).unwrap();
819
820        let cloud = XyzCsvReader::read_point_cloud(temp_file).unwrap();
821        assert_eq!(cloud.len(), 3);
822        assert_eq!(cloud[0], Point3f::new(1.0, 2.0, 3.0));
823        assert_eq!(cloud[1], Point3f::new(4.0, 5.0, 6.0));
824        assert_eq!(cloud[2], Point3f::new(7.0, 8.0, 9.0));
825
826        fs::remove_file(temp_file).unwrap();
827    }
828
829    #[test]
830    fn test_csv_reader_with_header() {
831        let temp_file = "test_header.csv";
832        let content = "x,y,z\n1.0,2.0,3.0\n4.0,5.0,6.0\n";
833        fs::write(temp_file, content).unwrap();
834
835        let cloud = XyzCsvReader::read_point_cloud(temp_file).unwrap();
836        assert_eq!(cloud.len(), 2);
837        assert_eq!(cloud[0], Point3f::new(1.0, 2.0, 3.0));
838        assert_eq!(cloud[1], Point3f::new(4.0, 5.0, 6.0));
839
840        fs::remove_file(temp_file).unwrap();
841    }
842
843    #[test]
844    fn test_csv_reader_with_colors() {
845        let temp_file = "test_colors.csv";
846        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";
847        fs::write(temp_file, content).unwrap();
848
849        let points = XyzCsvReader::read_detailed_points(temp_file).unwrap();
850        assert_eq!(points.len(), 2);
851        assert_eq!(points[0].position, Point3f::new(1.0, 2.0, 3.0));
852        assert_eq!(points[0].color, Some([255, 0, 0]));
853        assert_eq!(points[1].position, Point3f::new(4.0, 5.0, 6.0));
854        assert_eq!(points[1].color, Some([0, 255, 0]));
855
856        fs::remove_file(temp_file).unwrap();
857    }
858
859    #[test]
860    fn test_csv_reader_with_normals() {
861        let temp_file = "test_normals.csv";
862        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";
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].normal, Some(Vector3f::new(0.0, 0.0, 1.0)));
869        assert_eq!(points[1].position, Point3f::new(4.0, 5.0, 6.0));
870        assert_eq!(points[1].normal, Some(Vector3f::new(0.0, 1.0, 0.0)));
871
872        fs::remove_file(temp_file).unwrap();
873    }
874
875    #[test]
876    fn test_xyz_writer() {
877        let temp_file = "test_write.xyz";
878        let cloud = PointCloud::from_points(vec![
879            Point3f::new(1.0, 2.0, 3.0),
880            Point3f::new(4.0, 5.0, 6.0),
881        ]);
882
883        let options = XyzCsvWriteOptions::xyz();
884        XyzCsvWriter::write_point_cloud(&cloud, temp_file, &options).unwrap();
885
886        let content = fs::read_to_string(temp_file).unwrap();
887        assert!(content.contains("1 2 3"));
888        assert!(content.contains("4 5 6"));
889
890        fs::remove_file(temp_file).unwrap();
891    }
892
893    #[test]
894    fn test_csv_writer_with_header() {
895        let temp_file = "test_write_header.csv";
896        let cloud = PointCloud::from_points(vec![
897            Point3f::new(1.0, 2.0, 3.0),
898            Point3f::new(4.0, 5.0, 6.0),
899        ]);
900
901        let options = XyzCsvWriteOptions::csv_with_header();
902        XyzCsvWriter::write_point_cloud(&cloud, temp_file, &options).unwrap();
903
904        let content = fs::read_to_string(temp_file).unwrap();
905        assert!(content.starts_with("x,y,z"));
906        assert!(content.contains("1,2,3"));
907        assert!(content.contains("4,5,6"));
908
909        fs::remove_file(temp_file).unwrap();
910    }
911
912    #[test]
913    fn test_detailed_points_writer() {
914        let temp_file = "test_detailed.csv";
915        let points = vec![
916            XyzCsvPoint::with_color(Point3f::new(1.0, 2.0, 3.0), [255, 0, 0]),
917            XyzCsvPoint::with_intensity(Point3f::new(4.0, 5.0, 6.0), 0.8),
918        ];
919
920        let options = XyzCsvWriteOptions::csv_complete();
921        XyzCsvWriter::write_detailed_points(&points, temp_file, &options).unwrap();
922
923        let content = fs::read_to_string(temp_file).unwrap();
924        assert!(content.starts_with("x,y,z,intensity,r,g,b,nx,ny,nz"));
925
926        fs::remove_file(temp_file).unwrap();
927    }
928
929    #[test]
930    fn test_schema_detection() {
931        let temp_file = "test_schema.csv";
932        let content = "x,y,z,intensity\n1.0,2.0,3.0,0.5\n4.0,5.0,6.0,0.8\n";
933        fs::write(temp_file, content).unwrap();
934
935        let schema = XyzCsvSchema::detect_from_file(temp_file).unwrap();
936        assert_eq!(schema.delimiter, Delimiter::Comma);
937        assert!(schema.has_header);
938        assert!(schema.columns.contains(&ColumnType::X));
939        assert!(schema.columns.contains(&ColumnType::Y));
940        assert!(schema.columns.contains(&ColumnType::Z));
941        assert!(schema.columns.contains(&ColumnType::Intensity));
942
943        fs::remove_file(temp_file).unwrap();
944    }
945
946    #[test]
947    fn test_error_handling() {
948        // Test missing coordinates
949        let temp_file = "test_error.xyz";
950        let content = "1.0 2.0\n"; // Missing z coordinate
951        fs::write(temp_file, content).unwrap();
952
953        let result = XyzCsvReader::read_point_cloud(temp_file);
954        assert!(result.is_err());
955
956        let _ = fs::remove_file(temp_file);
957    }
958
959    #[test]
960    fn test_registry_traits() {
961        use crate::registry::{PointCloudReader, PointCloudWriter};
962
963        let reader = XyzCsvReader;
964        let writer = XyzCsvWriter;
965
966        assert_eq!(reader.format_name(), "xyz_csv");
967        assert_eq!(writer.format_name(), "xyz_csv");
968
969        // Test can_read
970        assert!(reader.can_read(Path::new("test.xyz")));
971        assert!(reader.can_read(Path::new("test.csv")));
972        assert!(reader.can_read(Path::new("test.txt")));
973        assert!(!reader.can_read(Path::new("test.ply")));
974    }
975}