Skip to main content

spatialrust_io/ply/
reader.rs

1use std::io::BufRead;
2
3use spatialrust_core::{
4    DType, PointBuffer, PointBufferSet, PointCloud, PointSchema, SpatialMetadata,
5};
6
7use crate::error::{ply_format, ply_parse, IoError};
8use crate::ply::header::{PlyFormat, PlyHeader, PlyPropertyKind};
9use crate::ply::schema::schema_from_ply_properties;
10use crate::{PointReader, ReadOptions};
11
12#[cfg(feature = "streaming")]
13use crate::streaming::{
14    read_bounded_ascii_line, records_io, FormatStreamState, MAX_ASCII_RECORD_BYTES,
15};
16#[cfg(feature = "streaming")]
17use spatialrust_records::{
18    BoundedSpatialRecordSource, CancellationToken, MemoryTracker, RecordsResult, SchemaDescriptor,
19    SpatialRecordChunk, StreamOptions,
20};
21
22/// Reads point clouds from PLY files or streams.
23pub struct PlyReader<R: BufRead> {
24    reader: R,
25    header: PlyHeader,
26    metadata: SpatialMetadata,
27    schema: PointSchema,
28    loaded: bool,
29}
30
31impl<R: BufRead> PlyReader<R> {
32    /// Creates a reader and parses the PLY header eagerly.
33    pub fn new(mut reader: R) -> Result<Self, IoError> {
34        let header = PlyHeader::parse(&mut reader)?;
35        let schema = schema_from_ply_properties(&header.properties)?;
36        let metadata = metadata_from_header(&header);
37        Ok(Self { reader, header, metadata, schema, loaded: false })
38    }
39
40    /// Returns the parsed PLY header.
41    #[must_use]
42    pub fn header(&self) -> &PlyHeader {
43        &self.header
44    }
45
46    /// Reads the point cloud payload.
47    pub fn read_cloud(&mut self) -> Result<PointCloud, IoError> {
48        if self.loaded {
49            return Err(ply_format("PLY reader already consumed"));
50        }
51        self.loaded = true;
52        read_ply_body(&self.header, &mut self.reader, self.schema.clone(), self.metadata.clone())
53    }
54}
55
56impl<R: BufRead> PointReader for PlyReader<R> {
57    fn schema(&self) -> spatialrust_core::SpatialResult<PointSchema> {
58        Ok(self.schema.clone())
59    }
60
61    fn metadata(&self) -> spatialrust_core::SpatialResult<SpatialMetadata> {
62        Ok(self.metadata.clone())
63    }
64
65    fn read(&mut self, _options: &ReadOptions) -> spatialrust_core::SpatialResult<PointCloud> {
66        self.read_cloud().map_err(|error| spatialrust_core::SpatialError::Io(error.to_string()))
67    }
68}
69
70/// Reads a complete PLY file from any buffered reader.
71pub fn read_ply<R: BufRead>(reader: &mut R) -> Result<PointCloud, IoError> {
72    let header = PlyHeader::parse(reader)?;
73    let schema = schema_from_ply_properties(&header.properties)?;
74    let metadata = metadata_from_header(&header);
75    read_ply_body(&header, reader, schema, metadata)
76}
77
78fn read_ply_body<R: BufRead>(
79    header: &PlyHeader,
80    reader: &mut R,
81    schema: PointSchema,
82    metadata: SpatialMetadata,
83) -> Result<PointCloud, IoError> {
84    let mut buffers = PointBufferSet::new();
85    for field in schema.fields() {
86        buffers.insert(
87            field.name.clone(),
88            PointBuffer::with_capacity(field.dtype, header.vertex_count),
89        );
90    }
91
92    match header.format {
93        PlyFormat::Ascii => read_ascii_vertices(reader, header, &schema, &mut buffers)?,
94        PlyFormat::BinaryLittleEndian => {
95            read_binary_vertices(reader, header, &schema, &mut buffers)?
96        }
97    }
98
99    PointCloud::try_from_parts(schema, buffers, metadata).map_err(IoError::from)
100}
101
102fn metadata_from_header(_header: &PlyHeader) -> SpatialMetadata {
103    SpatialMetadata {
104        frame_id: spatialrust_core::FrameId::new("ply"),
105        timestamp: spatialrust_core::Timestamp::from_nanos(0),
106        sensor_origin: None,
107        unit: "meter".to_owned(),
108    }
109}
110
111fn read_ascii_vertices<R: BufRead>(
112    reader: &mut R,
113    header: &PlyHeader,
114    schema: &PointSchema,
115    buffers: &mut PointBufferSet,
116) -> Result<(), IoError> {
117    for vertex_index in 0..header.vertex_count {
118        let line = read_ascii_vertex_line(reader, vertex_index, header.vertex_count)?;
119        let mut tokens = line.split_whitespace();
120        for property in &header.properties {
121            let token = tokens.next().ok_or_else(|| {
122                ply_parse(format!(
123                    "missing token for property `{}` on vertex {vertex_index}",
124                    property.name
125                ))
126            })?;
127            let value = token
128                .parse::<f64>()
129                .map_err(|_| ply_parse(format!("invalid ASCII value `{token}`")))?;
130            push_to_field(buffers, schema, &property.name, value as f32)?;
131        }
132    }
133    Ok(())
134}
135
136fn read_ascii_vertex_line<R: BufRead>(
137    reader: &mut R,
138    vertex_index: usize,
139    vertex_count: usize,
140) -> Result<String, IoError> {
141    loop {
142        let mut line = String::new();
143        let read = reader.read_line(&mut line)?;
144        if read == 0 {
145            return Err(ply_parse(format!(
146                "unexpected EOF after {vertex_index} of {vertex_count} ASCII vertices"
147            )));
148        }
149        let trimmed = line.trim();
150        if trimmed.is_empty() || trimmed.starts_with('#') {
151            continue;
152        }
153        return Ok(trimmed.to_owned());
154    }
155}
156
157fn read_binary_vertices<R: BufRead>(
158    reader: &mut R,
159    header: &PlyHeader,
160    schema: &PointSchema,
161    buffers: &mut PointBufferSet,
162) -> Result<(), IoError> {
163    let mut payload = vec![0_u8; header.vertex_stride() * header.vertex_count];
164    std::io::Read::read_exact(&mut *reader, &mut payload).map_err(IoError::from)?;
165
166    for vertex_index in 0..header.vertex_count {
167        let start = vertex_index * header.vertex_stride();
168        let mut offset = 0usize;
169        for property in &header.properties {
170            let size = property.kind.size_bytes();
171            let chunk = &payload[start + offset..start + offset + size];
172            offset += size;
173            let value = read_binary_scalar(property.kind, chunk)?;
174            push_to_field(buffers, schema, &property.name, value)?;
175        }
176    }
177    Ok(())
178}
179
180fn read_binary_scalar(kind: PlyPropertyKind, chunk: &[u8]) -> Result<f32, IoError> {
181    let value = match kind {
182        PlyPropertyKind::Char => i8::from_le_bytes(chunk.try_into().expect("char")) as f32,
183        PlyPropertyKind::UChar => f32::from(chunk[0]),
184        PlyPropertyKind::Short => i16::from_le_bytes(chunk.try_into().expect("short")) as f32,
185        PlyPropertyKind::UShort => f32::from(u16::from_le_bytes(chunk.try_into().expect("ushort"))),
186        PlyPropertyKind::Int => i32::from_le_bytes(chunk.try_into().expect("int")) as f32,
187        PlyPropertyKind::UInt => u32::from_le_bytes(chunk.try_into().expect("uint")) as f32,
188        PlyPropertyKind::Float => f32::from_le_bytes(chunk.try_into().expect("float")),
189        PlyPropertyKind::Double => f64::from_le_bytes(chunk.try_into().expect("double")) as f32,
190    };
191    Ok(value)
192}
193
194fn push_to_field(
195    buffers: &mut PointBufferSet,
196    schema: &PointSchema,
197    name: &str,
198    value: f32,
199) -> Result<(), IoError> {
200    let field = schema
201        .fields()
202        .iter()
203        .find(|field| field.name == name)
204        .ok_or_else(|| ply_format(format!("schema missing mapped field `{name}`")))?;
205
206    let buffer = buffers
207        .get_mut(name)
208        .ok_or_else(|| ply_format(format!("buffer missing for field `{name}`")))?;
209
210    match field.dtype {
211        DType::F32 | DType::F16 => buffer.push_f32(value).map_err(IoError::from),
212        DType::F64 => buffer.push_f64(f64::from(value)).map_err(IoError::from),
213        DType::U8 => buffer.push_u8(value.round() as u8).map_err(IoError::from),
214        DType::U16 => buffer.push_u16(value.round() as u16).map_err(IoError::from),
215        DType::I32 => buffer.push_i32(value.round() as i32).map_err(IoError::from),
216        DType::U32 => {
217            let PointBuffer::U32(values) = buffer else {
218                return Err(IoError::Core(spatialrust_core::SpatialError::UnsupportedDType(
219                    field.dtype,
220                )));
221            };
222            values.push(value.round() as u32);
223            Ok(())
224        }
225    }
226}
227
228/// Reads a PLY file from disk.
229pub fn read_ply_file(path: impl AsRef<std::path::Path>) -> Result<PointCloud, IoError> {
230    let file = std::fs::File::open(path.as_ref())?;
231    let mut reader = std::io::BufReader::new(file);
232    read_ply(&mut reader)
233}
234
235/// Bounded sequential PLY source for ASCII and little-endian binary vertices.
236#[cfg(feature = "streaming")]
237pub struct PlyChunkSource<R: BufRead> {
238    reader: R,
239    header: PlyHeader,
240    metadata: SpatialMetadata,
241    state: FormatStreamState,
242    loaded: usize,
243}
244
245#[cfg(feature = "streaming")]
246impl<R: BufRead> PlyChunkSource<R> {
247    /// Parses the header and creates a bounded PLY source.
248    pub fn new(
249        mut reader: R,
250        options: StreamOptions,
251        cancellation: CancellationToken,
252    ) -> Result<Self, IoError> {
253        let header = PlyHeader::parse(&mut reader)?;
254        let schema = schema_from_ply_properties(&header.properties)?;
255        let metadata = metadata_from_header(&header);
256        let state = FormatStreamState::new("ply", schema, options, cancellation)?;
257        Ok(Self { reader, header, metadata, state, loaded: 0 })
258    }
259
260    /// Returns the parsed header, including the declared vertex count.
261    #[must_use]
262    pub fn header(&self) -> &PlyHeader {
263        &self.header
264    }
265}
266
267#[cfg(feature = "streaming")]
268impl PlyChunkSource<std::io::BufReader<std::fs::File>> {
269    /// Opens a local PLY file for bounded chunk reads.
270    pub fn open(
271        path: impl AsRef<std::path::Path>,
272        options: StreamOptions,
273        cancellation: CancellationToken,
274    ) -> Result<Self, IoError> {
275        Self::new(std::io::BufReader::new(std::fs::File::open(path)?), options, cancellation)
276    }
277}
278
279#[cfg(feature = "streaming")]
280impl<R: BufRead> BoundedSpatialRecordSource for PlyChunkSource<R> {
281    fn schema(&self) -> &SchemaDescriptor {
282        &self.state.schema
283    }
284
285    fn options(&self) -> &StreamOptions {
286        &self.state.options
287    }
288
289    fn memory_tracker(&self) -> &MemoryTracker {
290        &self.state.tracker
291    }
292
293    fn cancellation_token(&self) -> CancellationToken {
294        self.state.cancellation.clone()
295    }
296
297    fn max_chunk_bytes(&self) -> u64 {
298        self.state.max_chunk_bytes
299    }
300
301    fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
302        if self.loaded == self.header.vertex_count {
303            return None;
304        }
305        let count = (self.header.vertex_count - self.loaded).min(self.state.options.chunk_points());
306        let scratch = match self.header.format {
307            PlyFormat::Ascii => 0,
308            PlyFormat::BinaryLittleEndian => match self
309                .header
310                .vertex_stride()
311                .checked_mul(count)
312                .and_then(|bytes| u64::try_from(bytes).ok())
313            {
314                Some(bytes) => bytes,
315                None => {
316                    return Some(Err(spatialrust_records::RecordsError::InvalidChunk(
317                        "PLY binary chunk size overflow".into(),
318                    )));
319                }
320            },
321        };
322        let reservation = match self.state.reserve_points_with_scratch(count, scratch) {
323            Ok(reservation) => reservation,
324            Err(error) => return Some(Err(error)),
325        };
326        let schema = self.state.schema.point_schema().clone();
327        let mut buffers = PointBufferSet::new();
328        for field in schema.fields() {
329            buffers.insert(field.name.clone(), PointBuffer::with_capacity(field.dtype, count));
330        }
331
332        let decoded = match self.header.format {
333            PlyFormat::Ascii => {
334                let mut result = Ok(());
335                let mut line_buffer = [0_u8; MAX_ASCII_RECORD_BYTES];
336                let mut decoded_count = 0;
337                while decoded_count < count {
338                    let line =
339                        match read_bounded_ascii_line(&mut self.reader, &mut line_buffer, "PLY") {
340                            Ok(Some(line)) => line,
341                            Ok(None) => {
342                                return Some(Err(records_io(ply_parse(format!(
343                                    "unexpected EOF after {} of {} ASCII vertices",
344                                    self.loaded + decoded_count,
345                                    self.header.vertex_count
346                                )))));
347                            }
348                            Err(error) => return Some(Err(records_io(error))),
349                        };
350                    let trimmed = line.trim();
351                    if trimmed.is_empty() || trimmed.starts_with('#') {
352                        continue;
353                    }
354                    let mut tokens = trimmed.split_whitespace();
355                    for property in &self.header.properties {
356                        let value = tokens
357                            .next()
358                            .ok_or_else(|| {
359                                ply_parse(format!("missing property `{}`", property.name))
360                            })
361                            .and_then(|token| {
362                                token.parse::<f64>().map_err(|_| {
363                                    ply_parse(format!("invalid ASCII value `{token}`"))
364                                })
365                            })
366                            .and_then(|value| {
367                                push_to_field(&mut buffers, &schema, &property.name, value as f32)
368                            });
369                        if let Err(error) = value {
370                            result = Err(error);
371                            break;
372                        }
373                    }
374                    if result.is_err() {
375                        break;
376                    }
377                    decoded_count += 1;
378                }
379                result
380            }
381            PlyFormat::BinaryLittleEndian => {
382                let stride = self.header.vertex_stride();
383                let mut payload = vec![0_u8; stride * count];
384                std::io::Read::read_exact(&mut self.reader, &mut payload)
385                    .map_err(IoError::from)
386                    .and_then(|()| {
387                        payload.chunks_exact(stride).try_for_each(|vertex| {
388                            let mut offset = 0;
389                            for property in &self.header.properties {
390                                let size = property.kind.size_bytes();
391                                let value = read_binary_scalar(
392                                    property.kind,
393                                    &vertex[offset..offset + size],
394                                )?;
395                                push_to_field(&mut buffers, &schema, &property.name, value)?;
396                                offset += size;
397                            }
398                            Ok::<_, IoError>(())
399                        })
400                    })
401            }
402        };
403        if let Err(error) = decoded {
404            return Some(Err(records_io(error)));
405        }
406        let cloud = match PointCloud::try_from_parts(schema, buffers, self.metadata.clone()) {
407            Ok(cloud) => cloud,
408            Err(error) => return Some(Err(error.into())),
409        };
410        self.loaded += count;
411        Some(self.state.lease(cloud, reservation))
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::read_ply;
418    use crate::ply::writer::{write_ply, PlyWriteFormat};
419    use spatialrust_core::{HasIntensity, HasPositions3, PointCloudBuilder, StandardSchemas};
420    use std::io::Cursor;
421
422    const SAMPLE_XYZ_ASCII: &str = "\
423ply
424format ascii 1.0
425element vertex 3
426property float x
427property float y
428property float z
429end_header
4300.0 0.0 0.0
4311.0 0.0 0.0
4320.0 1.0 0.0
433";
434
435    const SAMPLE_XYZI_ASCII: &str = "\
436ply
437format ascii 1.0
438element vertex 2
439property float x
440property float y
441property float z
442property float intensity
443end_header
4440.0 0.0 0.0 0.5
4451.0 0.0 0.0 0.8
446";
447
448    #[test]
449    fn reads_ascii_xyz() {
450        let mut reader = Cursor::new(SAMPLE_XYZ_ASCII.as_bytes());
451        let cloud = read_ply(&mut reader).unwrap();
452        assert_eq!(cloud.len(), 3);
453        let (x, y, z) = cloud.positions3().unwrap();
454        assert_eq!(x, &[0.0, 1.0, 0.0]);
455        assert_eq!(y, &[0.0, 0.0, 1.0]);
456        assert_eq!(z, &[0.0, 0.0, 0.0]);
457    }
458
459    #[test]
460    fn reads_ascii_xyzi() {
461        let mut reader = Cursor::new(SAMPLE_XYZI_ASCII.as_bytes());
462        let cloud = read_ply(&mut reader).unwrap();
463        assert_eq!(cloud.intensity().unwrap(), &[0.5, 0.8]);
464    }
465
466    #[test]
467    fn roundtrip_ascii_xyz() {
468        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
469        builder.push_point([0.0, 0.0, 0.0]).unwrap();
470        builder.push_point([1.0, 2.0, 3.0]).unwrap();
471        let cloud = builder.build().unwrap();
472
473        let mut buffer = Vec::new();
474        write_ply(&mut buffer, &cloud, PlyWriteFormat::Ascii).unwrap();
475
476        let mut reader = Cursor::new(buffer);
477        let loaded = read_ply(&mut reader).unwrap();
478        assert_eq!(loaded.len(), cloud.len());
479        let (x, y, z) = loaded.positions3().unwrap();
480        assert_eq!(x, cloud.field("x").unwrap().as_f32().unwrap());
481        assert_eq!(y, cloud.field("y").unwrap().as_f32().unwrap());
482        assert_eq!(z, cloud.field("z").unwrap().as_f32().unwrap());
483    }
484
485    #[test]
486    fn roundtrip_binary_xyz() {
487        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
488        builder.push_point([0.5, 1.5, 2.5]).unwrap();
489        let cloud = builder.build().unwrap();
490
491        let mut buffer = Vec::new();
492        write_ply(&mut buffer, &cloud, PlyWriteFormat::BinaryLittleEndian).unwrap();
493
494        let mut reader = Cursor::new(buffer);
495        let loaded = read_ply(&mut reader).unwrap();
496        let (x, _, _) = loaded.positions3().unwrap();
497        assert!((x[0] - 0.5).abs() < 1e-6);
498    }
499}