1use std::fs::File;
19use std::io::{BufRead, BufReader, BufWriter, Write};
20use std::path::Path;
21
22use nalgebra::Vector3;
23use rigidity_core::PointCloud;
24
25use crate::IoError;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum Delimiter {
30 Whitespace,
32 Character(char),
34}
35
36impl Delimiter {
37 fn split(self, line: &str) -> Vec<&str> {
40 match self {
41 Self::Whitespace => line.split_whitespace().collect(),
42 Self::Character(c) => line.split(c).map(str::trim).collect(),
43 }
44 }
45
46 fn of(line: &str) -> Self {
52 for candidate in [',', ';'] {
53 if line.contains(candidate) {
54 return Self::Character(candidate);
55 }
56 }
57 Self::Whitespace
58 }
59}
60
61fn skippable(line: &str) -> bool {
63 line.is_empty() || line.starts_with('#') || line.starts_with("//")
64}
65
66fn named(header: &[&str], names: &[&str]) -> Option<usize> {
68 header.iter().position(|column| {
69 let trimmed = column.trim().trim_matches('"').to_ascii_lowercase();
70 names.iter().any(|candidate| trimmed == *candidate)
71 })
72}
73
74fn columns(first: &[&str]) -> ([usize; 3], bool) {
83 let numeric = first
84 .iter()
85 .take(3)
86 .filter(|field| field.trim().parse::<f64>().is_ok())
87 .count();
88 if numeric == 3 && first.len() >= 3 {
89 return ([0, 1, 2], false);
90 }
91 let x = named(first, &["x", "x(m)", "x [m]", "//x"]);
92 let y = named(first, &["y", "y(m)", "y [m]"]);
93 let z = named(first, &["z", "z(m)", "z [m]"]);
94 match (x, y, z) {
95 (Some(x), Some(y), Some(z)) => ([x, y, z], true),
96 _ => ([0, 1, 2], true),
101 }
102}
103
104pub fn read_text(path: &Path) -> Result<PointCloud, IoError> {
110 let mut reader = BufReader::new(File::open(path)?);
111
112 let mut line = String::new();
115 let first = loop {
116 line.clear();
117 if reader.read_line(&mut line)? == 0 {
118 return Err(IoError::BadHeader("the file holds no data".into()));
119 }
120 if !skippable(line.trim()) {
121 break line.trim().to_owned();
122 }
123 };
124
125 let delimiter = Delimiter::of(&first);
126 let fields = delimiter.split(&first);
127 let ([x, y, z], had_header) = columns(&fields);
128 let needed = x.max(y).max(z) + 1;
129
130 let mut points: Vec<Vector3<f64>> = Vec::new();
131 let mut minimum = Vector3::repeat(f64::INFINITY);
132 let mut maximum = Vector3::repeat(f64::NEG_INFINITY);
133
134 let mut take = |fields: &[&str]| -> Result<(), IoError> {
135 if fields.len() < needed {
136 return Ok(());
137 }
138 let parse = |index: usize| -> Result<f64, IoError> {
139 fields[index]
140 .trim()
141 .parse()
142 .map_err(|_| IoError::BadNumber(fields[index].to_string()))
143 };
144 let point = Vector3::new(parse(x)?, parse(y)?, parse(z)?);
145 if point.iter().all(|value| value.is_finite()) {
146 minimum = minimum.inf(&point);
147 maximum = maximum.sup(&point);
148 points.push(point);
149 }
150 Ok(())
151 };
152
153 if !had_header {
154 take(&fields)?;
155 }
156 loop {
157 line.clear();
158 if reader.read_line(&mut line)? == 0 {
159 break;
160 }
161 let trimmed = line.trim();
162 if skippable(trimmed) {
163 continue;
164 }
165 take(&delimiter.split(trimmed))?;
166 }
167
168 if points.is_empty() {
169 return Ok(PointCloud::new());
170 }
171 let origin = (minimum + maximum) * 0.5;
172 let mut cloud = PointCloud::with_origin(origin);
173 for point in &points {
174 cloud.push(*point);
175 }
176 Ok(cloud)
177}
178
179pub fn write_text(cloud: &PointCloud, path: &Path) -> Result<(), IoError> {
191 let comma = path
192 .extension()
193 .and_then(|end| end.to_str())
194 .is_some_and(|end| end.eq_ignore_ascii_case("csv"));
195 let separator = if comma { "," } else { " " };
196
197 let mut out = BufWriter::new(File::create(path)?);
198 for index in 0..cloud.len() {
199 let point = cloud.point(index);
200 writeln!(
201 out,
202 "{:?}{separator}{:?}{separator}{:?}",
203 point.x, point.y, point.z
204 )?;
205 }
206 out.flush()?;
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn read(name: &str, body: &str) -> Result<PointCloud, IoError> {
216 let path = std::env::temp_dir().join(format!("rigidity-io-text-{name}"));
217 std::fs::write(&path, body).expect("a temporary file");
218 let cloud = read_text(&path);
219 std::fs::remove_file(&path).ok();
220 cloud
221 }
222
223 fn points(cloud: &PointCloud) -> Vec<[f64; 3]> {
224 (0..cloud.len())
225 .map(|index| {
226 let p = cloud.point(index);
227 [p.x, p.y, p.z]
228 })
229 .collect()
230 }
231
232 const EXPECTED: [[f64; 3]; 3] = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.5]];
233
234 #[test]
241 fn the_shapes_a_text_file_arrives_in() {
242 let cases: [(&str, &str); 8] = [
243 ("bare-space", "1 2 3\n4 5 6\n7 8 9.5\n"),
244 ("bare-tab", "1\t2\t3\n4\t5\t6\n7\t8\t9.5\n"),
245 ("ragged-space", " 1 2 3\n4 5 6\n 7 8 9.5 \n"),
246 ("bare-comma", "1,2,3\n4,5,6\n7,8,9.5\n"),
247 ("padded-comma", "1, 2, 3\n4, 5, 6\n7, 8, 9.5\n"),
248 ("semicolon", "1;2;3\n4;5;6\n7;8;9.5\n"),
249 ("named-header", "x,y,z\n1,2,3\n4,5,6\n7,8,9.5\n"),
250 (
251 "comments-and-blanks",
252 "# station 4\n\n1 2 3\n\n// noise\n4 5 6\n7 8 9.5\n",
253 ),
254 ];
255 for (name, body) in cases {
256 let cloud = read(name, body).unwrap_or_else(|e| panic!("{name}: {e}"));
257 assert_eq!(points(&cloud), EXPECTED, "{name}");
258 }
259 }
260
261 #[test]
263 fn a_header_puts_the_columns_where_it_says() {
264 let cloud = read(
265 "reordered",
266 "id,z,intensity,x,y\n0,3,-1,1,2\n1,6,-1,4,5\n2,9.5,-1,7,8\n",
267 )
268 .expect("a reordered header should be read by name");
269 assert_eq!(points(&cloud), EXPECTED);
270 }
271
272 #[test]
274 fn extra_columns_are_not_an_obstacle() {
275 let cloud = read("extra", "1 2 3 128 0.4\n4 5 6 130 0.5\n7 8 9.5 99 0.6\n")
276 .expect("intensity and the rest are somebody else's business");
277 assert_eq!(points(&cloud), EXPECTED);
278 }
279
280 #[test]
286 fn an_unnamed_header_is_recognised_as_one() {
287 let cloud = read("east-north-up", "East North Up\n1 2 3\n4 5 6\n7 8 9.5\n")
288 .expect("a header that does not name x should not lose its file");
289 assert_eq!(points(&cloud), EXPECTED);
290 }
291
292 #[test]
294 fn csv_is_written_with_commas_and_txt_with_spaces() {
295 let mut cloud = PointCloud::new();
296 for point in EXPECTED {
297 cloud.push(Vector3::new(point[0], point[1], point[2]));
298 }
299 for (extension, separator) in [("txt", " "), ("csv", ",")] {
300 let path = std::env::temp_dir().join(format!("rigidity-io-sep.{extension}"));
301 write_text(&cloud, &path).expect("the cloud should write");
302 let text = std::fs::read_to_string(&path).expect("and be readable");
303 assert_eq!(
304 text.lines().next(),
305 Some(format!("1.0{separator}2.0{separator}3.0").as_str()),
306 "{extension}"
307 );
308 std::fs::remove_file(&path).ok();
309 }
310 }
311
312 #[test]
318 fn an_empty_file_says_so() {
319 for (name, body) in [("empty", ""), ("only-comments", "# nothing here\n\n")] {
320 assert!(
321 matches!(read(name, body), Err(IoError::BadHeader(_))),
322 "{name} was accepted"
323 );
324 }
325 }
326
327 #[test]
329 fn a_bad_number_is_an_error() {
330 assert!(matches!(
331 read("bad-number", "1 2 3\n4 five 6\n"),
332 Err(IoError::BadNumber(_))
333 ));
334 }
335}