1use threecrate_core::{PointCloud, Result, Point3f, Vector3f, Error};
11use std::path::Path;
12use std::fs::File;
13use std::io::{BufRead, BufReader, BufWriter, Write};
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.iter()
51 .max_by_key(|(count, _)| count)
52 .filter(|(count, _)| *count > 0)
53 .map(|(_, delimiter)| *delimiter)
54 }
55}
56
57#[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 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#[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 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 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 let delimiter = Delimiter::detect_from_line(&first_line)
122 .ok_or_else(|| Error::InvalidData("Could not detect delimiter".to_string()))?;
123
124 let has_header = Self::is_header_line(&first_line, &[], delimiter);
126
127 let columns = if has_header {
128 Self::parse_columns(&first_line, delimiter)?
130 } else {
131 vec![ColumnType::X, ColumnType::Y, ColumnType::Z]
133 };
134
135 Ok(Self::new(columns, has_header, delimiter))
136 }
137
138 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 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())
165 .map(|s| s.trim())
166 .collect();
167
168 if parts.len() < 3 {
170 return false;
171 }
172
173 if !columns.is_empty() {
175 if parts.len() < columns.len() {
177 return false;
178 }
179
180 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; }
190 }
191 ColumnType::Unknown => {
192 if part.parse::<f32>().is_err() {
194 return true;
195 }
196 }
197 }
198 }
199 }
200 } else {
201 for (_i, part) in parts.iter().enumerate().take(3) {
203 if part.parse::<f32>().is_err() {
204 return true; }
206 }
207 }
208
209 false
210 }
211}
212
213#[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 pub fn new(position: Point3f) -> Self {
225 Self {
226 position,
227 intensity: None,
228 color: None,
229 normal: None,
230 }
231 }
232
233 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 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 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
264pub struct XyzCsvReader;
266
267impl XyzCsvReader {
268 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 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 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 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 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 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 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 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 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 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
439pub 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 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 fn fill_buffer(&mut self) -> Result<bool> {
467 self.buffer.clear();
468 self.buffer_index = 0;
469
470 for _ in 0..1000 { let mut line = String::new();
472 match self.reader.read_line(&mut line)? {
473 0 => break, _ => {
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 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 if self.buffer_index >= self.buffer.len() {
501 match self.fill_buffer() {
502 Ok(true) => {}, Ok(false) => return None, Err(e) => return Some(Err(e)),
505 }
506 }
507
508 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
523pub struct XyzCsvWriter;
525
526impl XyzCsvWriter {
527 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 if options.include_header {
538 let header = Self::generate_header(&options.schema);
539 writeln!(writer, "{}", header)?;
540 }
541
542 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 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 if options.include_header {
563 let header = Self::generate_header(&options.schema);
564 writeln!(writer, "{}", header)?;
565 }
566
567 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 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 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()), }
607 }
608
609 values.join(&schema.delimiter.as_char().to_string())
610 }
611
612 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#[derive(Debug, Clone)]
639pub struct XyzCsvWriteOptions {
640 pub schema: XyzCsvSchema,
641 pub include_header: bool,
642}
643
644impl XyzCsvWriteOptions {
645 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 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 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 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 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
717impl 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 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 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 let temp_file = "test_error.xyz";
918 let content = "1.0 2.0\n"; 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 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}