use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use nalgebra::Vector3;
use rigidity_core::PointCloud;
use crate::IoError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Delimiter {
Whitespace,
Character(char),
}
impl Delimiter {
fn split(self, line: &str) -> Vec<&str> {
match self {
Self::Whitespace => line.split_whitespace().collect(),
Self::Character(c) => line.split(c).map(str::trim).collect(),
}
}
fn of(line: &str) -> Self {
for candidate in [',', ';'] {
if line.contains(candidate) {
return Self::Character(candidate);
}
}
Self::Whitespace
}
}
fn skippable(line: &str) -> bool {
line.is_empty() || line.starts_with('#') || line.starts_with("//")
}
fn named(header: &[&str], names: &[&str]) -> Option<usize> {
header.iter().position(|column| {
let trimmed = column.trim().trim_matches('"').to_ascii_lowercase();
names.iter().any(|candidate| trimmed == *candidate)
})
}
fn columns(first: &[&str]) -> ([usize; 3], bool) {
let numeric = first
.iter()
.take(3)
.filter(|field| field.trim().parse::<f64>().is_ok())
.count();
if numeric == 3 && first.len() >= 3 {
return ([0, 1, 2], false);
}
let x = named(first, &["x", "x(m)", "x [m]", "//x"]);
let y = named(first, &["y", "y(m)", "y [m]"]);
let z = named(first, &["z", "z(m)", "z [m]"]);
match (x, y, z) {
(Some(x), Some(y), Some(z)) => ([x, y, z], true),
_ => ([0, 1, 2], true),
}
}
pub fn read_text(path: &Path) -> Result<PointCloud, IoError> {
let mut reader = BufReader::new(File::open(path)?);
let mut line = String::new();
let first = loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
return Err(IoError::BadHeader("the file holds no data".into()));
}
if !skippable(line.trim()) {
break line.trim().to_owned();
}
};
let delimiter = Delimiter::of(&first);
let fields = delimiter.split(&first);
let ([x, y, z], had_header) = columns(&fields);
let needed = x.max(y).max(z) + 1;
let mut points: Vec<Vector3<f64>> = Vec::new();
let mut minimum = Vector3::repeat(f64::INFINITY);
let mut maximum = Vector3::repeat(f64::NEG_INFINITY);
let mut take = |fields: &[&str]| -> Result<(), IoError> {
if fields.len() < needed {
return Ok(());
}
let parse = |index: usize| -> Result<f64, IoError> {
fields[index]
.trim()
.parse()
.map_err(|_| IoError::BadNumber(fields[index].to_string()))
};
let point = Vector3::new(parse(x)?, parse(y)?, parse(z)?);
if point.iter().all(|value| value.is_finite()) {
minimum = minimum.inf(&point);
maximum = maximum.sup(&point);
points.push(point);
}
Ok(())
};
if !had_header {
take(&fields)?;
}
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
break;
}
let trimmed = line.trim();
if skippable(trimmed) {
continue;
}
take(&delimiter.split(trimmed))?;
}
if points.is_empty() {
return Ok(PointCloud::new());
}
let origin = (minimum + maximum) * 0.5;
let mut cloud = PointCloud::with_origin(origin);
for point in &points {
cloud.push(*point);
}
Ok(cloud)
}
pub fn write_text(cloud: &PointCloud, path: &Path) -> Result<(), IoError> {
let comma = path
.extension()
.and_then(|end| end.to_str())
.is_some_and(|end| end.eq_ignore_ascii_case("csv"));
let separator = if comma { "," } else { " " };
let mut out = BufWriter::new(File::create(path)?);
for index in 0..cloud.len() {
let point = cloud.point(index);
writeln!(
out,
"{:?}{separator}{:?}{separator}{:?}",
point.x, point.y, point.z
)?;
}
out.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn read(name: &str, body: &str) -> Result<PointCloud, IoError> {
let path = std::env::temp_dir().join(format!("rigidity-io-text-{name}"));
std::fs::write(&path, body).expect("a temporary file");
let cloud = read_text(&path);
std::fs::remove_file(&path).ok();
cloud
}
fn points(cloud: &PointCloud) -> Vec<[f64; 3]> {
(0..cloud.len())
.map(|index| {
let p = cloud.point(index);
[p.x, p.y, p.z]
})
.collect()
}
const EXPECTED: [[f64; 3]; 3] = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.5]];
#[test]
fn the_shapes_a_text_file_arrives_in() {
let cases: [(&str, &str); 8] = [
("bare-space", "1 2 3\n4 5 6\n7 8 9.5\n"),
("bare-tab", "1\t2\t3\n4\t5\t6\n7\t8\t9.5\n"),
("ragged-space", " 1 2 3\n4 5 6\n 7 8 9.5 \n"),
("bare-comma", "1,2,3\n4,5,6\n7,8,9.5\n"),
("padded-comma", "1, 2, 3\n4, 5, 6\n7, 8, 9.5\n"),
("semicolon", "1;2;3\n4;5;6\n7;8;9.5\n"),
("named-header", "x,y,z\n1,2,3\n4,5,6\n7,8,9.5\n"),
(
"comments-and-blanks",
"# station 4\n\n1 2 3\n\n// noise\n4 5 6\n7 8 9.5\n",
),
];
for (name, body) in cases {
let cloud = read(name, body).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(points(&cloud), EXPECTED, "{name}");
}
}
#[test]
fn a_header_puts_the_columns_where_it_says() {
let cloud = read(
"reordered",
"id,z,intensity,x,y\n0,3,-1,1,2\n1,6,-1,4,5\n2,9.5,-1,7,8\n",
)
.expect("a reordered header should be read by name");
assert_eq!(points(&cloud), EXPECTED);
}
#[test]
fn extra_columns_are_not_an_obstacle() {
let cloud = read("extra", "1 2 3 128 0.4\n4 5 6 130 0.5\n7 8 9.5 99 0.6\n")
.expect("intensity and the rest are somebody else's business");
assert_eq!(points(&cloud), EXPECTED);
}
#[test]
fn an_unnamed_header_is_recognised_as_one() {
let cloud = read("east-north-up", "East North Up\n1 2 3\n4 5 6\n7 8 9.5\n")
.expect("a header that does not name x should not lose its file");
assert_eq!(points(&cloud), EXPECTED);
}
#[test]
fn csv_is_written_with_commas_and_txt_with_spaces() {
let mut cloud = PointCloud::new();
for point in EXPECTED {
cloud.push(Vector3::new(point[0], point[1], point[2]));
}
for (extension, separator) in [("txt", " "), ("csv", ",")] {
let path = std::env::temp_dir().join(format!("rigidity-io-sep.{extension}"));
write_text(&cloud, &path).expect("the cloud should write");
let text = std::fs::read_to_string(&path).expect("and be readable");
assert_eq!(
text.lines().next(),
Some(format!("1.0{separator}2.0{separator}3.0").as_str()),
"{extension}"
);
std::fs::remove_file(&path).ok();
}
}
#[test]
fn an_empty_file_says_so() {
for (name, body) in [("empty", ""), ("only-comments", "# nothing here\n\n")] {
assert!(
matches!(read(name, body), Err(IoError::BadHeader(_))),
"{name} was accepted"
);
}
}
#[test]
fn a_bad_number_is_an_error() {
assert!(matches!(
read("bad-number", "1 2 3\n4 five 6\n"),
Err(IoError::BadNumber(_))
));
}
}