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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum LasWriteFormat {
21 #[default]
23 Las,
24 Laz,
26}
27
28pub 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 #[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
56pub 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
85pub 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#[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 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 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 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 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}