Skip to main content

spatialrust_io/las/
writer.rs

1use std::io::{Seek, Write};
2
3use las::point::{Classification, Format};
4use las::{Builder, Color, Header, Point, Writer};
5use spatialrust_core::{FieldSemantic, HasPositions3, PointCloud, PointField, PointSchema};
6
7use crate::error::{las_format, las_parse, IoError};
8use crate::las::schema::schema_from_point_cloud;
9use crate::{PointWriter, WriteOptions};
10
11#[cfg(feature = "streaming")]
12use crate::streaming::records_io;
13#[cfg(feature = "streaming")]
14use spatialrust_records::{
15    BoundedSpatialRecordSink, RecordsError, RecordsResult, SchemaDescriptor, SpatialRecordChunk,
16};
17
18/// Output encoding for LAS writers.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum LasWriteFormat {
21    /// Uncompressed LAS.
22    #[default]
23    Las,
24    /// LAZ compression (requires `io-laz` feature).
25    Laz,
26}
27
28/// Writes point clouds to LAS/LAZ files or streams.
29pub struct LasWriter<W: Write + Seek + Send + Sync + 'static> {
30    writer: W,
31    format: LasWriteFormat,
32}
33
34impl<W: Write + Seek + Send + Sync + 'static> LasWriter<W> {
35    /// Creates a new LAS writer.
36    #[must_use]
37    pub const fn new(writer: W, format: LasWriteFormat) -> Self {
38        Self { writer, format }
39    }
40}
41
42impl<W: Write + Seek + Send + Sync + 'static> PointWriter for LasWriter<W> {
43    fn write(
44        &mut self,
45        cloud: &PointCloud,
46        _options: &WriteOptions,
47    ) -> spatialrust_core::SpatialResult<()> {
48        let buffer = write_las(std::io::Cursor::new(Vec::new()), cloud, self.format)
49            .map_err(|error| spatialrust_core::SpatialError::Io(error.to_string()))?;
50        self.writer
51            .write_all(buffer.get_ref())
52            .map_err(|error| spatialrust_core::SpatialError::Io(error.to_string()))
53    }
54}
55
56/// Writes a point cloud to a LAS/LAZ stream and returns the inner writer.
57pub fn write_las<W: Write + Seek + Send + Sync + 'static>(
58    writer: W,
59    cloud: &PointCloud,
60    format: LasWriteFormat,
61) -> Result<W, IoError> {
62    cloud.validate()?;
63    if format == LasWriteFormat::Laz {
64        #[cfg(not(feature = "io-laz"))]
65        {
66            return Err(crate::error::laz_format(
67                "LAZ output requires the io-laz feature".to_owned(),
68            ));
69        }
70    }
71
72    let (point_format, export_schema) = schema_from_point_cloud(cloud.schema())?;
73    let header = header_from_cloud(point_format, format)?;
74    let mut las_writer =
75        Writer::new(writer, header).map_err(|error| las_format(error.to_string()))?;
76
77    for index in 0..cloud.len() {
78        let point = point_from_cloud(cloud, &export_schema, index, point_format)?;
79        las_writer.write_point(point).map_err(|error| las_format(error.to_string()))?;
80    }
81
82    las_writer.into_inner().map_err(|error| las_format(error.to_string()))
83}
84
85/// Writes a point cloud to a LAS/LAZ file on disk.
86pub fn write_las_file(
87    path: impl AsRef<std::path::Path>,
88    cloud: &PointCloud,
89    format: LasWriteFormat,
90) -> Result<(), IoError> {
91    if format == LasWriteFormat::Laz {
92        #[cfg(not(feature = "io-laz"))]
93        {
94            return Err(crate::error::laz_format(
95                "LAZ output requires the io-laz feature".to_owned(),
96            ));
97        }
98    }
99
100    let (point_format, export_schema) = schema_from_point_cloud(cloud.schema())?;
101    let header = header_from_cloud(point_format, format)?;
102    let mut las_writer =
103        Writer::from_path(path.as_ref(), header).map_err(|error| las_format(error.to_string()))?;
104
105    for index in 0..cloud.len() {
106        let point = point_from_cloud(cloud, &export_schema, index, point_format)?;
107        las_writer.write_point(point).map_err(|error| las_format(error.to_string()))?;
108    }
109
110    las_writer.close().map_err(|error| las_format(error.to_string()))
111}
112
113/// Sequential LAS/LAZ sink that writes each leased chunk immediately.
114#[cfg(feature = "streaming")]
115pub struct LasChunkSink<W: Write + Seek + Send + Sync + 'static> {
116    writer: Writer<W>,
117    schema: SchemaDescriptor,
118    export_schema: PointSchema,
119    point_format: Format,
120    expected_points: Option<u64>,
121    written_points: u64,
122    finished: bool,
123}
124
125#[cfg(feature = "streaming")]
126impl<W: Write + Seek + Send + Sync + 'static> LasChunkSink<W> {
127    /// Creates a LAS/LAZ sink over a seekable writer.
128    pub fn new(
129        writer: W,
130        schema: SchemaDescriptor,
131        expected_points: u64,
132        format: LasWriteFormat,
133    ) -> Result<Self, IoError> {
134        if format == LasWriteFormat::Laz {
135            #[cfg(not(feature = "io-laz"))]
136            return Err(crate::error::laz_format(
137                "LAZ output requires the io-laz feature".to_owned(),
138            ));
139        }
140        let (point_format, export_schema) = schema_from_point_cloud(schema.point_schema())?;
141        let header = header_from_cloud(point_format, format)?;
142        let writer = Writer::new(writer, header).map_err(|error| las_format(error.to_string()))?;
143        Ok(Self {
144            writer,
145            schema,
146            export_schema,
147            point_format,
148            expected_points: Some(expected_points),
149            written_points: 0,
150            finished: false,
151        })
152    }
153
154    /// Creates a LAS/LAZ sink whose final point count is not known up front.
155    ///
156    /// The seekable LAS writer patches the header when [`Self::finish`] closes it.
157    pub fn new_open_ended(
158        writer: W,
159        schema: SchemaDescriptor,
160        format: LasWriteFormat,
161    ) -> Result<Self, IoError> {
162        if format == LasWriteFormat::Laz {
163            #[cfg(not(feature = "io-laz"))]
164            return Err(crate::error::laz_format(
165                "LAZ output requires the io-laz feature".to_owned(),
166            ));
167        }
168        let (point_format, export_schema) = schema_from_point_cloud(schema.point_schema())?;
169        let header = header_from_cloud(point_format, format)?;
170        let writer = Writer::new(writer, header).map_err(|error| las_format(error.to_string()))?;
171        Ok(Self {
172            writer,
173            schema,
174            export_schema,
175            point_format,
176            expected_points: None,
177            written_points: 0,
178            finished: false,
179        })
180    }
181}
182
183#[cfg(feature = "streaming")]
184impl LasChunkSink<std::io::BufWriter<std::fs::File>> {
185    /// Creates a local LAS/LAZ file sink.
186    pub fn create(
187        path: impl AsRef<std::path::Path>,
188        schema: SchemaDescriptor,
189        expected_points: u64,
190        format: LasWriteFormat,
191    ) -> Result<Self, IoError> {
192        Self::new(
193            std::io::BufWriter::new(std::fs::File::create(path)?),
194            schema,
195            expected_points,
196            format,
197        )
198    }
199
200    /// Creates a local LAS/LAZ sink whose final point count is not known up front.
201    pub fn create_open_ended(
202        path: impl AsRef<std::path::Path>,
203        schema: SchemaDescriptor,
204        format: LasWriteFormat,
205    ) -> Result<Self, IoError> {
206        Self::new_open_ended(std::io::BufWriter::new(std::fs::File::create(path)?), schema, format)
207    }
208}
209
210#[cfg(feature = "streaming")]
211impl<W: Write + Seek + Send + Sync + 'static> BoundedSpatialRecordSink for LasChunkSink<W> {
212    fn write_chunk(&mut self, chunk: &SpatialRecordChunk) -> RecordsResult<()> {
213        if self.finished {
214            return Err(RecordsError::InvalidConfiguration("LAS sink is already finished".into()));
215        }
216        let cloud = chunk.record().cloud();
217        if cloud.schema() != self.schema.point_schema() {
218            return Err(RecordsError::SchemaMismatch(
219                "LAS chunk schema differs from sink schema".into(),
220            ));
221        }
222        let next = self
223            .written_points
224            .checked_add(
225                u64::try_from(cloud.len())
226                    .map_err(|_| RecordsError::ReceiptOverflow("LAS chunk point count".into()))?,
227            )
228            .ok_or_else(|| RecordsError::ReceiptOverflow("LAS point count".into()))?;
229        if let Some(expected_points) = self.expected_points {
230            if next > expected_points {
231                return Err(RecordsError::InvalidChunk(format!(
232                    "LAS sink expected {expected_points} points but received at least {next}"
233                )));
234            }
235        }
236        for index in 0..cloud.len() {
237            let point = point_from_cloud(cloud, &self.export_schema, index, self.point_format)
238                .map_err(records_io)?;
239            self.writer
240                .write_point(point)
241                .map_err(|error| records_io(las_format(error.to_string())))?;
242        }
243        self.written_points = next;
244        Ok(())
245    }
246
247    fn finish(&mut self) -> RecordsResult<()> {
248        if let Some(expected_points) = self.expected_points {
249            if self.written_points != expected_points {
250                return Err(RecordsError::InvalidChunk(format!(
251                    "LAS sink expected {expected_points} points but received {}",
252                    self.written_points
253                )));
254            }
255        }
256        self.writer.close().map_err(|error| records_io(las_format(error.to_string())))?;
257        self.finished = true;
258        Ok(())
259    }
260}
261
262fn header_from_cloud(point_format: Format, format: LasWriteFormat) -> Result<Header, IoError> {
263    let mut builder = Builder::from((1, 2));
264    builder.point_format = point_format;
265    builder.system_identifier = "SpatialRust".to_owned();
266    builder.generating_software = "SpatialRust".to_owned();
267
268    if format == LasWriteFormat::Laz {
269        builder.point_format.is_compressed = true;
270    }
271
272    builder.into_header().map_err(|error| las_format(error.to_string()))
273}
274
275pub(crate) fn point_from_cloud(
276    cloud: &PointCloud,
277    schema: &PointSchema,
278    index: usize,
279    format: Format,
280) -> Result<Point, IoError> {
281    let (x, y, z) = cloud.positions3()?;
282    let mut point = Point {
283        x: f64::from(x[index]),
284        y: f64::from(y[index]),
285        z: f64::from(z[index]),
286        ..Default::default()
287    };
288
289    for field in schema.fields() {
290        if matches!(
291            field.semantic,
292            FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ
293        ) {
294            continue;
295        }
296        let Some(field_name) = cloud_field_name_for_export(cloud, field) else {
297            continue;
298        };
299        let value = read_cloud_field(cloud, field_name, index)?;
300        apply_las_field(&mut point, field, value, format)?;
301    }
302
303    Ok(point)
304}
305
306fn cloud_field_name_for_export<'a>(
307    cloud: &'a PointCloud,
308    export_field: &'a PointField,
309) -> Option<&'a str> {
310    if cloud.field(&export_field.name).is_ok() {
311        return Some(export_field.name.as_str());
312    }
313    cloud
314        .schema()
315        .find_semantic(export_field.semantic)
316        .map(|field| field.name.as_str())
317        .filter(|name| cloud.field(name).is_ok())
318}
319
320fn read_cloud_field(cloud: &PointCloud, field_name: &str, index: usize) -> Result<f64, IoError> {
321    let buffer = cloud.field(field_name)?;
322    Ok(match buffer {
323        PointBuffer::F32(values) => f64::from(values[index]),
324        PointBuffer::F64(values) => values[index],
325        PointBuffer::U8(values) => f64::from(values[index]),
326        PointBuffer::U16(values) => f64::from(values[index]),
327        PointBuffer::I32(values) => f64::from(values[index]),
328        PointBuffer::U32(values) => f64::from(values[index]),
329    })
330}
331
332use spatialrust_core::PointBuffer;
333
334fn apply_las_field(
335    point: &mut Point,
336    field: &PointField,
337    value: f64,
338    format: Format,
339) -> Result<(), IoError> {
340    match field.semantic {
341        FieldSemantic::Intensity => point.intensity = value.round() as u16,
342        FieldSemantic::Label => {
343            point.classification = Classification::new(value.round() as u8)
344                .map_err(|error| las_parse(error.to_string()))?;
345        }
346        FieldSemantic::TimeOffset => {
347            if format.has_gps_time {
348                point.gps_time = Some(value);
349            }
350        }
351        FieldSemantic::ColorR | FieldSemantic::ColorG | FieldSemantic::ColorB => {
352            if format.has_color {
353                let color = point.color.get_or_insert_with(Color::default);
354                match field.semantic {
355                    FieldSemantic::ColorR => color.red = value.round() as u16,
356                    FieldSemantic::ColorG => color.green = value.round() as u16,
357                    FieldSemantic::ColorB => color.blue = value.round() as u16,
358                    _ => {}
359                }
360            }
361        }
362        FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ => {}
363        _ => {}
364    }
365    Ok(())
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{write_las, LasWriteFormat};
371    use spatialrust_core::PointCloudBuilder;
372    use std::io::Cursor;
373
374    #[test]
375    fn writes_xyz_cloud() {
376        let mut builder = PointCloudBuilder::xyz();
377        builder.push_point([1.0, 2.0, 3.0]).unwrap();
378        let cloud = builder.build().unwrap();
379        let bytes = write_las(Cursor::new(Vec::new()), &cloud, LasWriteFormat::Las).unwrap();
380        assert!(!bytes.get_ref().is_empty());
381    }
382
383    #[test]
384    fn writes_xyzirgb_cloud_with_u8_color_fields() {
385        use spatialrust_core::StandardSchemas;
386
387        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzirgb());
388        builder.push_point([1.0, 2.0, 3.0, 100.0, 255.0, 128.0, 64.0]).unwrap();
389        let cloud = builder.build().unwrap();
390        let bytes = write_las(Cursor::new(Vec::new()), &cloud, LasWriteFormat::Las).unwrap();
391        assert!(!bytes.get_ref().is_empty());
392    }
393
394    #[cfg(feature = "io-laz")]
395    #[test]
396    fn writes_laz_file() {
397        use super::write_las_file;
398
399        let mut builder = PointCloudBuilder::xyz();
400        builder.push_point([0.0, 0.0, 0.0]).unwrap();
401        let cloud = builder.build().unwrap();
402        let path =
403            std::env::temp_dir().join(format!("spatialrust_laz_write_{}.laz", std::process::id()));
404        write_las_file(&path, &cloud, LasWriteFormat::Laz).unwrap();
405        let loaded = crate::las::read_las_file(&path).unwrap();
406        let _ = std::fs::remove_file(path);
407        assert_eq!(loaded.len(), 1);
408    }
409}