use crate::error::{Error, Result};
use crate::format::Format;
use crate::io::{
fmt_f64, parse_f32, parse_f64, parse_u16, ColumnMapping, DelimitedOptions, Delimiter,
};
use crate::types::{Color, Metadata, Point, PointCloud, Vec3};
use std::io::{BufRead, Write};
pub fn read<R: BufRead>(
reader: &mut R,
format: Format,
options: &DelimitedOptions,
) -> Result<PointCloud> {
let mut cloud = PointCloud::empty();
cloud.metadata.source_format = Some(format);
let mut mapping = options.columns.clone();
let mut delimiter = options.delimiter;
let mut first_data_seen = false;
let mut header_decided = options.has_header;
let mut line = String::new();
let mut fields = Vec::new();
let mut line_no = 0;
loop {
fields.clear();
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break;
}
line_no += 1;
let trimmed = line.trim();
let trimmed_unsafe: &'static str = unsafe { &*(trimmed as *const str) };
if trimmed_unsafe.is_empty()
|| trimmed_unsafe.starts_with('#')
|| trimmed_unsafe.starts_with("//")
{
continue;
}
if delimiter == Delimiter::Auto {
delimiter = Delimiter::detect(trimmed_unsafe);
}
delimiter.split_into(trimmed_unsafe, &mut fields);
if fields.is_empty() {
continue;
}
if !first_data_seen {
let line_is_header = match header_decided {
Some(value) => value,
None => !looks_like_point_line(format, line_no, &mapping, &fields),
};
header_decided = Some(line_is_header);
if line_is_header {
if let Some(header_mapping) = ColumnMapping::from_header(&fields) {
mapping = header_mapping;
} else {
return Err(Error::parse(
format,
line_no,
"header must include x/y/z columns (accepted aliases: x/easting/lon, y/northing/lat, z/elevation/height)",
));
}
first_data_seen = true;
continue;
}
first_data_seen = true;
}
let point = parse_point_fields(format, line_no, &mapping, &fields)?;
cloud.points.push(point);
}
cloud.metadata.point_count_hint = Some(cloud.points.len());
Ok(cloud)
}
pub fn write<W: Write>(
writer: &mut W,
format: Format,
cloud: &PointCloud,
options: &DelimitedOptions,
) -> Result<()> {
let delimiter = match options.delimiter {
Delimiter::Auto => {
if matches!(format, Format::Csv) {
Delimiter::Comma
} else {
Delimiter::Whitespace
}
}
other => other,
};
let sep = delimiter.as_str();
let has_intensity = cloud.has_intensity();
let has_color = cloud.has_color();
let has_classification = cloud.has_classification();
let has_gps_time = cloud.has_gps_time();
let has_normals = cloud.has_normals();
if options.write_header || matches!(format, Format::Csv) {
let mut header = vec!["x", "y", "z"];
if has_intensity {
header.push("intensity");
}
if has_color {
header.extend(["red", "green", "blue"]);
}
if has_classification {
header.push("classification");
}
if has_gps_time {
header.push("gps_time");
}
if has_normals {
header.extend(["normal_x", "normal_y", "normal_z"]);
}
writeln!(writer, "{}", header.join(sep))?;
}
for point in &cloud.points {
let mut fields = vec![
fmt_f64(point.position.x, options.precision),
fmt_f64(point.position.y, options.precision),
fmt_f64(point.position.z, options.precision),
];
if has_intensity {
fields.push(
point
.intensity
.map(|v| format!("{:.*}", options.precision, v))
.unwrap_or_default(),
);
}
if has_color {
if let Some(color) = point.color {
fields.extend([
color.red.to_string(),
color.green.to_string(),
color.blue.to_string(),
]);
} else {
fields.extend([String::new(), String::new(), String::new()]);
}
}
if has_classification {
fields.push(
point
.classification
.map(|v| v.to_string())
.unwrap_or_default(),
);
}
if has_gps_time {
fields.push(
point
.gps_time
.map(|v| fmt_f64(v, options.precision))
.unwrap_or_default(),
);
}
if has_normals {
if let Some(normal) = point.normal {
fields.extend([
fmt_f64(normal.x, options.precision),
fmt_f64(normal.y, options.precision),
fmt_f64(normal.z, options.precision),
]);
} else {
fields.extend([String::new(), String::new(), String::new()]);
}
}
writeln!(writer, "{}", fields.join(sep))?;
}
Ok(())
}
pub(crate) fn parse_point_fields(
format: Format,
line_no: usize,
mapping: &ColumnMapping,
fields: &[&str],
) -> Result<Point> {
fn get<'a>(
format: Format,
line_no: usize,
fields: &'a [&str],
idx: usize,
name: &str,
) -> Result<&'a str> {
fields
.get(idx)
.copied()
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
Error::parse(
format,
line_no,
format!("missing required column {name} at index {idx}"),
)
})
}
fn opt<'a>(fields: &'a [&str], idx: Option<usize>) -> Option<&'a str> {
idx.and_then(|i| fields.get(i).copied())
.map(str::trim)
.filter(|s| !s.is_empty())
}
let x = parse_f64(
format,
line_no,
"x",
get(format, line_no, fields, mapping.x, "x")?,
)?;
let y = parse_f64(
format,
line_no,
"y",
get(format, line_no, fields, mapping.y, "y")?,
)?;
let z = parse_f64(
format,
line_no,
"z",
get(format, line_no, fields, mapping.z, "z")?,
)?;
let mut point = Point::new(x, y, z);
if let Some(value) = opt(fields, mapping.intensity) {
point.intensity = Some(parse_f32(format, line_no, "intensity", value)?);
}
if let (Some(r), Some(g), Some(b)) = (
opt(fields, mapping.red),
opt(fields, mapping.green),
opt(fields, mapping.blue),
) {
point.color = Some(Color::new(
parse_u16(format, line_no, "red", r)?,
parse_u16(format, line_no, "green", g)?,
parse_u16(format, line_no, "blue", b)?,
));
}
if let Some(value) = opt(fields, mapping.classification) {
point.classification = Some(crate::io::parse_u8(
format,
line_no,
"classification",
value,
)?);
}
if let Some(value) = opt(fields, mapping.gps_time) {
point.gps_time = Some(parse_f64(format, line_no, "gps_time", value)?);
}
if let (Some(nx), Some(ny), Some(nz)) = (
opt(fields, mapping.normal_x),
opt(fields, mapping.normal_y),
opt(fields, mapping.normal_z),
) {
point.normal = Some(Vec3::new(
parse_f64(format, line_no, "normal_x", nx)?,
parse_f64(format, line_no, "normal_y", ny)?,
parse_f64(format, line_no, "normal_z", nz)?,
));
}
if !point.position.is_finite() {
return Err(Error::parse(
format,
line_no,
"point coordinates must be finite",
));
}
Ok(point)
}
fn looks_like_point_line(
format: Format,
line_no: usize,
mapping: &ColumnMapping,
fields: &[&str],
) -> bool {
let needed = [mapping.x, mapping.y, mapping.z];
needed.iter().all(|&idx| {
fields
.get(idx)
.copied()
.map(|value| parse_f64(format, line_no, "coordinate", value).is_ok())
.unwrap_or(false)
})
}
#[allow(dead_code)]
pub(crate) fn cloud_with_format(mut cloud: PointCloud, format: Format) -> PointCloud {
if cloud.metadata == Metadata::default() {
cloud.metadata.source_format = Some(format);
}
cloud
}