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 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 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
449pub 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 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 fn fill_buffer(&mut self) -> Result<bool> {
477 self.buffer.clear();
478 self.buffer_index = 0;
479
480 for _ in 0..1000 {
481 let mut line = String::new();
483 match self.reader.read_line(&mut line)? {
484 0 => break, _ => {
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 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 if self.buffer_index >= self.buffer.len() {
512 match self.fill_buffer() {
513 Ok(true) => {} Ok(false) => return None, Err(e) => return Some(Err(e)),
516 }
517 }
518
519 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
534pub struct XyzCsvWriter;
536
537impl XyzCsvWriter {
538 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 if options.include_header {
549 let header = Self::generate_header(&options.schema);
550 writeln!(writer, "{}", header)?;
551 }
552
553 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 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 if options.include_header {
574 let header = Self::generate_header(&options.schema);
575 writeln!(writer, "{}", header)?;
576 }
577
578 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 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 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()), }
622 }
623
624 values.join(&schema.delimiter.as_char().to_string())
625 }
626
627 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#[derive(Debug, Clone)]
654pub struct XyzCsvWriteOptions {
655 pub schema: XyzCsvSchema,
656 pub include_header: bool,
657}
658
659impl XyzCsvWriteOptions {
660 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 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 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 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 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
746impl 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 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 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 let temp_file = "test_error.xyz";
950 let content = "1.0 2.0\n"; 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 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}