Skip to main content

lepcc_ffi/
lib.rs

1//! Safe Rust bindings for the [Esri LEPCC](https://github.com/Esri/lepcc)
2//! point-cloud compression library.
3//!
4//! Note that `Context` is **not** `Send + Sync` — create one per thread / per decode call.
5//!
6//! LEPCC (Limited Error Point Cloud Compression) is the codec used by the I3S
7//! PointCloud layer type to compress:
8//!
9//! * **XYZ** — 3-D coordinates (`lepcc-xyz` blobs)
10//! * **RGB** — 8-bit-per-channel colour (`lepcc-rgb` blobs)
11//! * **Intensity** — 16-bit intensity (`lepcc-intensity` blobs)
12//! * **FlagBytes** — per-point classification flags
13//!
14//! # Usage
15//!
16//! ```no_run
17//! use lepcc_ffi::Context;
18//!
19//! // Fetch LEPCC blob
20//! let blob: Vec<u8> = todo!("fetch lepcc-xyz blob from I3S");
21//!
22//! // Create a LEPCC context
23//! let ctx = Context::new();
24//!
25//! // Get blob info
26//! let (blob_type, blob_size) = ctx.blob_info(&blob).unwrap();
27//!
28//! // Decode blobs
29//! let points: Vec<[f64; 3]> = ctx.decode_xyz(&blob).unwrap();
30//! let colors: Vec<[u8; 3]> = ctx.decode_rgb(&blob).unwrap();
31//! let intensity: Vec<u16> = ctx.decode_intensity(&blob).unwrap();
32//! let flags: Vec<u8> = ctx.decode_flag_bytes(&blob).unwrap();
33//!
34//! // Encode
35//! let xyz_blob = ctx.encode_xyz(&points, 0.001).unwrap();
36//! let rgb_blob = ctx.encode_rgb(&colors).unwrap();
37//! let intensity_blob = ctx.encode_intensity(&intensity).unwrap();
38//! let flag_bytes_blob = ctx.encode_flag_bytes(&flags).unwrap();
39//! ```
40
41mod sys;
42
43use std::ffi::c_int;
44
45use thiserror::Error;
46
47/// Errors returned by LEPCC operations.
48#[derive(Debug, Error)]
49pub enum LepccError {
50    #[error("LEPCC returned status code {0}")]
51    Status(u32),
52    #[error("input buffer too large for c_int")]
53    BufferTooLarge,
54}
55
56pub type Result<T> = std::result::Result<T, LepccError>;
57
58fn buf_len(data: &[u8]) -> Result<c_int> {
59    c_int::try_from(data.len()).map_err(|_| LepccError::BufferTooLarge)
60}
61
62fn check(status: u32) -> Result<()> {
63    if status == 0 {
64        Ok(())
65    } else {
66        Err(LepccError::Status(status))
67    }
68}
69
70/// RAII wrapper around a `lepcc_ContextHdl`.
71///
72/// Each method operates on a fresh decode pass — the underlying C context is
73/// reset by each call.  `Context` is **not** `Send + Sync` because the raw
74/// pointer is single-threaded; create one per thread / per decode call.
75pub struct Context {
76    hdl: sys::lepcc_ContextHdl,
77}
78
79impl Context {
80    /// Create a new LEPCC context.
81    ///
82    /// # Panics
83    ///
84    /// Panics if the underlying C allocation fails (returns null).
85    pub fn new() -> Self {
86        let hdl = unsafe { sys::lepcc_createContext() };
87        assert!(!hdl.is_null(), "lepcc_createContext returned null");
88        Self { hdl }
89    }
90
91    /// Identify the type and byte-length of the next blob in `data`.
92    ///
93    /// Returns `(blob_type, blob_size_in_bytes)`.
94    pub fn blob_info(&self, data: &[u8]) -> Result<(u32, u32)> {
95        let mut blob_type = 0u32;
96        let mut blob_size = 0u32;
97        let status = unsafe {
98            sys::lepcc_getBlobInfo(
99                self.hdl,
100                data.as_ptr(),
101                buf_len(data)?,
102                &mut blob_type,
103                &mut blob_size,
104            )
105        };
106        check(status)?;
107        Ok((blob_type, blob_size))
108    }
109
110    /// Decode a `lepcc-xyz` blob into a vector of `[x, y, z]` coordinates.
111    pub fn decode_xyz(&self, data: &[u8]) -> Result<Vec<[f64; 3]>> {
112        let len = buf_len(data)?;
113
114        // Query point count
115        let mut n_pts = 0u32;
116        let status = unsafe { sys::lepcc_getPointCount(self.hdl, data.as_ptr(), len, &mut n_pts) };
117        check(status)?;
118
119        let mut out = vec![[0.0f64; 3]; n_pts as usize];
120        let mut ptr = data.as_ptr();
121        let mut n_out = n_pts;
122        let status = unsafe {
123            sys::lepcc_decodeXYZ(
124                self.hdl,
125                &mut ptr,
126                len,
127                &mut n_out,
128                out.as_mut_ptr() as *mut f64,
129            )
130        };
131        check(status)?;
132        Ok(out)
133    }
134
135    /// Decode a `lepcc-rgb` blob into a vector of `[r, g, b]` byte triples.
136    pub fn decode_rgb(&self, data: &[u8]) -> Result<Vec<[u8; 3]>> {
137        let len = buf_len(data)?;
138
139        let mut n_rgb = 0u32;
140        let status = unsafe { sys::lepcc_getRGBCount(self.hdl, data.as_ptr(), len, &mut n_rgb) };
141        check(status)?;
142
143        let mut out = vec![[0u8; 3]; n_rgb as usize];
144        let mut ptr = data.as_ptr();
145        let mut n_out = n_rgb;
146        let status = unsafe {
147            sys::lepcc_decodeRGB(
148                self.hdl,
149                &mut ptr,
150                len,
151                &mut n_out,
152                out.as_mut_ptr() as *mut u8,
153            )
154        };
155        check(status)?;
156        Ok(out)
157    }
158
159    /// Decode a `lepcc-intensity` blob into a vector of `u16` intensity values.
160    pub fn decode_intensity(&self, data: &[u8]) -> Result<Vec<u16>> {
161        let len = buf_len(data)?;
162
163        let mut n_vals = 0u32;
164        let status =
165            unsafe { sys::lepcc_getIntensityCount(self.hdl, data.as_ptr(), len, &mut n_vals) };
166        check(status)?;
167
168        let mut out = vec![0u16; n_vals as usize];
169        let mut ptr = data.as_ptr();
170        let mut n_out = n_vals;
171        let status = unsafe {
172            sys::lepcc_decodeIntensity(self.hdl, &mut ptr, len, &mut n_out, out.as_mut_ptr())
173        };
174        check(status)?;
175        Ok(out)
176    }
177
178    /// Decode a `lepcc-flagbytes` blob into a vector of classification bytes.
179    pub fn decode_flag_bytes(&self, data: &[u8]) -> Result<Vec<u8>> {
180        let len = buf_len(data)?;
181
182        let mut n_vals = 0u32;
183        let status =
184            unsafe { sys::lepcc_getFlagByteCount(self.hdl, data.as_ptr(), len, &mut n_vals) };
185        check(status)?;
186
187        let mut out = vec![0u8; n_vals as usize];
188        let mut ptr = data.as_ptr();
189        let mut n_out = n_vals;
190        let status = unsafe {
191            sys::lepcc_decodeFlagBytes(self.hdl, &mut ptr, len, &mut n_out, out.as_mut_ptr())
192        };
193        check(status)?;
194        Ok(out)
195    }
196
197    /// Encode XYZ coordinates into a LEPCC blob.
198    ///
199    /// `max_err` is the maximum absolute error per axis (e.g. `0.001` metres).
200    pub fn encode_xyz(&self, points: &[[f64; 3]], max_err: f64) -> Result<Vec<u8>> {
201        let n = points.len() as u32;
202        let raw = points.as_ptr() as *const f64;
203
204        let mut n_bytes = 0u32;
205        let status = unsafe {
206            sys::lepcc_computeCompressedSizeXYZ(
207                self.hdl,
208                n,
209                raw,
210                max_err,
211                max_err,
212                max_err,
213                &mut n_bytes,
214                std::ptr::null_mut(), // order_out — not needed for decode-only use
215            )
216        };
217        check(status)?;
218
219        let mut buf = vec![0u8; n_bytes as usize];
220        let mut ptr = buf.as_mut_ptr();
221        let status = unsafe { sys::lepcc_encodeXYZ(self.hdl, &mut ptr, n_bytes as c_int) };
222        check(status)?;
223        Ok(buf)
224    }
225
226    /// Encode RGB colours into a LEPCC blob.
227    pub fn encode_rgb(&self, colours: &[[u8; 3]]) -> Result<Vec<u8>> {
228        let n = colours.len() as u32;
229        let raw = colours.as_ptr() as *const u8;
230
231        let mut n_bytes = 0u32;
232        let status = unsafe { sys::lepcc_computeCompressedSizeRGB(self.hdl, n, raw, &mut n_bytes) };
233        check(status)?;
234
235        let mut buf = vec![0u8; n_bytes as usize];
236        let mut ptr = buf.as_mut_ptr();
237        let status = unsafe { sys::lepcc_encodeRGB(self.hdl, &mut ptr, n_bytes as c_int) };
238        check(status)?;
239        Ok(buf)
240    }
241
242    /// Encode intensity values into a LEPCC blob.
243    pub fn encode_intensity(&self, values: &[u16]) -> Result<Vec<u8>> {
244        let n = values.len() as u32;
245
246        let mut n_bytes = 0u32;
247        let status = unsafe {
248            sys::lepcc_computeCompressedSizeIntensity(self.hdl, n, values.as_ptr(), &mut n_bytes)
249        };
250        check(status)?;
251
252        let mut buf = vec![0u8; n_bytes as usize];
253        let mut ptr = buf.as_mut_ptr();
254        let status = unsafe {
255            sys::lepcc_encodeIntensity(self.hdl, &mut ptr, n_bytes as c_int, values.as_ptr(), n)
256        };
257        check(status)?;
258        Ok(buf)
259    }
260
261    /// Encode classification flag bytes into a LEPCC blob.
262    pub fn encode_flag_bytes(&self, flags: &[u8]) -> Result<Vec<u8>> {
263        let n = flags.len() as u32;
264
265        let mut n_bytes = 0u32;
266        let status = unsafe {
267            sys::lepcc_computeCompressedSizeFlagBytes(self.hdl, n, flags.as_ptr(), &mut n_bytes)
268        };
269        check(status)?;
270
271        let mut buf = vec![0u8; n_bytes as usize];
272        let mut ptr = buf.as_mut_ptr();
273        let status = unsafe {
274            sys::lepcc_encodeFlagBytes(self.hdl, &mut ptr, n_bytes as c_int, flags.as_ptr(), n)
275        };
276        check(status)?;
277        Ok(buf)
278    }
279}
280
281impl Default for Context {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl Drop for Context {
288    fn drop(&mut self) {
289        unsafe { sys::lepcc_deleteContext(&mut self.hdl) };
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn sample_points() -> Vec<[f64; 3]> {
298        vec![
299            [0.0, 0.0, 0.0],
300            [1.0, 2.0, 3.0],
301            [100.0, 200.0, 50.5],
302            [-10.5, 300.0, 0.001],
303        ]
304    }
305
306    #[test]
307    fn roundtrip_xyz() {
308        let ctx = Context::new();
309        let pts = sample_points();
310        let blob = ctx.encode_xyz(&pts, 0.001).expect("encode_xyz failed");
311        assert!(!blob.is_empty());
312
313        let ctx2 = Context::new();
314        let decoded = ctx2.decode_xyz(&blob).expect("decode_xyz failed");
315        assert_eq!(decoded.len(), pts.len());
316        for (orig, dec) in pts.iter().zip(decoded.iter()) {
317            assert!(
318                (orig[0] - dec[0]).abs() <= 0.002,
319                "x mismatch: {} vs {}",
320                orig[0],
321                dec[0]
322            );
323            assert!(
324                (orig[1] - dec[1]).abs() <= 0.002,
325                "y mismatch: {} vs {}",
326                orig[1],
327                dec[1]
328            );
329            assert!(
330                (orig[2] - dec[2]).abs() <= 0.002,
331                "z mismatch: {} vs {}",
332                orig[2],
333                dec[2]
334            );
335        }
336    }
337
338    #[test]
339    fn roundtrip_rgb() {
340        let ctx = Context::new();
341        let colours: Vec<[u8; 3]> = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [128, 128, 128]];
342        let blob = ctx.encode_rgb(&colours).expect("encode_rgb failed");
343        assert!(!blob.is_empty());
344
345        let ctx2 = Context::new();
346        let decoded = ctx2.decode_rgb(&blob).expect("decode_rgb failed");
347        assert_eq!(decoded, colours);
348    }
349
350    #[test]
351    fn roundtrip_intensity() {
352        let ctx = Context::new();
353        let values: Vec<u16> = vec![0, 1000, 32768, 65535, 512];
354        let blob = ctx
355            .encode_intensity(&values)
356            .expect("encode_intensity failed");
357        assert!(!blob.is_empty());
358
359        let ctx2 = Context::new();
360        let decoded = ctx2
361            .decode_intensity(&blob)
362            .expect("decode_intensity failed");
363        assert_eq!(decoded, values);
364    }
365
366    #[test]
367    fn roundtrip_flag_bytes() {
368        let ctx = Context::new();
369        let flags: Vec<u8> = vec![0, 1, 2, 64, 128, 255];
370        let blob = ctx
371            .encode_flag_bytes(&flags)
372            .expect("encode_flag_bytes failed");
373        assert!(!blob.is_empty());
374
375        let ctx2 = Context::new();
376        let decoded = ctx2
377            .decode_flag_bytes(&blob)
378            .expect("decode_flag_bytes failed");
379        assert_eq!(decoded, flags);
380    }
381
382    #[test]
383    fn blob_info_xyz() {
384        let ctx = Context::new();
385        let pts = sample_points();
386        let blob = ctx.encode_xyz(&pts, 0.001).unwrap();
387
388        let ctx2 = Context::new();
389        let (blob_type, blob_size) = ctx2.blob_info(&blob).unwrap();
390        assert!(blob_size > 0);
391        // blob_type 0 = XYZ (indexed in encounter order: 0=XYZ, 1=RGB, 2=Intensity)
392        assert_eq!(blob_type, 0, "unexpected blob type {blob_type}");
393    }
394
395    /// Paths to the test files bundled under `extern/lepcc/testData/`.
396    const SLPK: &str = concat!(
397        env!("CARGO_MANIFEST_DIR"),
398        "/extern/lepcc/testData/SMALL_AUTZEN_LAS_All.slpk"
399    );
400    const GT_BIN: &str = concat!(
401        env!("CARGO_MANIFEST_DIR"),
402        "/extern/lepcc/testData/SMALL_AUTZEN_LAS_All.bin"
403    );
404
405    fn read_file(path: &str) -> Vec<u8> {
406        use std::io::Read;
407        let mut f =
408            std::fs::File::open(path).unwrap_or_else(|_| panic!("test data not found: {path}"));
409        let mut buf = Vec::new();
410        f.read_to_end(&mut buf).unwrap();
411        buf
412    }
413
414    // The `.bin` ground-truth file stores results in SLPK blob-encounter order.
415    // Each block is:  u32 count (little-endian)  +  count × stride raw bytes.
416    fn read_gt_block<'a>(cursor: &mut &'a [u8], stride: usize) -> (u32, &'a [u8]) {
417        let n = u32::from_le_bytes(cursor[..4].try_into().unwrap());
418        *cursor = &cursor[4..];
419        let bytes = n as usize * stride;
420        let block = &cursor[..bytes];
421        *cursor = &cursor[bytes..];
422        (n, block)
423    }
424
425    fn bytes_as_f64_le(bytes: &[u8]) -> Vec<f64> {
426        bytes
427            .chunks_exact(8)
428            .map(|b| f64::from_le_bytes(b.try_into().unwrap()))
429            .collect()
430    }
431
432    fn bytes_as_u16_le(bytes: &[u8]) -> Vec<u16> {
433        bytes
434            .chunks_exact(2)
435            .map(|b| u16::from_le_bytes(b.try_into().unwrap()))
436            .collect()
437    }
438
439    /// Mirrors `Test_C_Api.cpp`: scan the SLPK for XYZ / RGB / Intensity blobs,
440    /// decode each one, and compare against the pre-computed `.bin` ground truth.
441    ///
442    /// The scan uses `blob_info()` to jump over each blob after decoding it,
443    /// preventing false-positive magic-string matches inside compressed payloads.
444    ///
445    /// Requires the `extern/lepcc/testData/` files (present when the git submodule
446    /// is initialised). Run with `cargo test -- --ignored` to include this test.
447    #[test]
448    #[ignore = "requires extern/lepcc/testData/ from the git submodule"]
449    fn decode_slpk_matches_ground_truth() {
450        let slpk = read_file(SLPK);
451        let gt = read_file(GT_BIN);
452        let mut gt_cursor: &[u8] = &gt;
453
454        // The three magic strings the C++ test searches for (10 bytes each).
455        const MAGIC_LEN: usize = 10;
456        const MAGICS: [(&[u8; MAGIC_LEN], &str); 3] = [
457            (b"LEPCC     ", "xyz"),
458            (b"ClusterRGB", "rgb"),
459            (b"Intensity ", "intensity"),
460        ];
461
462        let info_size = unsafe { sys::lepcc_getBlobInfoSize() } as usize;
463
464        let mut pos = 0usize;
465        let mut blob_count = 0u32;
466
467        while pos + MAGIC_LEN <= slpk.len() {
468            let mut matched = false;
469            for &(magic, kind) in &MAGICS {
470                if &slpk[pos..pos + MAGIC_LEN] != magic.as_ref() {
471                    continue;
472                }
473
474                let remaining = &slpk[pos..];
475                let ctx = Context::new();
476
477                // Get the blob size so we can jump past it.
478                let blob_size = if remaining.len() >= info_size {
479                    ctx.blob_info(remaining)
480                        .map(|(_, sz)| sz as usize)
481                        .unwrap_or(MAGIC_LEN)
482                } else {
483                    MAGIC_LEN
484                };
485
486                match kind {
487                    "xyz" => {
488                        let pts = ctx.decode_xyz(remaining).expect("decode_xyz failed");
489                        let (n_gt, gt_bytes) = read_gt_block(&mut gt_cursor, 24); // 3 × f64
490                        let gt_flat = bytes_as_f64_le(gt_bytes);
491                        assert_eq!(pts.len(), n_gt as usize, "XYZ point count mismatch");
492                        let max_err = pts
493                            .iter()
494                            .zip(gt_flat.chunks_exact(3))
495                            .flat_map(|(p, g)| {
496                                [
497                                    (p[0] - g[0]).abs(),
498                                    (p[1] - g[1]).abs(),
499                                    (p[2] - g[2]).abs(),
500                                ]
501                            })
502                            .fold(0.0_f64, f64::max);
503                        // LEPCC is lossy; the C++ test just prints the max error.
504                        assert!(
505                            max_err < 1e-4,
506                            "XYZ max error {max_err:.2e} exceeds 1e-4 tolerance"
507                        );
508                    }
509                    "rgb" => {
510                        let rgb = ctx.decode_rgb(remaining).expect("decode_rgb failed");
511                        let (n_gt, gt_bytes) = read_gt_block(&mut gt_cursor, 3); // 3 × u8
512                        assert_eq!(rgb.len(), n_gt as usize, "RGB count mismatch");
513                        // Flatten Vec<[u8;3]> to &[u8] for comparison.
514                        let rgb_flat: Vec<u8> = rgb.iter().flat_map(|p| *p).collect();
515                        assert_eq!(rgb_flat.as_slice(), gt_bytes, "RGB values mismatch");
516                    }
517                    "intensity" => {
518                        let intensity = ctx
519                            .decode_intensity(remaining)
520                            .expect("decode_intensity failed");
521                        let (n_gt, gt_bytes) = read_gt_block(&mut gt_cursor, 2); // u16
522                        let gt_vals = bytes_as_u16_le(gt_bytes);
523                        assert_eq!(intensity.len(), n_gt as usize, "Intensity count mismatch");
524                        assert_eq!(intensity, gt_vals, "Intensity values mismatch");
525                    }
526                    _ => unreachable!(),
527                }
528
529                blob_count += 1;
530                pos += blob_size.max(MAGIC_LEN);
531                matched = true;
532                break;
533            }
534            if !matched {
535                pos += 1;
536            }
537        }
538
539        assert!(blob_count > 0, "no LEPCC blobs found in the SLPK");
540        assert!(
541            gt_cursor.is_empty(),
542            "{} bytes of ground truth not consumed — blob count mismatch",
543            gt_cursor.len()
544        );
545    }
546}