1use std::fs::File;
11use std::io::{BufRead, BufReader, BufWriter, Write};
12use std::path::Path;
13use threecrate_core::{Error, Point3f, PointCloud, Result, Vector3f};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Delimiter {
18 Comma,
19 Space,
20 Tab,
21 Semicolon,
22}
23
24impl Delimiter {
25 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 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 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#[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 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#[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 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 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 let delimiter = Delimiter::detect_from_line(&first_line)
123 .ok_or_else(|| Error::InvalidData("Could not detect delimiter".to_string()))?;
124
125 let has_header = Self::is_header_line(&first_line, &[], delimiter);
127
128 let columns = if has_header {
129 Self::parse_columns(&first_line, delimiter)?
131 } else {
132 vec![ColumnType::X, ColumnType::Y, ColumnType::Z]
134 };
135
136 Ok(Self::new(columns, has_header, delimiter))
137 }
138
139 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 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 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 parts.len() < 3 {
168 return false;
169 }
170
171 if !columns.is_empty() {
173 if parts.len() < columns.len() {
175 return false;
176 }
177
178 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; }
195 }
196 ColumnType::Unknown => {
197 if part.parse::<f32>().is_err() {
199 return true;
200 }
201 }
202 }
203 }
204 }
205 } else {
206 for (_i, part) in parts.iter().enumerate().take(3) {
208 if part.parse::<f32>().is_err() {
209 return true; }
211 }
212 }
213
214 false
215 }
216}
217
218#[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 pub fn new(position: Point3f) -> Self {
230 Self {
231 position,
232 intensity: None,
233 color: None,
234 normal: None,
235 }
236 }
237
238 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 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 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
269pub struct XyzCsvReader;
271
272impl XyzCsvReader {
273 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 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 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 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 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 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 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 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 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 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
447pub 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 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 fn fill_buffer(&mut self) -> Result<bool> {
475 self.buffer.clear();
476 self.buffer_index = 0;
477
478 for _ in 0..1000 {
479 let mut line = String::new();
481 match self.reader.read_line(&mut line)? {
482 0 => break, _ => {
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 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 if self.buffer_index >= self.buffer.len() {
510 match self.fill_buffer() {
511 Ok(true) => {} Ok(false) => return None, Err(e) => return Some(Err(e)),
514 }
515 }
516
517 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
532pub struct XyzCsvWriter;
534
535impl XyzCsvWriter {
536 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 if options.include_header {
547 let header = Self::generate_header(&options.schema);
548 writeln!(writer, "{}", header)?;
549 }
550
551 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 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 if options.include_header {
572 let header = Self::generate_header(&options.schema);
573 writeln!(writer, "{}", header)?;
574 }
575
576 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 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 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()), }
620 }
621
622 values.join(&schema.delimiter.as_char().to_string())
623 }
624
625 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#[derive(Debug, Clone)]
652pub struct XyzCsvWriteOptions {
653 pub schema: XyzCsvSchema,
654 pub include_header: bool,
655}
656
657impl XyzCsvWriteOptions {
658 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 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 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 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 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
744impl 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 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 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 let temp_file = "test_error.xyz";
966 let content = "1.0 2.0\n"; 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 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}