Skip to main content

rigidity_io/
text.rs

1//! Delimited text: `.txt` and `.csv`.
2//!
3//! The format everyone has and nobody specified. A scanner, a script or a
4//! colleague hands over a file of numbers with one point per line, and the
5//! only things that vary are the delimiter and whether the first line names
6//! the columns or is already data.
7//!
8//! Both are decided by looking, and neither is guessed at more than once.
9//! [`read_text`] takes the delimiter from the first line that holds data and
10//! reads the whole file with it, rather than re-deciding per line — a file
11//! whose separator changes half way through is a broken file, and reading it
12//! anyway would turn a diagnosable error into silently wrong coordinates.
13//!
14//! [`write_text`] writes three numbers a line and nothing else: no header,
15//! because a header is the part most likely to make another tool refuse the
16//! file, and the reader here does not need one.
17
18use 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/// How a file separates its columns.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum Delimiter {
30    /// Runs of spaces or tabs, the usual `.txt`.
31    Whitespace,
32    /// A single character: `,` or `;`.
33    Character(char),
34}
35
36impl Delimiter {
37    /// Splits a line, keeping empty fields when the delimiter is a
38    /// character so that column positions survive a missing value.
39    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    /// Guesses from a line, preferring an explicit separator to whitespace.
47    ///
48    /// A comma decides it even when spaces are also present, because
49    /// `1.0, 2.0, 3.0` is comma-separated with padding and never three
50    /// fields of whitespace with stray commas attached.
51    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
61/// Whether a line is a comment or has nothing on it.
62fn skippable(line: &str) -> bool {
63    line.is_empty() || line.starts_with('#') || line.starts_with("//")
64}
65
66/// Finds the column whose name matches one of the candidates.
67fn 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
74/// Which fields hold the coordinates, and whether the first line is data.
75///
76/// A header is recognised by what it is not: if the first three columns of
77/// the first line all parse as numbers, the line is data and there is no
78/// header. That is a stronger test than looking for the letter `x`, because
79/// plenty of headers call the column `X (m)` or `//X` and plenty of data
80/// files start with a line that happens to contain letters in a later
81/// column.
82fn 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        // A header that does not name its coordinates still tells us it is
97        // a header. Falling back to the first three columns is the same
98        // assumption the headerless case makes, and it is better than
99        // refusing a file whose columns are called `East North Up`.
100        _ => ([0, 1, 2], true),
101    }
102}
103
104/// Reads a cloud from a delimited text file.
105///
106/// The origin is placed at the centre of the bounding box: text files are
107/// where global coordinates arrive, and `f32` storage cannot hold a UTM
108/// easting directly. See `PointCloud::with_origin`.
109pub fn read_text(path: &Path) -> Result<PointCloud, IoError> {
110    let mut reader = BufReader::new(File::open(path)?);
111
112    // The first line that is neither blank nor a comment decides both
113    // questions, and is then re-read as data if it turns out to be data.
114    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
179/// Writes a cloud as one point a line.
180///
181/// The delimiter follows the extension — a space for `.txt`, a comma for
182/// `.csv` — because that is the only thing either extension actually
183/// promises anyone.
184///
185/// Coordinates are absolute and `f64`, formatted at the shortest decimal
186/// that reads back as the identical value. Writing the stored `f32` offsets
187/// would be smaller and would lose a quarter of a metre at four million
188/// metres north, which is the mistake PCD made here once already and the
189/// whole reason the core stores an `f64` origin.
190pub 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    /// Writes `body` to a uniquely named temporary file and reads it back.
215    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    /// The same three points, said eight ways.
235    ///
236    /// Every one of these is a file somebody has actually handed over, and
237    /// the round-trip test cannot reach any of them: it only ever reads
238    /// back what this module's own writer produced, which is one of the
239    /// eight.
240    #[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    /// Columns are found by name, not by position, when there is a header.
262    #[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    /// Columns past the third are ignored rather than refused.
273    #[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    /// A header whose columns are not called x, y and z is still a header.
281    ///
282    /// Falling through to the first three columns makes the same assumption
283    /// the headerless case makes; refusing the file would be a reader that
284    /// knows the answer and declines to give it.
285    #[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    /// The extension picks the separator, and only the extension.
293    #[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    /// A file with nothing in it is an error, not an empty cloud.
313    ///
314    /// The difference matters: an empty cloud reads as "this scan saw
315    /// nothing", and a scanner that saw nothing is a different problem from
316    /// a file that was never written.
317    #[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    /// A number that is not one is reported rather than skipped.
328    #[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}