point-formats 0.3.0

Dependency-light LiDAR/point-cloud/mesh format conversion crate with explicit adapters for heavyweight formats.
Documentation
use crate::error::{Error, Result};
use crate::format::Format;
use crate::io::{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 line_no = 0;

    loop {
        line.clear();
        let bytes_read = reader.read_line(&mut line)?;
        if bytes_read == 0 {
            break;
        }
        line_no += 1;
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
            continue;
        }

        if delimiter == Delimiter::Auto {
            delimiter = Delimiter::detect(trimmed);
        }
        let mut fields_buf = [""; 64];
        let fields_len = delimiter.split_into_slice(trimmed, &mut fields_buf);
        let fields = &fields_buf[..fields_len];
        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 {
        crate::io::write_fmt_f64(writer, point.position.x, options.precision)?;
        write!(writer, "{}", sep)?;
        crate::io::write_fmt_f64(writer, point.position.y, options.precision)?;
        write!(writer, "{}", sep)?;
        crate::io::write_fmt_f64(writer, point.position.z, options.precision)?;

        if has_intensity {
            write!(writer, "{}", sep)?;
            if let Some(v) = point.intensity {
                write!(writer, "{:.*}", options.precision, v)?;
            }
        }
        if has_color {
            write!(writer, "{}", sep)?;
            if let Some(color) = point.color {
                write!(
                    writer,
                    "{}{}{}{}{}",
                    color.red, sep, color.green, sep, color.blue
                )?;
            } else {
                write!(writer, "{}{}", sep, sep)?;
            }
        }
        if has_classification {
            write!(writer, "{}", sep)?;
            if let Some(v) = point.classification {
                write!(writer, "{}", v)?;
            }
        }
        if has_gps_time {
            write!(writer, "{}", sep)?;
            if let Some(v) = point.gps_time {
                crate::io::write_fmt_f64(writer, v, options.precision)?;
            }
        }
        if has_normals {
            write!(writer, "{}", sep)?;
            if let Some(normal) = point.normal {
                crate::io::write_fmt_f64(writer, normal.x, options.precision)?;
                write!(writer, "{}", sep)?;
                crate::io::write_fmt_f64(writer, normal.y, options.precision)?;
                write!(writer, "{}", sep)?;
                crate::io::write_fmt_f64(writer, normal.z, options.precision)?;
            } else {
                write!(writer, "{}{}", sep, sep)?;
            }
        }
        writeln!(writer)?;
    }
    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
}