Skip to main content

spatialrust_io/
format.rs

1//! Extension-based point cloud file format detection and dispatch.
2
3use std::path::Path;
4
5use spatialrust_core::PointCloud;
6
7use crate::IoError;
8
9/// Supported point cloud file formats detected from file extensions.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum PointCloudFileFormat {
12    /// PCD (Point Cloud Data).
13    Pcd,
14    /// PLY (Polygon File Format).
15    Ply,
16    /// LAS (ASPRS LiDAR).
17    Las,
18    /// LAZ (compressed LAS).
19    Laz,
20    /// E57 (ASTM E2807).
21    E57,
22    /// COPC (Cloud Optimized Point Cloud).
23    Copc,
24}
25
26impl PointCloudFileFormat {
27    /// Returns the canonical lowercase file extension without a leading dot.
28    #[must_use]
29    pub const fn extension(self) -> &'static str {
30        match self {
31            Self::Pcd => "pcd",
32            Self::Ply => "ply",
33            Self::Las => "las",
34            Self::Laz => "laz",
35            Self::E57 => "e57",
36            Self::Copc => "copc.laz",
37        }
38    }
39}
40
41/// Detects a point cloud format from a file path extension.
42#[must_use]
43pub fn detect_point_cloud_format(path: impl AsRef<Path>) -> Option<PointCloudFileFormat> {
44    let path = path.as_ref();
45    let file_name = path.file_name()?.to_str()?.to_ascii_lowercase();
46    if file_name.ends_with(".copc.laz") || file_name.ends_with(".copc.las") {
47        return Some(PointCloudFileFormat::Copc);
48    }
49
50    let extension = path.extension()?.to_str()?.to_ascii_lowercase();
51    match extension.as_str() {
52        "pcd" => Some(PointCloudFileFormat::Pcd),
53        "ply" => Some(PointCloudFileFormat::Ply),
54        "las" => Some(PointCloudFileFormat::Las),
55        "laz" => Some(PointCloudFileFormat::Laz),
56        "e57" => Some(PointCloudFileFormat::E57),
57        _ => None,
58    }
59}
60
61/// Reads a point cloud from disk, dispatching on the file extension.
62pub fn read_point_cloud_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
63    let path = path.as_ref();
64    let format = detect_point_cloud_format(path).ok_or_else(|| {
65        IoError::Io(format!("unsupported or missing point cloud extension: {}", path.display()))
66    })?;
67    read_point_cloud_file_with_format(path, format)
68}
69
70/// Reads a point cloud using an explicit format.
71pub fn read_point_cloud_file_with_format(
72    path: impl AsRef<Path>,
73    format: PointCloudFileFormat,
74) -> Result<PointCloud, IoError> {
75    match format {
76        PointCloudFileFormat::Pcd => read_pcd_file(path),
77        PointCloudFileFormat::Ply => read_ply_file(path),
78        PointCloudFileFormat::Las => read_las_file(path),
79        PointCloudFileFormat::Laz => read_laz_file(path),
80        PointCloudFileFormat::E57 => read_e57_file(path),
81        PointCloudFileFormat::Copc => read_copc_file(path),
82    }
83}
84
85/// Writes a point cloud to disk, dispatching on the file extension.
86pub fn write_point_cloud_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
87    let path = path.as_ref();
88    let format = detect_point_cloud_format(path).ok_or_else(|| {
89        IoError::Io(format!("unsupported or missing point cloud extension: {}", path.display()))
90    })?;
91    write_point_cloud_file_with_format(path, cloud, format)
92}
93
94/// Writes a point cloud using an explicit format.
95pub fn write_point_cloud_file_with_format(
96    path: impl AsRef<Path>,
97    cloud: &PointCloud,
98    format: PointCloudFileFormat,
99) -> Result<(), IoError> {
100    match format {
101        PointCloudFileFormat::Pcd => write_pcd_file(path, cloud),
102        PointCloudFileFormat::Ply => write_ply_file(path, cloud),
103        PointCloudFileFormat::Las => write_las_file(path, cloud),
104        PointCloudFileFormat::Laz => write_laz_file(path, cloud),
105        PointCloudFileFormat::E57 => write_e57_file(path, cloud),
106        PointCloudFileFormat::Copc => write_copc_file(path, cloud),
107    }
108}
109
110#[cfg(feature = "io-pcd")]
111fn read_pcd_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
112    crate::pcd::read_pcd_file(path)
113}
114
115#[cfg(not(feature = "io-pcd"))]
116fn read_pcd_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
117    Err(missing_feature_error("io-pcd", path.as_ref()))
118}
119
120#[cfg(feature = "io-ply")]
121fn read_ply_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
122    crate::ply::read_ply_file(path)
123}
124
125#[cfg(not(feature = "io-ply"))]
126fn read_ply_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
127    Err(missing_feature_error("io-ply", path.as_ref()))
128}
129
130#[cfg(feature = "io-las")]
131fn read_las_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
132    crate::las::read_las_file(path)
133}
134
135#[cfg(not(feature = "io-las"))]
136fn read_las_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
137    Err(missing_feature_error("io-las", path.as_ref()))
138}
139
140#[cfg(feature = "io-laz")]
141fn read_laz_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
142    crate::las::read_las_file(path)
143}
144
145#[cfg(not(feature = "io-laz"))]
146fn read_laz_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
147    Err(missing_feature_error("io-laz", path.as_ref()))
148}
149
150#[cfg(feature = "io-e57")]
151fn read_e57_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
152    crate::e57::read_e57_file(path)
153}
154
155#[cfg(not(feature = "io-e57"))]
156fn read_e57_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
157    Err(missing_feature_error("io-e57", path.as_ref()))
158}
159
160#[cfg(feature = "io-copc")]
161fn read_copc_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
162    crate::copc::read_copc_file(path)
163}
164
165#[cfg(not(feature = "io-copc"))]
166fn read_copc_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
167    Err(missing_feature_error("io-copc", path.as_ref()))
168}
169
170#[cfg(feature = "io-pcd")]
171fn write_pcd_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
172    crate::pcd::write_pcd_file(path, cloud, crate::pcd::PcdWriteFormat::Binary)
173}
174
175#[cfg(not(feature = "io-pcd"))]
176fn write_pcd_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
177    Err(missing_feature_error("io-pcd", path.as_ref()))
178}
179
180#[cfg(feature = "io-ply")]
181fn write_ply_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
182    crate::ply::write_ply_file(path, cloud, crate::ply::PlyWriteFormat::BinaryLittleEndian)
183}
184
185#[cfg(not(feature = "io-ply"))]
186fn write_ply_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
187    Err(missing_feature_error("io-ply", path.as_ref()))
188}
189
190#[cfg(feature = "io-las")]
191fn write_las_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
192    crate::las::write_las_file(path, cloud, crate::las::LasWriteFormat::Las)
193}
194
195#[cfg(not(feature = "io-las"))]
196fn write_las_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
197    Err(missing_feature_error("io-las", path.as_ref()))
198}
199
200#[cfg(feature = "io-laz")]
201fn write_laz_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
202    crate::las::write_las_file(path, cloud, crate::las::LasWriteFormat::Laz)
203}
204
205#[cfg(not(feature = "io-laz"))]
206fn write_laz_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
207    Err(missing_feature_error("io-laz", path.as_ref()))
208}
209
210#[cfg(feature = "io-e57")]
211fn write_e57_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
212    crate::e57::write_e57_file(path, cloud)
213}
214
215#[cfg(not(feature = "io-e57"))]
216fn write_e57_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
217    Err(missing_feature_error("io-e57", path.as_ref()))
218}
219
220#[cfg(feature = "io-copc")]
221fn write_copc_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
222    crate::copc::write_copc_file(path, cloud)
223}
224
225#[cfg(not(feature = "io-copc"))]
226fn write_copc_file(path: impl AsRef<Path>, _cloud: &PointCloud) -> Result<(), IoError> {
227    Err(missing_feature_error("io-copc", path.as_ref()))
228}
229
230#[allow(dead_code)] // only used by the per-feature fallback stubs
231fn missing_feature_error(feature: &str, path: &Path) -> IoError {
232    IoError::Io(format!("reading or writing `{}` requires the `{feature}` feature", path.display()))
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{detect_point_cloud_format, PointCloudFileFormat};
238    use std::path::PathBuf;
239
240    #[test]
241    fn detects_known_extensions() {
242        assert_eq!(detect_point_cloud_format("scan.pcd"), Some(PointCloudFileFormat::Pcd));
243        assert_eq!(
244            detect_point_cloud_format(PathBuf::from("/tmp/cloud.PLY")),
245            Some(PointCloudFileFormat::Ply)
246        );
247        assert_eq!(detect_point_cloud_format("data.xyz"), None);
248        assert_eq!(detect_point_cloud_format("scan.copc.laz"), Some(PointCloudFileFormat::Copc));
249    }
250
251    #[cfg(feature = "io-pcd")]
252    #[test]
253    fn roundtrip_via_auto_dispatch() {
254        use super::{read_point_cloud_file, write_point_cloud_file};
255        use spatialrust_core::PointCloudBuilder;
256
257        let mut builder = PointCloudBuilder::xyz();
258        builder.push_point([1.0, 2.0, 3.0]).unwrap();
259        let cloud = builder.build().unwrap();
260
261        let path =
262            std::env::temp_dir().join(format!("spatialrust_auto_{}.pcd", std::process::id()));
263        write_point_cloud_file(&path, &cloud).unwrap();
264        let loaded = read_point_cloud_file(&path).unwrap();
265        let _ = std::fs::remove_file(path);
266        assert_eq!(loaded.len(), 1);
267    }
268}