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