1use ogeom_core::{OgeomResult, ogeom_bail};
18use ogeom_math::{Point, Vector};
19use std::fmt::Write as _;
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct Scalar {
24 pub name: String,
26 pub values: Vec<f64>,
28}
29
30#[derive(Debug, Clone, Default, PartialEq)]
35pub struct PointCloud {
36 pub points: Vec<Point>,
38 pub normals: Option<Vec<Vector>>,
40 pub colours: Option<Vec<[f64; 4]>>,
42 pub scalars: Vec<Scalar>,
44}
45
46impl PointCloud {
47 #[must_use]
49 pub fn new(points: Vec<Point>) -> Self {
50 Self {
51 points,
52 ..Self::default()
53 }
54 }
55
56 #[must_use]
58 pub fn len(&self) -> usize {
59 self.points.len()
60 }
61
62 #[must_use]
64 pub fn is_empty(&self) -> bool {
65 self.points.is_empty()
66 }
67
68 #[must_use]
70 pub fn scalar(&self, name: &str) -> Option<&[f64]> {
71 self.scalars
72 .iter()
73 .find(|s| s.name == name)
74 .map(|s| s.values.as_slice())
75 }
76
77 pub fn check(&self) -> OgeomResult<()> {
84 let n = self.points.len();
85 if let Some(normals) = &self.normals
86 && normals.len() != n
87 {
88 ogeom_bail!(Dimension, "{} normals for {n} points", normals.len());
89 }
90 if let Some(colours) = &self.colours
91 && colours.len() != n
92 {
93 ogeom_bail!(Dimension, "{} colours for {n} points", colours.len());
94 }
95 for s in &self.scalars {
96 if s.values.len() != n {
97 ogeom_bail!(
98 Dimension,
99 "{} values of {} for {n} points",
100 s.values.len(),
101 s.name
102 );
103 }
104 }
105 Ok(())
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum XyzColumn {
112 X,
114 Y,
116 Z,
118 NormalX,
120 NormalY,
122 NormalZ,
124 Red,
126 Green,
128 Blue,
130 Scalar(String),
132 Skip,
134}
135
136impl XyzColumn {
137 fn named(word: &str) -> Self {
140 match word.to_ascii_lowercase().as_str() {
141 "x" => Self::X,
142 "y" => Self::Y,
143 "z" => Self::Z,
144 "nx" => Self::NormalX,
145 "ny" => Self::NormalY,
146 "nz" => Self::NormalZ,
147 "r" | "red" => Self::Red,
148 "g" | "green" => Self::Green,
149 "b" | "blue" => Self::Blue,
150 _ => Self::Scalar(word.to_owned()),
151 }
152 }
153
154 fn word(&self) -> &str {
155 match self {
156 Self::X => "x",
157 Self::Y => "y",
158 Self::Z => "z",
159 Self::NormalX => "nx",
160 Self::NormalY => "ny",
161 Self::NormalZ => "nz",
162 Self::Red => "red",
163 Self::Green => "green",
164 Self::Blue => "blue",
165 Self::Scalar(name) => name,
166 Self::Skip => "-",
167 }
168 }
169}
170
171fn fields(line: &str) -> impl Iterator<Item = &str> {
172 line.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
173 .filter(|f| !f.is_empty())
174}
175
176fn header(line: &str) -> Option<Vec<XyzColumn>> {
178 let rest = line
179 .strip_prefix("//")
180 .or_else(|| line.strip_prefix('#'))?
181 .trim_start_matches(|c: char| c == '#' || c == '/' || c.is_whitespace());
182 let rest = rest.strip_prefix("columns:").map_or(rest, str::trim_start);
183 let columns: Vec<XyzColumn> = fields(rest).map(XyzColumn::named).collect();
184 columns
185 .starts_with(&[XyzColumn::X, XyzColumn::Y, XyzColumn::Z])
186 .then_some(columns)
187}
188
189struct Table {
191 columns: Vec<Vec<f64>>,
192 header: Option<Vec<XyzColumn>>,
193}
194
195fn table(text: &str, counted: bool) -> OgeomResult<Table> {
198 let mut columns: Vec<Vec<f64>> = Vec::new();
199 let mut header_line = None;
200 let mut seen_data = false;
201 let mut owed: Option<usize> = None;
203 for (number, line) in text.lines().enumerate() {
204 let line = line.trim();
205 let number = number + 1;
206 if line.is_empty() {
207 continue;
208 }
209 if line.starts_with('#') || line.starts_with("//") {
210 if !seen_data && header_line.is_none() {
211 header_line = header(line);
212 }
213 continue;
214 }
215 let mut row = fields(line).peekable();
216 if counted && let Some(only) = row.peek().copied() {
217 let mut probe = fields(line);
218 probe.next();
219 if probe.next().is_none() {
220 let Ok(count) = only.parse::<usize>() else {
221 ogeom_bail!(
222 Construction,
223 "line {number} of the PTS holds one field, {only}, which is not a point count"
224 );
225 };
226 if let Some(left) = owed.filter(|&n| n > 0) {
227 ogeom_bail!(
228 Construction,
229 "line {number} opens a scan while the one before still owes {left} points"
230 );
231 }
232 owed = Some(count);
233 continue;
234 }
235 match owed.as_mut() {
236 None => ogeom_bail!(
237 Construction,
238 "the PTS has no point count before its first point, on line {number}"
239 ),
240 Some(0) => ogeom_bail!(
241 Construction,
242 "line {number} is a point past the count its scan gave"
243 ),
244 Some(n) => *n -= 1,
245 }
246 }
247 let mut width = 0;
248 for (k, field) in row.enumerate() {
249 let Ok(value) = field.parse::<f64>() else {
250 ogeom_bail!(
251 Construction,
252 "line {number} carries {field}, which is not a number"
253 );
254 };
255 if !value.is_finite() {
256 ogeom_bail!(
257 Construction,
258 "line {number} carries {field}, which is not finite"
259 );
260 }
261 if !seen_data {
262 columns.push(Vec::new());
263 } else if k >= columns.len() {
264 ogeom_bail!(
265 Construction,
266 "line {number} has more columns than the {} the first point has",
267 columns.len()
268 );
269 }
270 columns[k].push(value);
271 width = k + 1;
272 }
273 if seen_data && width != columns.len() {
274 ogeom_bail!(
275 Construction,
276 "line {number} has {width} columns where the first point has {}",
277 columns.len()
278 );
279 }
280 seen_data = true;
281 }
282 if let Some(left) = owed.filter(|&n| n > 0) {
283 ogeom_bail!(
284 Construction,
285 "the PTS ends with its last scan {left} points short of its count"
286 );
287 }
288 Ok(Table {
289 columns,
290 header: header_line,
291 })
292}
293
294fn colour_like(columns: &[Vec<f64>]) -> bool {
298 let channel = |v: &f64| v.fract() == 0.0 && (0.0..=255.0).contains(v);
299 columns.iter().all(|c| c.iter().all(channel))
300 && columns.iter().any(|c| c.iter().any(|&v| v > 1.0))
301}
302
303fn inferred(columns: &[Vec<f64>]) -> OgeomResult<Vec<XyzColumn>> {
305 use XyzColumn::{Blue, Green, NormalX, NormalY, NormalZ, Red, X, Y, Z};
306 let intensity = || XyzColumn::Scalar("intensity".to_owned());
307 let colour = [Red, Green, Blue];
308 let normal = [NormalX, NormalY, NormalZ];
309 let mut layout = vec![X, Y, Z];
310 match columns.len() {
311 3 => {}
312 4 => layout.push(intensity()),
313 6 => layout.extend(if colour_like(&columns[3..6]) {
314 colour
315 } else {
316 normal
317 }),
318 7 => {
319 layout.push(intensity());
320 layout.extend(colour);
321 }
322 9 => match (colour_like(&columns[3..6]), colour_like(&columns[6..9])) {
323 (true, false) => {
324 layout.extend(colour);
325 layout.extend(normal);
326 }
327 (false, true) => {
328 layout.extend(normal);
329 layout.extend(colour);
330 }
331 _ => ogeom_bail!(
332 Construction,
333 "a cloud of nine columns reads as colour and normals only when one triple is colour; name its columns"
334 ),
335 },
336 n => ogeom_bail!(
337 Construction,
338 "a cloud of {n} columns does not say what they are; name its columns"
339 ),
340 }
341 Ok(layout)
342}
343
344fn assemble(table: Table, named: Option<&[XyzColumn]>) -> OgeomResult<PointCloud> {
345 let Table { columns, header } = table;
346 if columns.is_empty() {
347 ogeom_bail!(Construction, "the file carries no points");
348 }
349 let layout = match (named, header) {
350 (Some(named), _) => named.to_vec(),
351 (None, Some(header)) => header,
352 (None, None) => inferred(&columns)?,
353 };
354 if layout.len() != columns.len() {
355 ogeom_bail!(
356 Construction,
357 "the rows have {} columns where {} are named",
358 columns.len(),
359 layout.len()
360 );
361 }
362 let find = |wanted: &XyzColumn| -> OgeomResult<Option<usize>> {
363 let mut at = layout.iter().enumerate().filter(|(_, c)| *c == wanted);
364 match (at.next(), at.next()) {
365 (Some((i, _)), None) => Ok(Some(i)),
366 (None, _) => Ok(None),
367 (Some(_), Some(_)) => {
368 ogeom_bail!(Construction, "the column {} is named twice", wanted.word())
369 }
370 }
371 };
372 let triple = |names: [XyzColumn; 3], what: &str| -> OgeomResult<Option<[usize; 3]>> {
373 match (find(&names[0])?, find(&names[1])?, find(&names[2])?) {
374 (Some(a), Some(b), Some(c)) => Ok(Some([a, b, c])),
375 (None, None, None) => Ok(None),
376 _ => ogeom_bail!(Construction, "the columns name part of a {what}"),
377 }
378 };
379 let Some(xyz) = triple([XyzColumn::X, XyzColumn::Y, XyzColumn::Z], "point")? else {
380 ogeom_bail!(Construction, "no columns are named x, y and z");
381 };
382 let nxyz = triple(
383 [XyzColumn::NormalX, XyzColumn::NormalY, XyzColumn::NormalZ],
384 "normal",
385 )?;
386 let rgb = triple(
387 [XyzColumn::Red, XyzColumn::Green, XyzColumn::Blue],
388 "colour",
389 )?;
390 let n = columns[0].len();
391 let points = (0..n)
392 .map(|i| Point::new(columns[xyz[0]][i], columns[xyz[1]][i], columns[xyz[2]][i]))
393 .collect();
394 let normals = nxyz.map(|[a, b, c]| {
395 (0..n)
396 .map(|i| Vector::new(columns[a][i], columns[b][i], columns[c][i]))
397 .collect()
398 });
399 let colours = match rgb {
400 Some(channels) => {
401 for &c in &channels {
402 if let Some(v) = columns[c].iter().find(|v| !(0.0..=255.0).contains(*v)) {
403 ogeom_bail!(Construction, "a colour channel reads {v}, outside 0 to 255");
404 }
405 }
406 Some(
407 (0..n)
408 .map(|i| {
409 let [r, g, b] = channels.map(|c| columns[c][i] / 255.0);
410 [r, g, b, 1.0]
411 })
412 .collect(),
413 )
414 }
415 None => None,
416 };
417 let mut scalars: Vec<Scalar> = Vec::new();
418 let mut columns = columns;
419 for (k, column) in layout.iter().enumerate() {
420 if let XyzColumn::Scalar(name) = column {
421 if scalars.iter().any(|s| &s.name == name) {
422 ogeom_bail!(Construction, "the column {name} is named twice");
423 }
424 scalars.push(Scalar {
425 name: name.clone(),
426 values: std::mem::take(&mut columns[k]),
427 });
428 }
429 }
430 Ok(PointCloud {
431 points,
432 normals,
433 colours,
434 scalars,
435 })
436}
437
438pub fn read_xyz(text: &str, columns: Option<&[XyzColumn]>) -> OgeomResult<PointCloud> {
465 assemble(table(text, false)?, columns)
466}
467
468pub fn read_pts(text: &str, columns: Option<&[XyzColumn]>) -> OgeomResult<PointCloud> {
479 assemble(table(text, true)?, columns)
480}
481
482pub fn write_xyz(cloud: &PointCloud) -> OgeomResult<String> {
499 cloud.check()?;
500 let mut out = String::from("# x y z");
501 if cloud.normals.is_some() {
502 out.push_str(" nx ny nz");
503 }
504 if cloud.colours.is_some() {
505 out.push_str(" red green blue");
506 }
507 for s in &cloud.scalars {
508 let separated = s
509 .name
510 .chars()
511 .any(|c| c.is_whitespace() || c == ',' || c == ';' || c == '#');
512 if s.name.is_empty()
513 || separated
514 || !matches!(XyzColumn::named(&s.name), XyzColumn::Scalar(_))
515 {
516 ogeom_bail!(
517 Domain,
518 "the scalar name {:?} cannot head an XYZ column",
519 s.name
520 );
521 }
522 let _ = write!(out, " {}", s.name);
523 }
524 out.push('\n');
525 for (i, p) in cloud.points.iter().enumerate() {
526 let _ = write!(out, "{} {} {}", p.x, p.y, p.z);
527 if let Some(normals) = &cloud.normals {
528 let n = normals[i];
529 let _ = write!(out, " {} {} {}", n.x, n.y, n.z);
530 }
531 if let Some(colours) = &cloud.colours {
532 let [r, g, b] = [0, 1, 2].map(|k| channel_byte(colours[i][k]));
533 let _ = write!(out, " {r} {g} {b}");
534 }
535 for s in &cloud.scalars {
536 let _ = write!(out, " {}", s.values[i]);
537 }
538 out.push('\n');
539 }
540 Ok(out)
541}
542
543pub(crate) fn channel_byte(value: f64) -> u8 {
545 #[allow(
546 clippy::cast_possible_truncation,
547 clippy::cast_sign_loss,
548 reason = "clamped into 0..=255 first"
549 )]
550 let byte = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
551 byte
552}
553
554#[cfg(test)]
555mod tests {
556 #![allow(clippy::unwrap_used, reason = "test code")]
557 use super::*;
558
559 fn sample_cloud() -> PointCloud {
560 PointCloud {
561 points: vec![Point::new(0.1, -2.5, 1e-7), Point::new(3.0, 4.0, 5.0)],
562 normals: Some(vec![Vector::Z, Vector::new(0.6, 0.0, -0.8)]),
563 colours: Some(vec![[1.0, 0.0, 51.0 / 255.0, 1.0], [0.0, 1.0, 0.0, 1.0]]),
564 scalars: vec![Scalar {
565 name: "deviation".into(),
566 values: vec![0.012_345_678_9, -0.25],
567 }],
568 }
569 }
570
571 #[test]
572 fn an_xyz_cloud_round_trips_with_its_header() {
573 let cloud = sample_cloud();
574 let text = write_xyz(&cloud).unwrap();
575 assert!(text.starts_with("# x y z nx ny nz red green blue deviation\n"));
576 assert_eq!(read_xyz(&text, None).unwrap(), cloud);
577 }
578
579 #[test]
580 fn unnamed_columns_are_read_by_their_count() {
581 let rgb = read_xyz("1,2,3,255,0,10\n4,5,6,0,128,0\n", None).unwrap();
582 assert_eq!(rgb.colours.unwrap()[0], [1.0, 0.0, 10.0 / 255.0, 1.0]);
583 assert!(rgb.normals.is_none());
584 let normal = read_xyz("1 2 3 0 0 1\n4 5 6 0 1 0\n", None).unwrap();
585 assert_eq!(normal.normals.unwrap()[1], Vector::Y);
586 let intensity = read_xyz("1 2 3 -40\n", None).unwrap();
587 assert_eq!(intensity.scalar("intensity"), Some(&[-40.0][..]));
588 let nine = read_xyz("0 0 0 0.6 0 0.8 10 20 30\n", None).unwrap();
589 assert_eq!(nine.normals.unwrap()[0], Vector::new(0.6, 0.0, 0.8));
590 assert_eq!(nine.colours.unwrap()[0][1], 20.0 / 255.0);
591 assert!(read_xyz("1 2 3 4 5\n", None).is_err());
592 assert!(read_xyz("0 0 0 10 20 30 10 20 30\n", None).is_err());
593 let named = read_xyz(
594 "1 2 3 9 7\n",
595 Some(&[
596 XyzColumn::Skip,
597 XyzColumn::X,
598 XyzColumn::Y,
599 XyzColumn::Z,
600 XyzColumn::Scalar("quality".into()),
601 ]),
602 )
603 .unwrap();
604 assert_eq!(named.points[0], Point::new(2.0, 3.0, 9.0));
605 assert_eq!(named.scalar("quality"), Some(&[7.0][..]));
606 }
607
608 #[test]
609 fn a_pts_reads_every_scan_and_holds_each_to_its_count() {
610 let text = "2\n0 0 0 -100 255 0 0\n1 0 0 200 0 255 0\n1\n0 1 0 0 0 0 255\n";
611 let cloud = read_pts(text, None).unwrap();
612 assert_eq!(cloud.len(), 3);
613 assert_eq!(cloud.scalar("intensity"), Some(&[-100.0, 200.0, 0.0][..]));
614 assert_eq!(cloud.colours.unwrap()[2], [0.0, 0.0, 1.0, 1.0]);
615 assert!(read_pts("3\n0 0 0\n1 1 1\n", None).is_err());
616 assert!(read_pts("1\n0 0 0\n1 1 1\n", None).is_err());
617 assert!(read_pts("0 0 0\n", None).is_err());
618 }
619
620 #[test]
621 fn garbage_and_truncation_are_refused_by_name() {
622 for bad in [
623 "",
624 "# x y z\n",
625 "1 2 nan\n",
626 "1 2 3\n4 5\n",
627 "1 2 3\n4 5 6 7\n",
628 "1 2 x\n",
629 "1 2 3 0 300 0 1\n",
630 "\u{0}\u{1}\u{2}",
631 ] {
632 assert!(read_xyz(bad, None).is_err(), "{bad:?}");
633 }
634 let text = write_xyz(&sample_cloud()).unwrap();
635 for cut in 1..text.len() {
636 let _ = read_xyz(&text[..cut], None);
638 }
639 let mut bad_name = sample_cloud();
640 bad_name.scalars[0].name = "red".into();
641 assert!(write_xyz(&bad_name).is_err());
642 let mut short = sample_cloud();
643 short.normals = Some(vec![Vector::Z]);
644 assert!(write_xyz(&short).is_err());
645 }
646}