Skip to main content

edgefirst_schemas/sensor_msgs/
pointcloud.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4//! Zero-copy point cloud access layer over PointCloud2 data buffers.
5//!
6//! Provides two tiers of access:
7//! - [`DynPointCloud`]: Runtime-defined field inspection with dynamic typed access.
8//! - [`PointCloud`]: Compile-time typed access via the [`Point`] trait.
9//!
10//! Both modes are zero-copy views over the PointCloud2 data buffer.
11//! Field reads use `from_le_bytes` on small byte-array copies (matching
12//! the pattern used by [`CdrCursor`](crate::cdr::CdrCursor)).
13//!
14//! The dynamic tier also provides **type-coercing** access via
15//! [`FieldDesc::read_as_f64`] and [`FieldDesc::read_as_f32`], which
16//! convert any stored [`PointFieldType`] to a common float target.
17//! This is useful when the field's storage type varies across services.
18
19use super::PointFieldView;
20
21/// Maximum number of fields supported by [`DynPointCloud`].
22///
23/// 16 covers all practical sensor outputs. Clouds with more fields
24/// than this should use the static [`PointCloud<P>`] tier instead.
25pub const MAX_FIELDS: usize = 16;
26
27// ── PointFieldType ──────────────────────────────────────────────────
28
29/// Typed representation of PointField datatype constants.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum PointFieldType {
32    Int8 = 1,
33    Uint8 = 2,
34    Int16 = 3,
35    Uint16 = 4,
36    Int32 = 5,
37    Uint32 = 6,
38    Float32 = 7,
39    Float64 = 8,
40}
41
42impl PointFieldType {
43    /// Convert from the ROS2 PointField datatype constant.
44    pub fn from_datatype(dt: u8) -> Option<Self> {
45        match dt {
46            1 => Some(Self::Int8),
47            2 => Some(Self::Uint8),
48            3 => Some(Self::Int16),
49            4 => Some(Self::Uint16),
50            5 => Some(Self::Int32),
51            6 => Some(Self::Uint32),
52            7 => Some(Self::Float32),
53            8 => Some(Self::Float64),
54            _ => None,
55        }
56    }
57
58    /// Size of a single scalar of this type in bytes.
59    pub fn size_bytes(self) -> usize {
60        match self {
61            Self::Int8 | Self::Uint8 => 1,
62            Self::Int16 | Self::Uint16 => 2,
63            Self::Int32 | Self::Uint32 | Self::Float32 => 4,
64            Self::Float64 => 8,
65        }
66    }
67
68    /// Read a single scalar of this type from `data` at `off`, returning
69    /// as `f64`.
70    ///
71    /// All integer and `Float32` variants convert to `f64` without
72    /// precision loss. `Float64` is returned as-is. Returns `None` if
73    /// `data` is too short.
74    ///
75    /// Reads element 0 only. For fields with `count > 1`, subsequent
76    /// elements require manual offset arithmetic via
77    /// [`size_bytes`](Self::size_bytes).
78    pub fn read_as_f64(self, data: &[u8], off: usize) -> Option<f64> {
79        match self {
80            Self::Float64 => Some(f64::from_le_bytes(data.get(off..off + 8)?.try_into().ok()?)),
81            Self::Float32 => {
82                Some(f32::from_le_bytes(data.get(off..off + 4)?.try_into().ok()?) as f64)
83            }
84            Self::Uint32 => {
85                Some(u32::from_le_bytes(data.get(off..off + 4)?.try_into().ok()?) as f64)
86            }
87            Self::Int32 => {
88                Some(i32::from_le_bytes(data.get(off..off + 4)?.try_into().ok()?) as f64)
89            }
90            Self::Uint16 => {
91                Some(u16::from_le_bytes(data.get(off..off + 2)?.try_into().ok()?) as f64)
92            }
93            Self::Int16 => {
94                Some(i16::from_le_bytes(data.get(off..off + 2)?.try_into().ok()?) as f64)
95            }
96            Self::Uint8 => Some(*data.get(off)? as f64),
97            Self::Int8 => Some(*data.get(off)? as i8 as f64),
98        }
99    }
100}
101
102// ── FieldDesc ───────────────────────────────────────────────────────
103
104/// Resolved field descriptor with typed information.
105#[derive(Debug, Clone, Copy)]
106pub struct FieldDesc<'a> {
107    pub name: &'a str,
108    pub byte_offset: u32,
109    pub field_type: PointFieldType,
110    pub count: u32,
111}
112
113impl<'a> FieldDesc<'a> {
114    /// Create from a PointFieldView, validating the datatype.
115    pub fn from_view(view: &PointFieldView<'a>) -> Option<Self> {
116        Some(FieldDesc {
117            name: view.name,
118            byte_offset: view.offset,
119            field_type: PointFieldType::from_datatype(view.datatype)?,
120            count: view.count,
121        })
122    }
123
124    /// Read this field from a point's data slice, converting any numeric
125    /// type to `f64`.
126    ///
127    /// All integer and `Float32` variants convert to `f64` without
128    /// precision loss. `Float64` is returned as-is. Delegates to
129    /// [`PointFieldType::read_as_f64`].
130    ///
131    /// Returns `None` if `point_data` is too short for the field's byte
132    /// offset and type width.
133    pub fn read_as_f64(&self, point_data: &[u8]) -> Option<f64> {
134        self.field_type
135            .read_as_f64(point_data, self.byte_offset as usize)
136    }
137
138    /// Read this field from a point's data slice, converting any numeric
139    /// type to `f32`.
140    ///
141    /// **Precision:** `Int32` / `Uint32` values wider than 24 bits may
142    /// lose precision (f32 mantissa is 23 bits). `Float64` values are
143    /// narrowed and may become `±inf` if outside f32 range.
144    ///
145    /// Returns `None` if `point_data` is too short for the field's byte
146    /// offset and type width.
147    pub fn read_as_f32(&self, point_data: &[u8]) -> Option<f32> {
148        self.field_type
149            .read_as_f64(point_data, self.byte_offset as usize)
150            .map(|v| v as f32)
151    }
152}
153
154// ── PointCloudError ─────────────────────────────────────────────────
155
156/// Errors from pointcloud view construction or field access.
157#[derive(Debug)]
158pub enum PointCloudError {
159    /// A required field was not found in the PointCloud2 field list.
160    FieldNotFound { name: &'static str },
161    /// A field was found but has mismatched type or offset.
162    FieldMismatch {
163        name: &'static str,
164        reason: &'static str,
165    },
166    /// The cloud has more fields than MAX_FIELDS.
167    TooManyFields { found: usize },
168    /// An unrecognized PointField datatype constant was encountered.
169    UnknownDatatype { field_name: String, datatype: u8 },
170    /// Big-endian point data is not supported.
171    BigEndianNotSupported,
172    /// The point step is zero or inconsistent with the data length.
173    InvalidLayout { reason: &'static str },
174    /// A field descriptor's byte offset is out of range for the point data.
175    FieldAccessOutOfBounds { byte_offset: u32 },
176}
177
178impl core::fmt::Display for PointCloudError {
179    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180        match self {
181            Self::FieldNotFound { name } => write!(f, "field not found: {name}"),
182            Self::FieldMismatch { name, reason } => {
183                write!(f, "field mismatch for '{name}': {reason}")
184            }
185            Self::TooManyFields { found } => {
186                write!(f, "too many fields: {found} (max {MAX_FIELDS})")
187            }
188            Self::UnknownDatatype {
189                field_name,
190                datatype,
191            } => write!(f, "unknown datatype {datatype} for field '{field_name}'"),
192            Self::BigEndianNotSupported => write!(f, "big-endian point data not supported"),
193            Self::InvalidLayout { reason } => write!(f, "invalid layout: {reason}"),
194            Self::FieldAccessOutOfBounds { byte_offset } => {
195                write!(f, "field access out of bounds at byte offset {byte_offset}")
196            }
197        }
198    }
199}
200
201impl std::error::Error for PointCloudError {}
202
203// ── PointScalar ─────────────────────────────────────────────────────
204
205/// Maps a Rust primitive to its PointFieldType and provides LE byte reading.
206///
207/// This is an implementation detail of [`define_point!`].
208pub trait PointScalar: Sized {
209    const FIELD_TYPE: PointFieldType;
210    fn read_le(data: &[u8], offset: usize) -> Self;
211}
212
213impl PointScalar for f32 {
214    const FIELD_TYPE: PointFieldType = PointFieldType::Float32;
215    #[inline(always)]
216    fn read_le(data: &[u8], offset: usize) -> Self {
217        let bytes: [u8; 4] = data[offset..offset + 4]
218            .try_into()
219            .expect("bounds checked by caller");
220        f32::from_le_bytes(bytes)
221    }
222}
223
224impl PointScalar for f64 {
225    const FIELD_TYPE: PointFieldType = PointFieldType::Float64;
226    #[inline(always)]
227    fn read_le(data: &[u8], offset: usize) -> Self {
228        let bytes: [u8; 8] = data[offset..offset + 8]
229            .try_into()
230            .expect("bounds checked by caller");
231        f64::from_le_bytes(bytes)
232    }
233}
234
235impl PointScalar for u8 {
236    const FIELD_TYPE: PointFieldType = PointFieldType::Uint8;
237    #[inline(always)]
238    fn read_le(data: &[u8], offset: usize) -> Self {
239        data[offset]
240    }
241}
242
243impl PointScalar for i8 {
244    const FIELD_TYPE: PointFieldType = PointFieldType::Int8;
245    #[inline(always)]
246    fn read_le(data: &[u8], offset: usize) -> Self {
247        data[offset] as i8
248    }
249}
250
251impl PointScalar for u16 {
252    const FIELD_TYPE: PointFieldType = PointFieldType::Uint16;
253    #[inline(always)]
254    fn read_le(data: &[u8], offset: usize) -> Self {
255        let bytes: [u8; 2] = data[offset..offset + 2]
256            .try_into()
257            .expect("bounds checked by caller");
258        u16::from_le_bytes(bytes)
259    }
260}
261
262impl PointScalar for i16 {
263    const FIELD_TYPE: PointFieldType = PointFieldType::Int16;
264    #[inline(always)]
265    fn read_le(data: &[u8], offset: usize) -> Self {
266        let bytes: [u8; 2] = data[offset..offset + 2]
267            .try_into()
268            .expect("bounds checked by caller");
269        i16::from_le_bytes(bytes)
270    }
271}
272
273impl PointScalar for u32 {
274    const FIELD_TYPE: PointFieldType = PointFieldType::Uint32;
275    #[inline(always)]
276    fn read_le(data: &[u8], offset: usize) -> Self {
277        let bytes: [u8; 4] = data[offset..offset + 4]
278            .try_into()
279            .expect("bounds checked by caller");
280        u32::from_le_bytes(bytes)
281    }
282}
283
284impl PointScalar for i32 {
285    const FIELD_TYPE: PointFieldType = PointFieldType::Int32;
286    #[inline(always)]
287    fn read_le(data: &[u8], offset: usize) -> Self {
288        let bytes: [u8; 4] = data[offset..offset + 4]
289            .try_into()
290            .expect("bounds checked by caller");
291        i32::from_le_bytes(bytes)
292    }
293}
294
295// ── Point trait ─────────────────────────────────────────────────────
296
297/// Expected field descriptor for compile-time point validation.
298pub struct ExpectedField {
299    pub name: &'static str,
300    pub byte_offset: u32,
301    pub field_type: PointFieldType,
302}
303
304/// Trait for compile-time typed point structs.
305///
306/// Implement this via the [`define_point!`] macro. The trait provides
307/// field layout metadata for validation and a `read_from` function
308/// for reading a point from a data buffer at a given byte offset.
309pub trait Point: Sized {
310    /// Number of scalar fields.
311    const FIELD_COUNT: usize;
312
313    /// Expected field layout for runtime validation against PointCloud2.
314    fn expected_fields() -> &'static [ExpectedField];
315
316    /// Size of one point record in bytes (max offset + field size).
317    fn point_size() -> u32;
318
319    /// Read one point from a data buffer at the given byte offset.
320    ///
321    /// # Panics
322    /// Panics if `base + point_size() > data.len()`.
323    fn read_from(data: &[u8], base: usize) -> Self;
324}
325
326/// Define a named point struct implementing the [`Point`] trait.
327///
328/// # Example
329/// ```
330/// use edgefirst_schemas::define_point;
331/// use edgefirst_schemas::sensor_msgs::pointcloud::{Point, PointFieldType};
332///
333/// define_point! {
334///     pub struct XyzPoint {
335///         x: f32 => 0,
336///         y: f32 => 4,
337///         z: f32 => 8,
338///     }
339/// }
340///
341/// assert_eq!(XyzPoint::point_size(), 12);
342/// ```
343#[macro_export]
344macro_rules! define_point {
345    (
346        $(#[$meta:meta])*
347        $vis:vis struct $Name:ident {
348            $( $field:ident : $ty:ty => $offset:expr ),+ $(,)?
349        }
350    ) => {
351        $(#[$meta])*
352        #[derive(Debug, Clone, Copy, PartialEq)]
353        $vis struct $Name {
354            $( pub $field: $ty ),+
355        }
356
357        impl $crate::sensor_msgs::pointcloud::Point for $Name {
358            const FIELD_COUNT: usize =
359                $crate::_point_field_count!($($field)+);
360
361            fn expected_fields()
362                -> &'static [$crate::sensor_msgs::pointcloud::ExpectedField]
363            {
364                &[
365                    $(
366                        $crate::sensor_msgs::pointcloud::ExpectedField {
367                            name: stringify!($field),
368                            byte_offset: $offset,
369                            field_type:
370                                <$ty as $crate::sensor_msgs::pointcloud::PointScalar>
371                                    ::FIELD_TYPE,
372                        }
373                    ),+
374                ]
375            }
376
377            fn point_size() -> u32 {
378                let mut size = 0u32;
379                $(
380                    {
381                        let end = $offset + (core::mem::size_of::<$ty>() as u32);
382                        if end > size { size = end; }
383                    }
384                )+
385                size
386            }
387
388            fn read_from(data: &[u8], base: usize) -> Self {
389                $Name {
390                    $(
391                        $field:
392                            <$ty as $crate::sensor_msgs::pointcloud::PointScalar>
393                                ::read_le(data, base + ($offset as usize))
394                    ),+
395                }
396            }
397        }
398    };
399}
400
401/// Helper macro to count fields. Not public API.
402#[macro_export]
403#[doc(hidden)]
404macro_rules! _point_field_count {
405    () => { 0usize };
406    ($x:ident $($rest:ident)*) => {
407        1usize + $crate::_point_field_count!($($rest)*)
408    };
409}
410
411// Re-export macros under the pointcloud module path for discoverability.
412pub use crate::_point_field_count;
413pub use crate::define_point;
414
415// ── DynPointCloud ───────────────────────────────────────────────────
416
417/// Zero-copy dynamic point cloud view over PointCloud2 data.
418///
419/// Fields are resolved at construction time from the PointCloud2 field
420/// descriptors. Access is by field name with explicit type. Supports up
421/// to [`MAX_FIELDS`] fields per point; clouds with more fields should
422/// use the compile-time [`PointCloud<P>`] tier instead.
423///
424/// The returned view borrows the PointCloud2's internal data buffer —
425/// no data is copied. The `PointCloud2` must outlive this view.
426///
427/// # Example
428/// ```ignore
429/// let cloud = DynPointCloud::from_pointcloud2(&pcd2)?;
430/// for point in cloud.iter() {
431///     let x = point.read_f32("x");
432///     let y = point.read_f32("y");
433///     let z = point.read_f32("z");
434/// }
435/// ```
436#[derive(Debug)]
437pub struct DynPointCloud<'a> {
438    data: &'a [u8],
439    point_step: usize,
440    row_step: usize,
441    num_points: usize,
442    fields: [Option<FieldDesc<'a>>; MAX_FIELDS],
443    field_count: usize,
444    height: u32,
445    width: u32,
446}
447
448impl<'a> DynPointCloud<'a> {
449    /// Create a dynamic point cloud view from a PointCloud2 message.
450    ///
451    /// Returns a zero-copy view that borrows the PointCloud2's internal
452    /// data buffer. The `PointCloud2` must outlive the returned view.
453    ///
454    /// # Errors
455    ///
456    /// - [`PointCloudError::BigEndianNotSupported`] — big-endian point data.
457    /// - [`PointCloudError::InvalidLayout`] — `point_step` is zero, `row_step`
458    ///   smaller than `width × point_step`, data buffer shorter than
459    ///   `height × row_step`, or a field extends beyond `point_step`.
460    /// - [`PointCloudError::TooManyFields`] — more than [`MAX_FIELDS`] fields.
461    /// - [`PointCloudError::UnknownDatatype`] — unrecognized PointField datatype.
462    pub fn from_pointcloud2<B: AsRef<[u8]>>(
463        pc: &'a super::PointCloud2<B>,
464    ) -> Result<Self, PointCloudError> {
465        if pc.is_bigendian() {
466            return Err(PointCloudError::BigEndianNotSupported);
467        }
468
469        let point_step = pc.point_step() as usize;
470        if point_step == 0 {
471            return Err(PointCloudError::InvalidLayout {
472                reason: "point_step is zero",
473            });
474        }
475
476        let num_points = pc.point_count();
477        let data = pc.data();
478        let height = pc.height() as usize;
479        let width = pc.width() as usize;
480        let row_step = pc.row_step() as usize;
481
482        if num_points > 0 {
483            // row_step must accommodate at least width × point_step.
484            let min_row_step =
485                width
486                    .checked_mul(point_step)
487                    .ok_or(PointCloudError::InvalidLayout {
488                        reason: "width × point_step overflows usize",
489                    })?;
490            if row_step < min_row_step {
491                return Err(PointCloudError::InvalidLayout {
492                    reason: "row_step smaller than width × point_step",
493                });
494            }
495
496            // Data buffer must hold height × row_step bytes.
497            let required_len =
498                height
499                    .checked_mul(row_step)
500                    .ok_or(PointCloudError::InvalidLayout {
501                        reason: "height × row_step overflows usize",
502                    })?;
503            if data.len() < required_len {
504                return Err(PointCloudError::InvalidLayout {
505                    reason: "data buffer shorter than height × row_step",
506                });
507            }
508        }
509
510        let mut fields = [const { None }; MAX_FIELDS];
511        let mut field_count = 0;
512
513        for view in pc.fields_iter() {
514            if field_count >= MAX_FIELDS {
515                return Err(PointCloudError::TooManyFields {
516                    found: field_count + 1,
517                });
518            }
519            let desc =
520                FieldDesc::from_view(&view).ok_or_else(|| PointCloudError::UnknownDatatype {
521                    field_name: view.name.to_string(),
522                    datatype: view.datatype,
523                })?;
524            // Validate field fits within point_step (accounting for count).
525            let field_size = desc
526                .field_type
527                .size_bytes()
528                .checked_mul(desc.count as usize)
529                .ok_or(PointCloudError::InvalidLayout {
530                    reason: "field count × size overflows usize",
531                })?;
532            let field_end = (desc.byte_offset as usize).checked_add(field_size).ok_or(
533                PointCloudError::InvalidLayout {
534                    reason: "field offset + size overflows usize",
535                },
536            )?;
537            if field_end > point_step {
538                return Err(PointCloudError::InvalidLayout {
539                    reason: "field extends beyond point_step",
540                });
541            }
542            fields[field_count] = Some(desc);
543            field_count += 1;
544        }
545
546        Ok(DynPointCloud {
547            data,
548            point_step,
549            row_step: pc.row_step() as usize,
550            num_points,
551            fields,
552            field_count,
553            height: pc.height(),
554            width: pc.width(),
555        })
556    }
557
558    /// Number of points in the cloud.
559    pub fn len(&self) -> usize {
560        self.num_points
561    }
562
563    /// Whether the cloud is empty.
564    pub fn is_empty(&self) -> bool {
565        self.num_points == 0
566    }
567
568    /// Height (number of rows). 1 for unorganized clouds.
569    pub fn height(&self) -> u32 {
570        self.height
571    }
572
573    /// Width (number of columns per row).
574    pub fn width(&self) -> u32 {
575        self.width
576    }
577
578    /// Point step in bytes.
579    pub fn point_step(&self) -> usize {
580        self.point_step
581    }
582
583    /// Number of fields per point.
584    pub fn field_count(&self) -> usize {
585        self.field_count
586    }
587
588    /// Iterate over field descriptors.
589    pub fn fields(&self) -> impl Iterator<Item = &FieldDesc<'a>> {
590        self.fields[..self.field_count]
591            .iter()
592            .filter_map(|f| f.as_ref())
593    }
594
595    /// Look up a field by name.
596    pub fn field(&self, name: &str) -> Option<&FieldDesc<'a>> {
597        self.fields().find(|f| f.name == name)
598    }
599
600    /// Compute the byte offset of the i-th point, correctly handling
601    /// row padding in organized clouds.
602    #[inline]
603    fn point_offset(&self, i: usize) -> usize {
604        if self.row_step == (self.width as usize) * self.point_step {
605            i * self.point_step
606        } else {
607            let w = self.width as usize;
608            (i / w) * self.row_step + (i % w) * self.point_step
609        }
610    }
611
612    /// Get a single point view by linear index.
613    pub fn point(&self, index: usize) -> Option<DynPoint<'a, '_>> {
614        if index >= self.num_points {
615            return None;
616        }
617        let base = self.point_offset(index);
618        let end = base + self.point_step;
619        if end > self.data.len() {
620            return None;
621        }
622        Some(DynPoint {
623            data: &self.data[base..end],
624            cloud: self,
625        })
626    }
627
628    /// Get a point by (row, col) for organized clouds.
629    ///
630    /// Uses `row_step` to correctly handle row padding in organized clouds.
631    pub fn point_at(&self, row: u32, col: u32) -> Option<DynPoint<'a, '_>> {
632        if row >= self.height || col >= self.width {
633            return None;
634        }
635        let base = (row as usize) * self.row_step + (col as usize) * self.point_step;
636        let end = base + self.point_step;
637        if end > self.data.len() {
638            return None;
639        }
640        Some(DynPoint {
641            data: &self.data[base..end],
642            cloud: self,
643        })
644    }
645
646    /// Iterate over all points.
647    pub fn iter(&self) -> DynPointIter<'a, '_> {
648        DynPointIter {
649            cloud: self,
650            index: 0,
651        }
652    }
653
654    /// Gather a named f32 field into a Vec.
655    ///
656    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
657    /// access without allocation, use [`DynPoint::read_f32_at`] with a
658    /// pre-resolved descriptor instead.
659    pub fn gather_f32(&self, name: &str) -> Option<Vec<f32>> {
660        let desc = self.field(name)?;
661        if desc.field_type != PointFieldType::Float32 {
662            return None;
663        }
664        let off = desc.byte_offset as usize;
665        let mut out = Vec::with_capacity(self.num_points);
666        for i in 0..self.num_points {
667            let base = self.point_offset(i) + off;
668            let bytes: [u8; 4] = self.data[base..base + 4].try_into().ok()?;
669            out.push(f32::from_le_bytes(bytes));
670        }
671        Some(out)
672    }
673
674    /// Gather a named u32 field into a Vec.
675    ///
676    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
677    /// access without allocation, use [`DynPoint::read_u32_at`] with a
678    /// pre-resolved descriptor instead.
679    pub fn gather_u32(&self, name: &str) -> Option<Vec<u32>> {
680        let desc = self.field(name)?;
681        if desc.field_type != PointFieldType::Uint32 {
682            return None;
683        }
684        let off = desc.byte_offset as usize;
685        let mut out = Vec::with_capacity(self.num_points);
686        for i in 0..self.num_points {
687            let base = self.point_offset(i) + off;
688            let bytes: [u8; 4] = self.data[base..base + 4].try_into().ok()?;
689            out.push(u32::from_le_bytes(bytes));
690        }
691        Some(out)
692    }
693
694    /// Gather a named u16 field into a Vec.
695    ///
696    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
697    /// access without allocation, use [`DynPoint::read_u16_at`] with a
698    /// pre-resolved descriptor instead.
699    pub fn gather_u16(&self, name: &str) -> Option<Vec<u16>> {
700        let desc = self.field(name)?;
701        if desc.field_type != PointFieldType::Uint16 {
702            return None;
703        }
704        let off = desc.byte_offset as usize;
705        let mut out = Vec::with_capacity(self.num_points);
706        for i in 0..self.num_points {
707            let base = self.point_offset(i) + off;
708            let bytes: [u8; 2] = self.data[base..base + 2].try_into().ok()?;
709            out.push(u16::from_le_bytes(bytes));
710        }
711        Some(out)
712    }
713
714    /// Gather a named u8 field into a Vec.
715    ///
716    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
717    /// access without allocation, use [`DynPoint::read_u8_at`] with a
718    /// pre-resolved descriptor instead.
719    pub fn gather_u8(&self, name: &str) -> Option<Vec<u8>> {
720        let desc = self.field(name)?;
721        if desc.field_type != PointFieldType::Uint8 {
722            return None;
723        }
724        let off = desc.byte_offset as usize;
725        let mut out = Vec::with_capacity(self.num_points);
726        for i in 0..self.num_points {
727            out.push(self.data[self.point_offset(i) + off]);
728        }
729        Some(out)
730    }
731
732    /// Gather a named i8 field into a Vec.
733    ///
734    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
735    /// access without allocation, use [`DynPoint::read_i8_at`] with a
736    /// pre-resolved descriptor instead.
737    pub fn gather_i8(&self, name: &str) -> Option<Vec<i8>> {
738        let desc = self.field(name)?;
739        if desc.field_type != PointFieldType::Int8 {
740            return None;
741        }
742        let off = desc.byte_offset as usize;
743        let mut out = Vec::with_capacity(self.num_points);
744        for i in 0..self.num_points {
745            out.push(self.data[self.point_offset(i) + off] as i8);
746        }
747        Some(out)
748    }
749
750    /// Gather a named i16 field into a Vec.
751    ///
752    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
753    /// access without allocation, use [`DynPoint::read_i16_at`] with a
754    /// pre-resolved descriptor instead.
755    pub fn gather_i16(&self, name: &str) -> Option<Vec<i16>> {
756        let desc = self.field(name)?;
757        if desc.field_type != PointFieldType::Int16 {
758            return None;
759        }
760        let off = desc.byte_offset as usize;
761        let mut out = Vec::with_capacity(self.num_points);
762        for i in 0..self.num_points {
763            let base = self.point_offset(i) + off;
764            let bytes: [u8; 2] = self.data[base..base + 2].try_into().ok()?;
765            out.push(i16::from_le_bytes(bytes));
766        }
767        Some(out)
768    }
769
770    /// Gather a named i32 field into a Vec.
771    ///
772    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
773    /// access without allocation, use [`DynPoint::read_i32_at`] with a
774    /// pre-resolved descriptor instead.
775    pub fn gather_i32(&self, name: &str) -> Option<Vec<i32>> {
776        let desc = self.field(name)?;
777        if desc.field_type != PointFieldType::Int32 {
778            return None;
779        }
780        let off = desc.byte_offset as usize;
781        let mut out = Vec::with_capacity(self.num_points);
782        for i in 0..self.num_points {
783            let base = self.point_offset(i) + off;
784            let bytes: [u8; 4] = self.data[base..base + 4].try_into().ok()?;
785            out.push(i32::from_le_bytes(bytes));
786        }
787        Some(out)
788    }
789
790    /// Gather a named f64 field into a Vec.
791    ///
792    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
793    /// access without allocation, use [`DynPoint::read_f64_at`] with a
794    /// pre-resolved descriptor instead.
795    pub fn gather_f64(&self, name: &str) -> Option<Vec<f64>> {
796        let desc = self.field(name)?;
797        if desc.field_type != PointFieldType::Float64 {
798            return None;
799        }
800        let off = desc.byte_offset as usize;
801        let mut out = Vec::with_capacity(self.num_points);
802        for i in 0..self.num_points {
803            let base = self.point_offset(i) + off;
804            let bytes: [u8; 8] = self.data[base..base + 8].try_into().ok()?;
805            out.push(f64::from_le_bytes(bytes));
806        }
807        Some(out)
808    }
809
810    /// Gather a named field into a `Vec<f64>`, widening from any stored
811    /// numeric type.
812    ///
813    /// **Note:** Allocates a `Vec` of `num_points` elements. For hot-path
814    /// access without allocation, pre-resolve a [`FieldDesc`] with
815    /// [`DynPointCloud::field`] and call [`FieldDesc::read_as_f64`] with
816    /// [`DynPoint::data`] per point.
817    ///
818    /// Returns `None` if the field does not exist.
819    pub fn gather_as_f64(&self, name: &str) -> Option<Vec<f64>> {
820        let desc = self.field(name)?;
821        let mut out = Vec::with_capacity(self.num_points);
822        for i in 0..self.num_points {
823            let base = self.point_offset(i);
824            let point_data = &self.data[base..base + self.point_step];
825            out.push(desc.read_as_f64(point_data)?);
826        }
827        Some(out)
828    }
829
830    /// Gather a named field into a `Vec<f32>`, widening from any stored
831    /// numeric type.
832    ///
833    /// **Precision:** See [`FieldDesc::read_as_f32`] for precision caveats
834    /// when widening `Int32`/`Uint32` or narrowing `Float64`.
835    ///
836    /// **Note:** Allocates a `Vec` of `num_points` elements.
837    ///
838    /// Returns `None` if the field does not exist.
839    pub fn gather_as_f32(&self, name: &str) -> Option<Vec<f32>> {
840        let desc = self.field(name)?;
841        let mut out = Vec::with_capacity(self.num_points);
842        for i in 0..self.num_points {
843            let base = self.point_offset(i);
844            let point_data = &self.data[base..base + self.point_step];
845            out.push(desc.read_as_f32(point_data)?);
846        }
847        Some(out)
848    }
849}
850
851// ── DynPoint ────────────────────────────────────────────────────────
852
853/// Zero-copy view of a single point within a [`DynPointCloud`].
854///
855/// Provides typed field access by name (`read_f32("x")`) or by
856/// pre-resolved [`FieldDesc`] (`read_f32_at(&desc)`) for hot-path
857/// use where repeated name lookups would be wasteful.
858///
859/// Field access reads bytes from the point's data slice using
860/// `from_le_bytes` — no unsafe code, no allocation, no alignment concerns.
861pub struct DynPoint<'a, 'c> {
862    data: &'a [u8],
863    cloud: &'c DynPointCloud<'a>,
864}
865
866impl<'a, 'c> DynPoint<'a, 'c> {
867    /// Read an f32 field by name. Returns None if field not found or wrong type.
868    pub fn read_f32(&self, name: &str) -> Option<f32> {
869        let desc = self.cloud.field(name)?;
870        if desc.field_type != PointFieldType::Float32 {
871            return None;
872        }
873        let off = desc.byte_offset as usize;
874        let bytes: [u8; 4] = self.data[off..off + 4].try_into().ok()?;
875        Some(f32::from_le_bytes(bytes))
876    }
877
878    /// Read a u32 field by name.
879    pub fn read_u32(&self, name: &str) -> Option<u32> {
880        let desc = self.cloud.field(name)?;
881        if desc.field_type != PointFieldType::Uint32 {
882            return None;
883        }
884        let off = desc.byte_offset as usize;
885        let bytes: [u8; 4] = self.data[off..off + 4].try_into().ok()?;
886        Some(u32::from_le_bytes(bytes))
887    }
888
889    /// Read a u16 field by name.
890    pub fn read_u16(&self, name: &str) -> Option<u16> {
891        let desc = self.cloud.field(name)?;
892        if desc.field_type != PointFieldType::Uint16 {
893            return None;
894        }
895        let off = desc.byte_offset as usize;
896        let bytes: [u8; 2] = self.data[off..off + 2].try_into().ok()?;
897        Some(u16::from_le_bytes(bytes))
898    }
899
900    /// Read a u8 field by name.
901    pub fn read_u8(&self, name: &str) -> Option<u8> {
902        let desc = self.cloud.field(name)?;
903        if desc.field_type != PointFieldType::Uint8 {
904            return None;
905        }
906        Some(self.data[desc.byte_offset as usize])
907    }
908
909    /// Read an i8 field by name.
910    pub fn read_i8(&self, name: &str) -> Option<i8> {
911        let desc = self.cloud.field(name)?;
912        if desc.field_type != PointFieldType::Int8 {
913            return None;
914        }
915        Some(self.data[desc.byte_offset as usize] as i8)
916    }
917
918    /// Read an i16 field by name.
919    pub fn read_i16(&self, name: &str) -> Option<i16> {
920        let desc = self.cloud.field(name)?;
921        if desc.field_type != PointFieldType::Int16 {
922            return None;
923        }
924        let off = desc.byte_offset as usize;
925        let bytes: [u8; 2] = self.data[off..off + 2].try_into().ok()?;
926        Some(i16::from_le_bytes(bytes))
927    }
928
929    /// Read an i32 field by name.
930    pub fn read_i32(&self, name: &str) -> Option<i32> {
931        let desc = self.cloud.field(name)?;
932        if desc.field_type != PointFieldType::Int32 {
933            return None;
934        }
935        let off = desc.byte_offset as usize;
936        let bytes: [u8; 4] = self.data[off..off + 4].try_into().ok()?;
937        Some(i32::from_le_bytes(bytes))
938    }
939
940    /// Read an f64 field by name.
941    pub fn read_f64(&self, name: &str) -> Option<f64> {
942        let desc = self.cloud.field(name)?;
943        if desc.field_type != PointFieldType::Float64 {
944            return None;
945        }
946        let off = desc.byte_offset as usize;
947        let bytes: [u8; 8] = self.data[off..off + 8].try_into().ok()?;
948        Some(f64::from_le_bytes(bytes))
949    }
950
951    /// Read an f32 field by pre-resolved descriptor (avoids name lookup).
952    ///
953    /// # Errors
954    /// Returns [`PointCloudError::FieldAccessOutOfBounds`] if the descriptor's
955    /// byte offset exceeds the point data slice.
956    pub fn read_f32_at(&self, desc: &FieldDesc<'_>) -> Result<f32, PointCloudError> {
957        let off = desc.byte_offset as usize;
958        let bytes: [u8; 4] = self
959            .data
960            .get(off..off + 4)
961            .ok_or(PointCloudError::FieldAccessOutOfBounds {
962                byte_offset: desc.byte_offset,
963            })?
964            .try_into()
965            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
966                byte_offset: desc.byte_offset,
967            })?;
968        Ok(f32::from_le_bytes(bytes))
969    }
970
971    /// Read a u32 field by pre-resolved descriptor.
972    pub fn read_u32_at(&self, desc: &FieldDesc<'_>) -> Result<u32, PointCloudError> {
973        let off = desc.byte_offset as usize;
974        let bytes: [u8; 4] = self
975            .data
976            .get(off..off + 4)
977            .ok_or(PointCloudError::FieldAccessOutOfBounds {
978                byte_offset: desc.byte_offset,
979            })?
980            .try_into()
981            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
982                byte_offset: desc.byte_offset,
983            })?;
984        Ok(u32::from_le_bytes(bytes))
985    }
986
987    /// Read a u16 field by pre-resolved descriptor.
988    pub fn read_u16_at(&self, desc: &FieldDesc<'_>) -> Result<u16, PointCloudError> {
989        let off = desc.byte_offset as usize;
990        let bytes: [u8; 2] = self
991            .data
992            .get(off..off + 2)
993            .ok_or(PointCloudError::FieldAccessOutOfBounds {
994                byte_offset: desc.byte_offset,
995            })?
996            .try_into()
997            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
998                byte_offset: desc.byte_offset,
999            })?;
1000        Ok(u16::from_le_bytes(bytes))
1001    }
1002
1003    /// Read a u8 field by pre-resolved descriptor.
1004    pub fn read_u8_at(&self, desc: &FieldDesc<'_>) -> Result<u8, PointCloudError> {
1005        self.data.get(desc.byte_offset as usize).copied().ok_or(
1006            PointCloudError::FieldAccessOutOfBounds {
1007                byte_offset: desc.byte_offset,
1008            },
1009        )
1010    }
1011
1012    /// Read an i8 field by pre-resolved descriptor.
1013    pub fn read_i8_at(&self, desc: &FieldDesc<'_>) -> Result<i8, PointCloudError> {
1014        self.data
1015            .get(desc.byte_offset as usize)
1016            .map(|&b| b as i8)
1017            .ok_or(PointCloudError::FieldAccessOutOfBounds {
1018                byte_offset: desc.byte_offset,
1019            })
1020    }
1021
1022    /// Read an i16 field by pre-resolved descriptor.
1023    pub fn read_i16_at(&self, desc: &FieldDesc<'_>) -> Result<i16, PointCloudError> {
1024        let off = desc.byte_offset as usize;
1025        let bytes: [u8; 2] = self
1026            .data
1027            .get(off..off + 2)
1028            .ok_or(PointCloudError::FieldAccessOutOfBounds {
1029                byte_offset: desc.byte_offset,
1030            })?
1031            .try_into()
1032            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
1033                byte_offset: desc.byte_offset,
1034            })?;
1035        Ok(i16::from_le_bytes(bytes))
1036    }
1037
1038    /// Read an i32 field by pre-resolved descriptor.
1039    pub fn read_i32_at(&self, desc: &FieldDesc<'_>) -> Result<i32, PointCloudError> {
1040        let off = desc.byte_offset as usize;
1041        let bytes: [u8; 4] = self
1042            .data
1043            .get(off..off + 4)
1044            .ok_or(PointCloudError::FieldAccessOutOfBounds {
1045                byte_offset: desc.byte_offset,
1046            })?
1047            .try_into()
1048            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
1049                byte_offset: desc.byte_offset,
1050            })?;
1051        Ok(i32::from_le_bytes(bytes))
1052    }
1053
1054    /// Read an f64 field by pre-resolved descriptor.
1055    pub fn read_f64_at(&self, desc: &FieldDesc<'_>) -> Result<f64, PointCloudError> {
1056        let off = desc.byte_offset as usize;
1057        let bytes: [u8; 8] = self
1058            .data
1059            .get(off..off + 8)
1060            .ok_or(PointCloudError::FieldAccessOutOfBounds {
1061                byte_offset: desc.byte_offset,
1062            })?
1063            .try_into()
1064            .map_err(|_| PointCloudError::FieldAccessOutOfBounds {
1065                byte_offset: desc.byte_offset,
1066            })?;
1067        Ok(f64::from_le_bytes(bytes))
1068    }
1069
1070    /// Access the parent point cloud for field metadata lookup.
1071    ///
1072    /// Enables the hot-loop pattern: resolve a [`FieldDesc`] once from the
1073    /// cloud, then call [`FieldDesc::read_as_f64`] with [`DynPoint::data`]
1074    /// per point without per-point name lookup overhead.
1075    pub fn cloud(&self) -> &DynPointCloud<'a> {
1076        self.cloud
1077    }
1078
1079    /// Access the raw byte slice for this point's data region.
1080    ///
1081    /// The slice spans exactly `point_step` bytes. Use with
1082    /// [`FieldDesc::read_as_f64`] and related methods for the
1083    /// resolve-once-read-many hot-loop pattern:
1084    ///
1085    /// ```ignore
1086    /// let desc = cloud.field("x").unwrap();
1087    /// for point in cloud.iter() {
1088    ///     let x = desc.read_as_f32(point.data()).unwrap();
1089    /// }
1090    /// ```
1091    ///
1092    /// Field offsets within this slice correspond to
1093    /// [`FieldDesc::byte_offset`].
1094    pub fn data(&self) -> &'a [u8] {
1095        self.data
1096    }
1097
1098    /// Read a named field as `f64`, widening from any stored numeric type.
1099    ///
1100    /// Combines a field name lookup with [`FieldDesc::read_as_f64`].
1101    /// Returns `None` if the field does not exist or the byte offset is
1102    /// out of range.
1103    ///
1104    /// For repeated access in a loop, prefer resolving the [`FieldDesc`]
1105    /// once with [`DynPointCloud::field`] and calling
1106    /// [`FieldDesc::read_as_f64`] directly with [`DynPoint::data`].
1107    pub fn read_as_f64(&self, name: &str) -> Option<f64> {
1108        self.cloud.field(name)?.read_as_f64(self.data)
1109    }
1110
1111    /// Read a named field as `f32`, converting from any stored numeric type.
1112    ///
1113    /// Returns `None` if the field does not exist or the byte offset is
1114    /// out of range. See [`FieldDesc::read_as_f32`] for precision caveats
1115    /// (`Int32`/`Uint32` >24-bit, `Float64` narrowing).
1116    pub fn read_as_f32(&self, name: &str) -> Option<f32> {
1117        self.cloud.field(name)?.read_as_f32(self.data)
1118    }
1119}
1120
1121// ── DynPointIter ────────────────────────────────────────────────────
1122
1123/// Iterator over points in a DynPointCloud.
1124pub struct DynPointIter<'a, 'c> {
1125    cloud: &'c DynPointCloud<'a>,
1126    index: usize,
1127}
1128
1129impl<'a, 'c> Iterator for DynPointIter<'a, 'c> {
1130    type Item = DynPoint<'a, 'c>;
1131
1132    fn next(&mut self) -> Option<Self::Item> {
1133        let point = self.cloud.point(self.index)?;
1134        self.index += 1;
1135        Some(point)
1136    }
1137
1138    fn size_hint(&self) -> (usize, Option<usize>) {
1139        let remaining = self.cloud.num_points.saturating_sub(self.index);
1140        (remaining, Some(remaining))
1141    }
1142}
1143
1144impl ExactSizeIterator for DynPointIter<'_, '_> {}
1145
1146// ── PointCloud<P> ───────────────────────────────────────────────────
1147
1148/// Compile-time typed zero-copy point cloud view.
1149///
1150/// `P` defines the expected point layout. Construction validates that the
1151/// PointCloud2 field descriptors match `P`'s expectations.
1152///
1153/// # Example
1154/// ```ignore
1155/// define_point! {
1156///     pub struct XyzPoint { x: f32 => 0, y: f32 => 4, z: f32 => 8 }
1157/// }
1158/// let cloud = PointCloud::<XyzPoint>::from_pointcloud2(&pcd2)?;
1159/// for point in cloud.iter() {
1160///     println!("{}, {}, {}", point.x, point.y, point.z);
1161/// }
1162/// ```
1163#[derive(Debug)]
1164pub struct PointCloud<'a, P: Point> {
1165    data: &'a [u8],
1166    point_step: usize,
1167    row_step: usize,
1168    num_points: usize,
1169    height: u32,
1170    width: u32,
1171    _marker: core::marker::PhantomData<P>,
1172}
1173
1174impl<'a, P: Point> PointCloud<'a, P> {
1175    /// Create a typed point cloud view, validating that the PointCloud2
1176    /// field layout matches `P`'s expectations.
1177    ///
1178    /// Returns a zero-copy view that borrows the PointCloud2's internal
1179    /// data buffer. The `PointCloud2` must outlive the returned view.
1180    ///
1181    /// # Errors
1182    ///
1183    /// - [`PointCloudError::BigEndianNotSupported`] — big-endian point data.
1184    /// - [`PointCloudError::FieldNotFound`] — a field expected by `P` is missing.
1185    /// - [`PointCloudError::FieldMismatch`] — a field has wrong offset or datatype.
1186    /// - [`PointCloudError::InvalidLayout`] — `point_step` smaller than
1187    ///   `P::point_size()`, `row_step` smaller than `width × point_step`,
1188    ///   or data buffer shorter than `height × row_step`.
1189    pub fn from_pointcloud2<B: AsRef<[u8]>>(
1190        pc: &'a super::PointCloud2<B>,
1191    ) -> Result<Self, PointCloudError> {
1192        if pc.is_bigendian() {
1193            return Err(PointCloudError::BigEndianNotSupported);
1194        }
1195
1196        Self::validate(pc)?;
1197
1198        let point_step = pc.point_step() as usize;
1199        let num_points = pc.point_count();
1200        let height = pc.height() as usize;
1201        let width = pc.width() as usize;
1202        let row_step = pc.row_step() as usize;
1203
1204        // Bounds-check the data buffer (same check as DynPointCloud).
1205        if num_points > 0 {
1206            let min_row_step =
1207                width
1208                    .checked_mul(point_step)
1209                    .ok_or(PointCloudError::InvalidLayout {
1210                        reason: "width × point_step overflows usize",
1211                    })?;
1212            if row_step < min_row_step {
1213                return Err(PointCloudError::InvalidLayout {
1214                    reason: "row_step smaller than width × point_step",
1215                });
1216            }
1217
1218            let required_len =
1219                height
1220                    .checked_mul(row_step)
1221                    .ok_or(PointCloudError::InvalidLayout {
1222                        reason: "height × row_step overflows usize",
1223                    })?;
1224            if pc.data().len() < required_len {
1225                return Err(PointCloudError::InvalidLayout {
1226                    reason: "data buffer shorter than height × row_step",
1227                });
1228            }
1229        }
1230
1231        Ok(PointCloud {
1232            data: pc.data(),
1233            point_step,
1234            row_step,
1235            num_points,
1236            height: pc.height(),
1237            width: pc.width(),
1238            _marker: core::marker::PhantomData,
1239        })
1240    }
1241
1242    /// Validate that the PointCloud2 field layout matches `P`'s expectations.
1243    pub fn validate<B: AsRef<[u8]>>(pc: &super::PointCloud2<B>) -> Result<(), PointCloudError> {
1244        let expected = P::expected_fields();
1245
1246        // Scan fields without allocating — for each expected field, iterate
1247        // the PointCloud2 field descriptors to find a match.
1248        for exp in expected {
1249            let mut found = None;
1250            for f in pc.fields_iter() {
1251                if f.name == exp.name {
1252                    found = Some((f.offset, f.datatype));
1253                    break;
1254                }
1255            }
1256            match found {
1257                None => {
1258                    return Err(PointCloudError::FieldNotFound { name: exp.name });
1259                }
1260                Some((offset, datatype)) => {
1261                    if offset != exp.byte_offset {
1262                        return Err(PointCloudError::FieldMismatch {
1263                            name: exp.name,
1264                            reason: "byte offset mismatch",
1265                        });
1266                    }
1267                    let actual_type = PointFieldType::from_datatype(datatype);
1268                    if actual_type != Some(exp.field_type) {
1269                        return Err(PointCloudError::FieldMismatch {
1270                            name: exp.name,
1271                            reason: "datatype mismatch",
1272                        });
1273                    }
1274                }
1275            }
1276        }
1277
1278        let point_step = pc.point_step();
1279        if point_step < P::point_size() {
1280            return Err(PointCloudError::InvalidLayout {
1281                reason: "point_step smaller than Point type size",
1282            });
1283        }
1284
1285        Ok(())
1286    }
1287
1288    /// Number of points.
1289    pub fn len(&self) -> usize {
1290        self.num_points
1291    }
1292
1293    /// Whether the cloud is empty.
1294    pub fn is_empty(&self) -> bool {
1295        self.num_points == 0
1296    }
1297
1298    /// Height of the cloud.
1299    pub fn height(&self) -> u32 {
1300        self.height
1301    }
1302
1303    /// Width of the cloud.
1304    pub fn width(&self) -> u32 {
1305        self.width
1306    }
1307
1308    /// Compute the byte offset of the i-th point, correctly handling
1309    /// row padding in organized clouds.
1310    #[inline]
1311    fn point_offset(&self, i: usize) -> usize {
1312        if self.row_step == (self.width as usize) * self.point_step {
1313            i * self.point_step
1314        } else {
1315            let w = self.width as usize;
1316            (i / w) * self.row_step + (i % w) * self.point_step
1317        }
1318    }
1319
1320    /// Read a single point by linear index.
1321    pub fn get(&self, index: usize) -> Option<P> {
1322        if index >= self.num_points {
1323            return None;
1324        }
1325        let base = self.point_offset(index);
1326        if base + P::point_size() as usize > self.data.len() {
1327            return None;
1328        }
1329        Some(P::read_from(self.data, base))
1330    }
1331
1332    /// Read a point by (row, col) for organized clouds.
1333    ///
1334    /// Uses `row_step` to correctly handle row padding in organized clouds.
1335    pub fn get_at(&self, row: u32, col: u32) -> Option<P> {
1336        if row >= self.height || col >= self.width {
1337            return None;
1338        }
1339        let base = (row as usize) * self.row_step + (col as usize) * self.point_step;
1340        if base + P::point_size() as usize > self.data.len() {
1341            return None;
1342        }
1343        Some(P::read_from(self.data, base))
1344    }
1345
1346    /// Iterate over all points.
1347    pub fn iter(&self) -> PointIter<'a, P> {
1348        PointIter {
1349            data: self.data,
1350            point_step: self.point_step,
1351            row_step: self.row_step,
1352            width: self.width as usize,
1353            num_points: self.num_points,
1354            index: 0,
1355            _marker: core::marker::PhantomData,
1356        }
1357    }
1358}
1359
1360/// Iterator over points in a typed PointCloud.
1361pub struct PointIter<'a, P: Point> {
1362    data: &'a [u8],
1363    point_step: usize,
1364    row_step: usize,
1365    width: usize,
1366    num_points: usize,
1367    index: usize,
1368    _marker: core::marker::PhantomData<P>,
1369}
1370
1371impl<P: Point> PointIter<'_, P> {
1372    #[inline]
1373    fn point_offset(&self, i: usize) -> usize {
1374        if self.row_step == self.width * self.point_step {
1375            i * self.point_step
1376        } else {
1377            (i / self.width) * self.row_step + (i % self.width) * self.point_step
1378        }
1379    }
1380}
1381
1382impl<P: Point> Iterator for PointIter<'_, P> {
1383    type Item = P;
1384
1385    fn next(&mut self) -> Option<P> {
1386        if self.index >= self.num_points {
1387            return None;
1388        }
1389        let base = self.point_offset(self.index);
1390        if base + P::point_size() as usize > self.data.len() {
1391            return None;
1392        }
1393        self.index += 1;
1394        Some(P::read_from(self.data, base))
1395    }
1396
1397    fn size_hint(&self) -> (usize, Option<usize>) {
1398        let remaining = self.num_points.saturating_sub(self.index);
1399        (remaining, Some(remaining))
1400    }
1401}
1402
1403impl<P: Point> ExactSizeIterator for PointIter<'_, P> {}
1404
1405// ── Tests ───────────────────────────────────────────────────────────
1406
1407#[cfg(test)]
1408#[allow(deprecated)] // Tests exercise PointCloud2::new, which is deprecated in 3.2.0 but still supported until 4.0.
1409mod tests {
1410    use super::*;
1411    use crate::builtin_interfaces::Time;
1412    use crate::sensor_msgs::{PointCloud2, PointFieldView};
1413
1414    /// Build a PointCloud2 with known xyz + intensity data.
1415    fn make_test_cloud() -> PointCloud2<Vec<u8>> {
1416        let fields = [
1417            PointFieldView {
1418                name: "x",
1419                offset: 0,
1420                datatype: 7,
1421                count: 1,
1422            },
1423            PointFieldView {
1424                name: "y",
1425                offset: 4,
1426                datatype: 7,
1427                count: 1,
1428            },
1429            PointFieldView {
1430                name: "z",
1431                offset: 8,
1432                datatype: 7,
1433                count: 1,
1434            },
1435            PointFieldView {
1436                name: "intensity",
1437                offset: 12,
1438                datatype: 7,
1439                count: 1,
1440            },
1441        ];
1442        let point_step = 16u32;
1443        let num_points = 4u32;
1444        let mut data = vec![0u8; (point_step * num_points) as usize];
1445
1446        // Write 4 test points: (1,2,3,10), (4,5,6,20), (7,8,9,30), (10,11,12,40)
1447        for i in 0..4u32 {
1448            let base = (i * point_step) as usize;
1449            let x = (i * 3 + 1) as f32;
1450            let y = (i * 3 + 2) as f32;
1451            let z = (i * 3 + 3) as f32;
1452            let intensity = ((i + 1) * 10) as f32;
1453            data[base..base + 4].copy_from_slice(&x.to_le_bytes());
1454            data[base + 4..base + 8].copy_from_slice(&y.to_le_bytes());
1455            data[base + 8..base + 12].copy_from_slice(&z.to_le_bytes());
1456            data[base + 12..base + 16].copy_from_slice(&intensity.to_le_bytes());
1457        }
1458
1459        PointCloud2::new(
1460            Time::new(100, 0),
1461            "lidar",
1462            1,
1463            num_points,
1464            &fields,
1465            false,
1466            point_step,
1467            point_step * num_points,
1468            &data,
1469            true,
1470        )
1471        .unwrap()
1472    }
1473
1474    // ── DynPointCloud tests ─────────────────────────────────────────
1475
1476    #[test]
1477    fn dyn_cloud_from_pointcloud2() {
1478        let pc = make_test_cloud();
1479        let cdr = pc.to_cdr();
1480        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1481        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1482
1483        assert_eq!(cloud.len(), 4);
1484        assert_eq!(cloud.field_count(), 4);
1485        assert!(cloud.field("x").is_some());
1486        assert!(cloud.field("intensity").is_some());
1487        assert!(cloud.field("nonexistent").is_none());
1488    }
1489
1490    #[test]
1491    fn dyn_cloud_point_access() {
1492        let pc = make_test_cloud();
1493        let cdr = pc.to_cdr();
1494        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1495        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1496
1497        let p0 = cloud.point(0).unwrap();
1498        assert_eq!(p0.read_f32("x"), Some(1.0));
1499        assert_eq!(p0.read_f32("y"), Some(2.0));
1500        assert_eq!(p0.read_f32("z"), Some(3.0));
1501        assert_eq!(p0.read_f32("intensity"), Some(10.0));
1502
1503        let p3 = cloud.point(3).unwrap();
1504        assert_eq!(p3.read_f32("x"), Some(10.0));
1505        assert_eq!(p3.read_f32("z"), Some(12.0));
1506
1507        // Out of bounds
1508        assert!(cloud.point(4).is_none());
1509    }
1510
1511    #[test]
1512    fn dyn_cloud_descriptor_access() {
1513        let pc = make_test_cloud();
1514        let cdr = pc.to_cdr();
1515        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1516        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1517
1518        // Pre-resolve field once, use for all points
1519        let x_desc = cloud.field("x").unwrap();
1520        let z_desc = cloud.field("z").unwrap();
1521
1522        for (i, point) in cloud.iter().enumerate() {
1523            let expected_x = (i as f32) * 3.0 + 1.0;
1524            let expected_z = (i as f32) * 3.0 + 3.0;
1525            assert_eq!(point.read_f32_at(x_desc).unwrap(), expected_x);
1526            assert_eq!(point.read_f32_at(z_desc).unwrap(), expected_z);
1527        }
1528    }
1529
1530    #[test]
1531    fn dyn_cloud_gather() {
1532        let pc = make_test_cloud();
1533        let cdr = pc.to_cdr();
1534        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1535        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1536
1537        let xs = cloud.gather_f32("x").unwrap();
1538        assert_eq!(xs, vec![1.0, 4.0, 7.0, 10.0]);
1539
1540        let zs = cloud.gather_f32("z").unwrap();
1541        assert_eq!(zs, vec![3.0, 6.0, 9.0, 12.0]);
1542
1543        // Wrong type returns None
1544        assert!(cloud.gather_u32("x").is_none());
1545        // Missing field returns None
1546        assert!(cloud.gather_f32("nonexistent").is_none());
1547    }
1548
1549    #[test]
1550    fn dyn_cloud_iterator_count() {
1551        let pc = make_test_cloud();
1552        let cdr = pc.to_cdr();
1553        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1554        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1555
1556        assert_eq!(cloud.iter().count(), 4);
1557        assert_eq!(cloud.iter().len(), 4);
1558    }
1559
1560    #[test]
1561    fn dyn_cloud_organized() {
1562        // 2×2 organized cloud
1563        let fields = [
1564            PointFieldView {
1565                name: "x",
1566                offset: 0,
1567                datatype: 7,
1568                count: 1,
1569            },
1570            PointFieldView {
1571                name: "y",
1572                offset: 4,
1573                datatype: 7,
1574                count: 1,
1575            },
1576            PointFieldView {
1577                name: "z",
1578                offset: 8,
1579                datatype: 7,
1580                count: 1,
1581            },
1582        ];
1583        let point_step = 12u32;
1584        let mut data = vec![0u8; 48]; // 4 points × 12 bytes
1585        for i in 0..4u32 {
1586            let base = (i * point_step) as usize;
1587            let val = (i + 1) as f32;
1588            data[base..base + 4].copy_from_slice(&val.to_le_bytes());
1589        }
1590        let pc = PointCloud2::new(
1591            Time::new(0, 0),
1592            "cam",
1593            2,
1594            2,
1595            &fields,
1596            false,
1597            point_step,
1598            24,
1599            &data,
1600            true,
1601        )
1602        .unwrap();
1603        let cdr = pc.to_cdr();
1604        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1605        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1606
1607        assert_eq!(cloud.height(), 2);
1608        assert_eq!(cloud.width(), 2);
1609        assert_eq!(cloud.point_at(0, 0).unwrap().read_f32("x"), Some(1.0));
1610        assert_eq!(cloud.point_at(0, 1).unwrap().read_f32("x"), Some(2.0));
1611        assert_eq!(cloud.point_at(1, 0).unwrap().read_f32("x"), Some(3.0));
1612        assert_eq!(cloud.point_at(1, 1).unwrap().read_f32("x"), Some(4.0));
1613        assert!(cloud.point_at(2, 0).is_none());
1614    }
1615
1616    #[test]
1617    fn dyn_cloud_rejects_bigendian() {
1618        let fields = [PointFieldView {
1619            name: "x",
1620            offset: 0,
1621            datatype: 7,
1622            count: 1,
1623        }];
1624        let data = vec![0u8; 4];
1625        let pc =
1626            PointCloud2::new(Time::new(0, 0), "f", 1, 1, &fields, true, 4, 4, &data, true).unwrap();
1627        let cdr = pc.to_cdr();
1628        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1629        let err = DynPointCloud::from_pointcloud2(&decoded).unwrap_err();
1630        assert!(matches!(err, PointCloudError::BigEndianNotSupported));
1631    }
1632
1633    #[test]
1634    fn dyn_cloud_mixed_types() {
1635        // Cloud with f32 xyz + u16 class + u8 flags
1636        let fields = [
1637            PointFieldView {
1638                name: "x",
1639                offset: 0,
1640                datatype: 7,
1641                count: 1,
1642            },
1643            PointFieldView {
1644                name: "y",
1645                offset: 4,
1646                datatype: 7,
1647                count: 1,
1648            },
1649            PointFieldView {
1650                name: "z",
1651                offset: 8,
1652                datatype: 7,
1653                count: 1,
1654            },
1655            PointFieldView {
1656                name: "class",
1657                offset: 12,
1658                datatype: 4,
1659                count: 1,
1660            },
1661            PointFieldView {
1662                name: "flags",
1663                offset: 14,
1664                datatype: 2,
1665                count: 1,
1666            },
1667        ];
1668        let point_step = 16u32;
1669        let mut data = vec![0u8; 16];
1670        data[0..4].copy_from_slice(&1.0f32.to_le_bytes());
1671        data[4..8].copy_from_slice(&2.0f32.to_le_bytes());
1672        data[8..12].copy_from_slice(&3.0f32.to_le_bytes());
1673        data[12..14].copy_from_slice(&42u16.to_le_bytes());
1674        data[14] = 0xFF;
1675
1676        let pc = PointCloud2::new(
1677            Time::new(0, 0),
1678            "f",
1679            1,
1680            1,
1681            &fields,
1682            false,
1683            point_step,
1684            16,
1685            &data,
1686            true,
1687        )
1688        .unwrap();
1689        let cdr = pc.to_cdr();
1690        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1691        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1692        let p = cloud.point(0).unwrap();
1693
1694        assert_eq!(p.read_f32("x"), Some(1.0));
1695        assert_eq!(p.read_u16("class"), Some(42));
1696        assert_eq!(p.read_u8("flags"), Some(0xFF));
1697        // Wrong type access
1698        assert_eq!(p.read_f32("class"), None);
1699        assert_eq!(p.read_u32("flags"), None);
1700
1701        // Gather typed columns
1702        assert_eq!(cloud.gather_u16("class"), Some(vec![42]));
1703        assert_eq!(cloud.gather_u8("flags"), Some(vec![0xFF]));
1704    }
1705
1706    #[test]
1707    fn dyn_cloud_empty() {
1708        let fields = [PointFieldView {
1709            name: "x",
1710            offset: 0,
1711            datatype: 7,
1712            count: 1,
1713        }];
1714        let pc =
1715            PointCloud2::new(Time::new(0, 0), "f", 0, 0, &fields, false, 4, 0, &[], true).unwrap();
1716        let cdr = pc.to_cdr();
1717        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1718        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
1719
1720        assert!(cloud.is_empty());
1721        assert_eq!(cloud.len(), 0);
1722        assert_eq!(cloud.iter().count(), 0);
1723        assert!(cloud.gather_f32("x").unwrap().is_empty());
1724    }
1725
1726    // ── Static PointCloud tests ─────────────────────────────────────
1727
1728    define_point! {
1729        struct TestXyzPoint {
1730            x: f32 => 0,
1731            y: f32 => 4,
1732            z: f32 => 8,
1733        }
1734    }
1735
1736    define_point! {
1737        struct TestXyzClassPoint {
1738            x: f32 => 0,
1739            y: f32 => 4,
1740            z: f32 => 8,
1741            class_id: u16 => 12,
1742            instance_id: u16 => 14,
1743        }
1744    }
1745
1746    #[test]
1747    fn static_cloud_xyz() {
1748        let fields = [
1749            PointFieldView {
1750                name: "x",
1751                offset: 0,
1752                datatype: 7,
1753                count: 1,
1754            },
1755            PointFieldView {
1756                name: "y",
1757                offset: 4,
1758                datatype: 7,
1759                count: 1,
1760            },
1761            PointFieldView {
1762                name: "z",
1763                offset: 8,
1764                datatype: 7,
1765                count: 1,
1766            },
1767        ];
1768        let point_step = 12u32;
1769        let mut data = vec![0u8; 36];
1770        for i in 0..3u32 {
1771            let base = (i * point_step) as usize;
1772            data[base..base + 4].copy_from_slice(&(i as f32 + 1.0).to_le_bytes());
1773            data[base + 4..base + 8].copy_from_slice(&(i as f32 + 10.0).to_le_bytes());
1774            data[base + 8..base + 12].copy_from_slice(&(i as f32 + 100.0).to_le_bytes());
1775        }
1776
1777        let pc = PointCloud2::new(
1778            Time::new(0, 0),
1779            "lidar",
1780            1,
1781            3,
1782            &fields,
1783            false,
1784            point_step,
1785            36,
1786            &data,
1787            true,
1788        )
1789        .unwrap();
1790        let cdr = pc.to_cdr();
1791        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1792        let cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
1793
1794        assert_eq!(cloud.len(), 3);
1795        let p0 = cloud.get(0).unwrap();
1796        assert_eq!(p0.x, 1.0);
1797        assert_eq!(p0.y, 10.0);
1798        assert_eq!(p0.z, 100.0);
1799
1800        let p2 = cloud.get(2).unwrap();
1801        assert_eq!(p2.x, 3.0);
1802        assert_eq!(p2.y, 12.0);
1803        assert_eq!(p2.z, 102.0);
1804    }
1805
1806    #[test]
1807    fn static_cloud_mixed_types() {
1808        let fields = [
1809            PointFieldView {
1810                name: "x",
1811                offset: 0,
1812                datatype: 7,
1813                count: 1,
1814            },
1815            PointFieldView {
1816                name: "y",
1817                offset: 4,
1818                datatype: 7,
1819                count: 1,
1820            },
1821            PointFieldView {
1822                name: "z",
1823                offset: 8,
1824                datatype: 7,
1825                count: 1,
1826            },
1827            PointFieldView {
1828                name: "class_id",
1829                offset: 12,
1830                datatype: 4,
1831                count: 1,
1832            },
1833            PointFieldView {
1834                name: "instance_id",
1835                offset: 14,
1836                datatype: 4,
1837                count: 1,
1838            },
1839        ];
1840        let point_step = 16u32;
1841        let mut data = vec![0u8; 16];
1842        data[0..4].copy_from_slice(&1.5f32.to_le_bytes());
1843        data[4..8].copy_from_slice(&2.5f32.to_le_bytes());
1844        data[8..12].copy_from_slice(&3.5f32.to_le_bytes());
1845        data[12..14].copy_from_slice(&7u16.to_le_bytes());
1846        data[14..16].copy_from_slice(&42u16.to_le_bytes());
1847
1848        let pc = PointCloud2::new(
1849            Time::new(0, 0),
1850            "f",
1851            1,
1852            1,
1853            &fields,
1854            false,
1855            point_step,
1856            16,
1857            &data,
1858            true,
1859        )
1860        .unwrap();
1861        let cdr = pc.to_cdr();
1862        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1863        let cloud = PointCloud::<TestXyzClassPoint>::from_pointcloud2(&decoded).unwrap();
1864
1865        let p = cloud.get(0).unwrap();
1866        assert_eq!(p.x, 1.5);
1867        assert_eq!(p.y, 2.5);
1868        assert_eq!(p.z, 3.5);
1869        assert_eq!(p.class_id, 7);
1870        assert_eq!(p.instance_id, 42);
1871    }
1872
1873    #[test]
1874    fn static_cloud_validation_field_missing() {
1875        let fields = [
1876            PointFieldView {
1877                name: "x",
1878                offset: 0,
1879                datatype: 7,
1880                count: 1,
1881            },
1882            PointFieldView {
1883                name: "y",
1884                offset: 4,
1885                datatype: 7,
1886                count: 1,
1887            },
1888        ];
1889        let data = vec![0u8; 8];
1890        let pc = PointCloud2::new(
1891            Time::new(0, 0),
1892            "f",
1893            1,
1894            1,
1895            &fields,
1896            false,
1897            8,
1898            8,
1899            &data,
1900            true,
1901        )
1902        .unwrap();
1903        let cdr = pc.to_cdr();
1904        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1905        let err = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap_err();
1906        assert!(matches!(err, PointCloudError::FieldNotFound { name: "z" }));
1907    }
1908
1909    #[test]
1910    fn static_cloud_validation_type_mismatch() {
1911        let fields = [
1912            PointFieldView {
1913                name: "x",
1914                offset: 0,
1915                datatype: 7,
1916                count: 1,
1917            },
1918            PointFieldView {
1919                name: "y",
1920                offset: 4,
1921                datatype: 7,
1922                count: 1,
1923            },
1924            PointFieldView {
1925                name: "z",
1926                offset: 8,
1927                datatype: 6, // u32 not f32
1928                count: 1,
1929            },
1930        ];
1931        let data = vec![0u8; 12];
1932        let pc = PointCloud2::new(
1933            Time::new(0, 0),
1934            "f",
1935            1,
1936            1,
1937            &fields,
1938            false,
1939            12,
1940            12,
1941            &data,
1942            true,
1943        )
1944        .unwrap();
1945        let cdr = pc.to_cdr();
1946        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
1947        let err = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap_err();
1948        assert!(matches!(
1949            err,
1950            PointCloudError::FieldMismatch { name: "z", .. }
1951        ));
1952    }
1953
1954    #[test]
1955    fn static_cloud_extra_fields_ok() {
1956        // Cloud has more fields than the Point type — that's fine,
1957        // we only check the fields the Point declares.
1958        let fields = [
1959            PointFieldView {
1960                name: "x",
1961                offset: 0,
1962                datatype: 7,
1963                count: 1,
1964            },
1965            PointFieldView {
1966                name: "y",
1967                offset: 4,
1968                datatype: 7,
1969                count: 1,
1970            },
1971            PointFieldView {
1972                name: "z",
1973                offset: 8,
1974                datatype: 7,
1975                count: 1,
1976            },
1977            PointFieldView {
1978                name: "intensity",
1979                offset: 12,
1980                datatype: 7,
1981                count: 1,
1982            },
1983        ];
1984        let data = vec![0u8; 16];
1985        let pc = PointCloud2::new(
1986            Time::new(0, 0),
1987            "f",
1988            1,
1989            1,
1990            &fields,
1991            false,
1992            16,
1993            16,
1994            &data,
1995            true,
1996        )
1997        .unwrap();
1998        let cdr = pc.to_cdr();
1999        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2000        let cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
2001        assert_eq!(cloud.len(), 1);
2002    }
2003
2004    #[test]
2005    fn static_cloud_iterator() {
2006        let fields = [
2007            PointFieldView {
2008                name: "x",
2009                offset: 0,
2010                datatype: 7,
2011                count: 1,
2012            },
2013            PointFieldView {
2014                name: "y",
2015                offset: 4,
2016                datatype: 7,
2017                count: 1,
2018            },
2019            PointFieldView {
2020                name: "z",
2021                offset: 8,
2022                datatype: 7,
2023                count: 1,
2024            },
2025        ];
2026        let point_step = 12u32;
2027        let n = 100u32;
2028        let mut data = vec![0u8; (point_step * n) as usize];
2029        for i in 0..n {
2030            let base = (i * point_step) as usize;
2031            data[base..base + 4].copy_from_slice(&(i as f32).to_le_bytes());
2032            data[base + 4..base + 8].copy_from_slice(&(i as f32 * 2.0).to_le_bytes());
2033            data[base + 8..base + 12].copy_from_slice(&(i as f32 * 3.0).to_le_bytes());
2034        }
2035
2036        let pc = PointCloud2::new(
2037            Time::new(0, 0),
2038            "f",
2039            1,
2040            n,
2041            &fields,
2042            false,
2043            point_step,
2044            point_step * n,
2045            &data,
2046            true,
2047        )
2048        .unwrap();
2049        let cdr = pc.to_cdr();
2050        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2051        let cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
2052
2053        assert_eq!(cloud.iter().len(), 100);
2054        let points: Vec<_> = cloud.iter().collect();
2055        assert_eq!(
2056            points[0],
2057            TestXyzPoint {
2058                x: 0.0,
2059                y: 0.0,
2060                z: 0.0
2061            }
2062        );
2063        assert_eq!(
2064            points[99],
2065            TestXyzPoint {
2066                x: 99.0,
2067                y: 198.0,
2068                z: 297.0
2069            }
2070        );
2071    }
2072
2073    // ── Convenience method tests ────────────────────────────────────
2074
2075    #[test]
2076    fn convenience_methods() {
2077        let pc = make_test_cloud();
2078        let cdr = pc.to_cdr();
2079        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2080
2081        // Dynamic
2082        let dyn_cloud = decoded.as_dyn_cloud().unwrap();
2083        assert_eq!(dyn_cloud.len(), 4);
2084
2085        // Static (using TestXyzPoint — ignores intensity field)
2086        let typed_cloud = decoded.as_typed_cloud::<TestXyzPoint>().unwrap();
2087        assert_eq!(typed_cloud.len(), 4);
2088        assert_eq!(typed_cloud.get(0).unwrap().x, 1.0);
2089    }
2090
2091    // ── PointFieldType unit tests ───────────────────────────────────
2092
2093    #[test]
2094    fn point_field_type_from_datatype() {
2095        assert_eq!(PointFieldType::from_datatype(1), Some(PointFieldType::Int8));
2096        assert_eq!(
2097            PointFieldType::from_datatype(2),
2098            Some(PointFieldType::Uint8)
2099        );
2100        assert_eq!(
2101            PointFieldType::from_datatype(3),
2102            Some(PointFieldType::Int16)
2103        );
2104        assert_eq!(
2105            PointFieldType::from_datatype(4),
2106            Some(PointFieldType::Uint16)
2107        );
2108        assert_eq!(
2109            PointFieldType::from_datatype(5),
2110            Some(PointFieldType::Int32)
2111        );
2112        assert_eq!(
2113            PointFieldType::from_datatype(6),
2114            Some(PointFieldType::Uint32)
2115        );
2116        assert_eq!(
2117            PointFieldType::from_datatype(7),
2118            Some(PointFieldType::Float32)
2119        );
2120        assert_eq!(
2121            PointFieldType::from_datatype(8),
2122            Some(PointFieldType::Float64)
2123        );
2124        // Invalid
2125        assert_eq!(PointFieldType::from_datatype(0), None);
2126        assert_eq!(PointFieldType::from_datatype(9), None);
2127        assert_eq!(PointFieldType::from_datatype(255), None);
2128    }
2129
2130    #[test]
2131    fn point_field_type_size_bytes() {
2132        assert_eq!(PointFieldType::Int8.size_bytes(), 1);
2133        assert_eq!(PointFieldType::Uint8.size_bytes(), 1);
2134        assert_eq!(PointFieldType::Int16.size_bytes(), 2);
2135        assert_eq!(PointFieldType::Uint16.size_bytes(), 2);
2136        assert_eq!(PointFieldType::Int32.size_bytes(), 4);
2137        assert_eq!(PointFieldType::Uint32.size_bytes(), 4);
2138        assert_eq!(PointFieldType::Float32.size_bytes(), 4);
2139        assert_eq!(PointFieldType::Float64.size_bytes(), 8);
2140    }
2141
2142    #[test]
2143    fn field_desc_from_view_unknown_datatype() {
2144        let view = PointFieldView {
2145            name: "bad",
2146            offset: 0,
2147            datatype: 99,
2148            count: 1,
2149        };
2150        assert!(FieldDesc::from_view(&view).is_none());
2151    }
2152
2153    // ── Signed and f64 type tests ───────────────────────────────────
2154
2155    #[test]
2156    fn dyn_cloud_signed_and_f64_types() {
2157        let fields = [
2158            PointFieldView {
2159                name: "i8_field",
2160                offset: 0,
2161                datatype: 1,
2162                count: 1,
2163            }, // Int8
2164            PointFieldView {
2165                name: "u8_field",
2166                offset: 1,
2167                datatype: 2,
2168                count: 1,
2169            }, // Uint8
2170            PointFieldView {
2171                name: "i16_field",
2172                offset: 2,
2173                datatype: 3,
2174                count: 1,
2175            }, // Int16
2176            PointFieldView {
2177                name: "u16_field",
2178                offset: 4,
2179                datatype: 4,
2180                count: 1,
2181            }, // Uint16
2182            PointFieldView {
2183                name: "i32_field",
2184                offset: 6,
2185                datatype: 5,
2186                count: 1,
2187            }, // Int32
2188            PointFieldView {
2189                name: "u32_field",
2190                offset: 10,
2191                datatype: 6,
2192                count: 1,
2193            }, // Uint32
2194            PointFieldView {
2195                name: "f32_field",
2196                offset: 14,
2197                datatype: 7,
2198                count: 1,
2199            }, // Float32
2200            PointFieldView {
2201                name: "f64_field",
2202                offset: 18,
2203                datatype: 8,
2204                count: 1,
2205            }, // Float64
2206        ];
2207        let point_step = 26u32; // 1+1+2+2+4+4+4+8
2208        let mut data = vec![0u8; point_step as usize];
2209        data[0] = 0xFE_u8; // i8 = -2
2210        data[1] = 42; // u8 = 42
2211        data[2..4].copy_from_slice(&(-300i16).to_le_bytes()); // i16
2212        data[4..6].copy_from_slice(&1000u16.to_le_bytes()); // u16
2213        data[6..10].copy_from_slice(&(-100_000i32).to_le_bytes()); // i32
2214        data[10..14].copy_from_slice(&3_000_000u32.to_le_bytes()); // u32
2215        data[14..18].copy_from_slice(&std::f32::consts::PI.to_le_bytes()); // f32
2216        data[18..26].copy_from_slice(&std::f64::consts::E.to_le_bytes()); // f64
2217
2218        let pc = PointCloud2::new(
2219            Time::new(0, 0),
2220            "test",
2221            1,
2222            1,
2223            &fields,
2224            false,
2225            point_step,
2226            point_step,
2227            &data,
2228            true,
2229        )
2230        .unwrap();
2231        let cdr = pc.to_cdr();
2232        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2233        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2234
2235        assert_eq!(cloud.field_count(), 8);
2236
2237        // Verify all field types are correctly resolved
2238        let i8_desc = cloud.field("i8_field").unwrap();
2239        assert_eq!(i8_desc.field_type, PointFieldType::Int8);
2240        let f64_desc = cloud.field("f64_field").unwrap();
2241        assert_eq!(f64_desc.field_type, PointFieldType::Float64);
2242
2243        // Verify gather for u8 and u16
2244        assert_eq!(cloud.gather_u8("u8_field"), Some(vec![42]));
2245        assert_eq!(cloud.gather_u16("u16_field"), Some(vec![1000]));
2246        assert_eq!(cloud.gather_u32("u32_field"), Some(vec![3_000_000]));
2247
2248        // Verify point-level access
2249        let p = cloud.point(0).unwrap();
2250        assert_eq!(p.read_u8("u8_field"), Some(42));
2251        assert_eq!(p.read_u16("u16_field"), Some(1000));
2252        assert_eq!(p.read_u32("u32_field"), Some(3_000_000));
2253        assert_eq!(p.read_f32("f32_field"), Some(std::f32::consts::PI));
2254
2255        // Verify descriptor-based access
2256        let u16_desc = cloud.field("u16_field").unwrap();
2257        assert_eq!(p.read_u16_at(u16_desc).unwrap(), 1000);
2258        let u8_desc = cloud.field("u8_field").unwrap();
2259        assert_eq!(p.read_u8_at(u8_desc).unwrap(), 42);
2260    }
2261
2262    // ── define_point! macro metadata tests ──────────────────────────
2263
2264    #[test]
2265    fn define_point_metadata() {
2266        assert_eq!(TestXyzPoint::FIELD_COUNT, 3);
2267        assert_eq!(TestXyzPoint::point_size(), 12);
2268
2269        let fields = TestXyzPoint::expected_fields();
2270        assert_eq!(fields.len(), 3);
2271        assert_eq!(fields[0].name, "x");
2272        assert_eq!(fields[0].byte_offset, 0);
2273        assert_eq!(fields[0].field_type, PointFieldType::Float32);
2274        assert_eq!(fields[1].name, "y");
2275        assert_eq!(fields[2].name, "z");
2276        assert_eq!(fields[2].byte_offset, 8);
2277    }
2278
2279    #[test]
2280    fn define_point_mixed_metadata() {
2281        assert_eq!(TestXyzClassPoint::FIELD_COUNT, 5);
2282        assert_eq!(TestXyzClassPoint::point_size(), 16); // max(14+2) = 16
2283
2284        let fields = TestXyzClassPoint::expected_fields();
2285        assert_eq!(fields[3].name, "class_id");
2286        assert_eq!(fields[3].field_type, PointFieldType::Uint16);
2287        assert_eq!(fields[3].byte_offset, 12);
2288        assert_eq!(fields[4].name, "instance_id");
2289        assert_eq!(fields[4].byte_offset, 14);
2290    }
2291
2292    #[test]
2293    fn define_point_read_from() {
2294        let mut data = vec![0u8; 12];
2295        data[0..4].copy_from_slice(&1.5f32.to_le_bytes());
2296        data[4..8].copy_from_slice(&2.5f32.to_le_bytes());
2297        data[8..12].copy_from_slice(&3.5f32.to_le_bytes());
2298
2299        let p = TestXyzPoint::read_from(&data, 0);
2300        assert_eq!(p.x, 1.5);
2301        assert_eq!(p.y, 2.5);
2302        assert_eq!(p.z, 3.5);
2303    }
2304
2305    // ── Static cloud validation edge cases ──────────────────────────
2306
2307    #[test]
2308    fn static_cloud_validation_offset_mismatch() {
2309        // Field "y" at wrong offset (8 instead of 4)
2310        let fields = [
2311            PointFieldView {
2312                name: "x",
2313                offset: 0,
2314                datatype: 7,
2315                count: 1,
2316            },
2317            PointFieldView {
2318                name: "y",
2319                offset: 8, // wrong — TestXyzPoint expects 4
2320                datatype: 7,
2321                count: 1,
2322            },
2323            PointFieldView {
2324                name: "z",
2325                offset: 12,
2326                datatype: 7,
2327                count: 1,
2328            },
2329        ];
2330        let data = vec![0u8; 16];
2331        let pc = PointCloud2::new(
2332            Time::new(0, 0),
2333            "f",
2334            1,
2335            1,
2336            &fields,
2337            false,
2338            16,
2339            16,
2340            &data,
2341            true,
2342        )
2343        .unwrap();
2344        let cdr = pc.to_cdr();
2345        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2346        let err = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap_err();
2347        assert!(matches!(
2348            err,
2349            PointCloudError::FieldMismatch {
2350                name: "y",
2351                reason: "byte offset mismatch"
2352            }
2353        ));
2354    }
2355
2356    #[test]
2357    fn static_cloud_point_step_too_small() {
2358        let fields = [
2359            PointFieldView {
2360                name: "x",
2361                offset: 0,
2362                datatype: 7,
2363                count: 1,
2364            },
2365            PointFieldView {
2366                name: "y",
2367                offset: 4,
2368                datatype: 7,
2369                count: 1,
2370            },
2371            PointFieldView {
2372                name: "z",
2373                offset: 8,
2374                datatype: 7,
2375                count: 1,
2376            },
2377        ];
2378        // point_step=8 < TestXyzPoint::point_size()=12
2379        let data = vec![0u8; 8];
2380        let pc = PointCloud2::new(
2381            Time::new(0, 0),
2382            "f",
2383            1,
2384            1,
2385            &fields,
2386            false,
2387            8,
2388            8,
2389            &data,
2390            true,
2391        )
2392        .unwrap();
2393        let cdr = pc.to_cdr();
2394        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2395        let err = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap_err();
2396        assert!(matches!(err, PointCloudError::InvalidLayout { .. }));
2397    }
2398
2399    // ── Realistic LiDAR layout with padding ─────────────────────────
2400
2401    define_point! {
2402        /// Typical Ouster OS1 point layout (subset).
2403        struct TestOusterPoint {
2404            x: f32 => 0,
2405            y: f32 => 4,
2406            z: f32 => 8,
2407        }
2408    }
2409
2410    #[test]
2411    fn static_cloud_with_padding() {
2412        // Simulates a sensor with 32-byte point_step but only xyz used
2413        let fields = [
2414            PointFieldView {
2415                name: "x",
2416                offset: 0,
2417                datatype: 7,
2418                count: 1,
2419            },
2420            PointFieldView {
2421                name: "y",
2422                offset: 4,
2423                datatype: 7,
2424                count: 1,
2425            },
2426            PointFieldView {
2427                name: "z",
2428                offset: 8,
2429                datatype: 7,
2430                count: 1,
2431            },
2432            PointFieldView {
2433                name: "intensity",
2434                offset: 12,
2435                datatype: 7,
2436                count: 1,
2437            },
2438            PointFieldView {
2439                name: "ring",
2440                offset: 16,
2441                datatype: 4,
2442                count: 1,
2443            },
2444            PointFieldView {
2445                name: "timestamp",
2446                offset: 24,
2447                datatype: 8,
2448                count: 1,
2449            },
2450        ];
2451        let point_step = 32u32;
2452        let n = 3u32;
2453        let mut data = vec![0u8; (point_step * n) as usize];
2454        for i in 0..n {
2455            let base = (i * point_step) as usize;
2456            data[base..base + 4].copy_from_slice(&(i as f32 * 10.0).to_le_bytes());
2457            data[base + 4..base + 8].copy_from_slice(&(i as f32 * 20.0).to_le_bytes());
2458            data[base + 8..base + 12].copy_from_slice(&(i as f32 * 30.0).to_le_bytes());
2459        }
2460
2461        let pc = PointCloud2::new(
2462            Time::new(0, 0),
2463            "os1",
2464            1,
2465            n,
2466            &fields,
2467            false,
2468            point_step,
2469            point_step * n,
2470            &data,
2471            true,
2472        )
2473        .unwrap();
2474        let cdr = pc.to_cdr();
2475        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2476
2477        // Static typed — only reads x,y,z, skips intensity/ring/timestamp
2478        let cloud = PointCloud::<TestOusterPoint>::from_pointcloud2(&decoded).unwrap();
2479        assert_eq!(cloud.len(), 3);
2480        let p1 = cloud.get(1).unwrap();
2481        assert_eq!(p1.x, 10.0);
2482        assert_eq!(p1.y, 20.0);
2483        assert_eq!(p1.z, 30.0);
2484
2485        // Dynamic — can also read intensity and ring
2486        let dyn_cloud = decoded.as_dyn_cloud().unwrap();
2487        assert_eq!(dyn_cloud.field_count(), 6);
2488        assert_eq!(dyn_cloud.point_step(), 32);
2489
2490        // Verify stride is respected in gather
2491        let xs = dyn_cloud.gather_f32("x").unwrap();
2492        assert_eq!(xs, vec![0.0, 10.0, 20.0]);
2493    }
2494
2495    // ── DynPointCloud error paths ───────────────────────────────────
2496
2497    #[test]
2498    fn dyn_cloud_unknown_datatype_rejected() {
2499        // Manually craft a PointCloud2 with an unknown datatype
2500        // We can't do this through the normal API, so test FieldDesc directly
2501        let view = PointFieldView {
2502            name: "bad",
2503            offset: 0,
2504            datatype: 99,
2505            count: 1,
2506        };
2507        assert!(FieldDesc::from_view(&view).is_none());
2508    }
2509
2510    #[test]
2511    fn dyn_cloud_gather_wrong_type_returns_none() {
2512        let pc = make_test_cloud();
2513        let cdr = pc.to_cdr();
2514        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2515        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2516
2517        // "x" is f32, not u32/u16/u8
2518        assert!(cloud.gather_u32("x").is_none());
2519        assert!(cloud.gather_u16("x").is_none());
2520        assert!(cloud.gather_u8("x").is_none());
2521
2522        // Non-existent field
2523        assert!(cloud.gather_f32("nonexistent").is_none());
2524        assert!(cloud.gather_u32("nonexistent").is_none());
2525        assert!(cloud.gather_u16("nonexistent").is_none());
2526        assert!(cloud.gather_u8("nonexistent").is_none());
2527    }
2528
2529    #[test]
2530    fn dyn_point_wrong_type_returns_none() {
2531        let pc = make_test_cloud();
2532        let cdr = pc.to_cdr();
2533        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2534        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2535        let p = cloud.point(0).unwrap();
2536
2537        // "x" is f32 — all non-f32 reads should return None
2538        assert!(p.read_u32("x").is_none());
2539        assert!(p.read_u16("x").is_none());
2540        assert!(p.read_u8("x").is_none());
2541
2542        // Non-existent field
2543        assert!(p.read_f32("nope").is_none());
2544        assert!(p.read_u32("nope").is_none());
2545        assert!(p.read_u16("nope").is_none());
2546        assert!(p.read_u8("nope").is_none());
2547    }
2548
2549    // ── PointCloud2 error display ───────────────────────────────────
2550
2551    #[test]
2552    fn point_cloud_error_display() {
2553        let e = PointCloudError::FieldNotFound { name: "x" };
2554        assert_eq!(format!("{e}"), "field not found: x");
2555
2556        let e = PointCloudError::FieldMismatch {
2557            name: "y",
2558            reason: "byte offset mismatch",
2559        };
2560        assert_eq!(
2561            format!("{e}"),
2562            "field mismatch for 'y': byte offset mismatch"
2563        );
2564
2565        let e = PointCloudError::TooManyFields { found: 20 };
2566        assert_eq!(format!("{e}"), "too many fields: 20 (max 16)");
2567
2568        let e = PointCloudError::UnknownDatatype {
2569            field_name: "bad".into(),
2570            datatype: 99,
2571        };
2572        assert_eq!(format!("{e}"), "unknown datatype 99 for field 'bad'");
2573
2574        let e = PointCloudError::BigEndianNotSupported;
2575        assert_eq!(format!("{e}"), "big-endian point data not supported");
2576
2577        let e = PointCloudError::InvalidLayout {
2578            reason: "point_step is zero",
2579        };
2580        assert_eq!(format!("{e}"), "invalid layout: point_step is zero");
2581    }
2582
2583    // ── Static cloud organized access ───────────────────────────────
2584
2585    #[test]
2586    fn static_cloud_organized_access() {
2587        let fields = [
2588            PointFieldView {
2589                name: "x",
2590                offset: 0,
2591                datatype: 7,
2592                count: 1,
2593            },
2594            PointFieldView {
2595                name: "y",
2596                offset: 4,
2597                datatype: 7,
2598                count: 1,
2599            },
2600            PointFieldView {
2601                name: "z",
2602                offset: 8,
2603                datatype: 7,
2604                count: 1,
2605            },
2606        ];
2607        let point_step = 12u32;
2608        // 3×2 organized cloud
2609        let n = 6u32;
2610        let mut data = vec![0u8; (point_step * n) as usize];
2611        for i in 0..n {
2612            let base = (i * point_step) as usize;
2613            data[base..base + 4].copy_from_slice(&(i as f32).to_le_bytes());
2614        }
2615
2616        let pc = PointCloud2::new(
2617            Time::new(0, 0),
2618            "depth",
2619            3,
2620            2,
2621            &fields,
2622            false,
2623            point_step,
2624            point_step * 2,
2625            &data,
2626            true,
2627        )
2628        .unwrap();
2629        let cdr = pc.to_cdr();
2630        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2631        let cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
2632
2633        assert_eq!(cloud.height(), 3);
2634        assert_eq!(cloud.width(), 2);
2635
2636        // (row, col) → linear index = row * width + col
2637        assert_eq!(cloud.get_at(0, 0).unwrap().x, 0.0);
2638        assert_eq!(cloud.get_at(0, 1).unwrap().x, 1.0);
2639        assert_eq!(cloud.get_at(1, 0).unwrap().x, 2.0);
2640        assert_eq!(cloud.get_at(1, 1).unwrap().x, 3.0);
2641        assert_eq!(cloud.get_at(2, 0).unwrap().x, 4.0);
2642        assert_eq!(cloud.get_at(2, 1).unwrap().x, 5.0);
2643
2644        // Out of bounds
2645        assert!(cloud.get_at(3, 0).is_none());
2646        assert!(cloud.get_at(0, 2).is_none());
2647    }
2648
2649    #[test]
2650    fn static_cloud_organized_with_row_padding() {
2651        // Organized cloud where row_step > width * point_step (row padding).
2652        let fields = [
2653            PointFieldView {
2654                name: "x",
2655                offset: 0,
2656                datatype: 7,
2657                count: 1,
2658            },
2659            PointFieldView {
2660                name: "y",
2661                offset: 4,
2662                datatype: 7,
2663                count: 1,
2664            },
2665            PointFieldView {
2666                name: "z",
2667                offset: 8,
2668                datatype: 7,
2669                count: 1,
2670            },
2671        ];
2672        let point_step = 12u32;
2673        let width = 2u32;
2674        let height = 3u32;
2675        // 8 bytes of padding per row (row_step = 32 > width * point_step = 24).
2676        let row_step = 32u32;
2677        let total_bytes = (row_step * height) as usize;
2678        let mut data = vec![0xFFu8; total_bytes]; // fill with 0xFF to catch stale reads
2679
2680        for row in 0..height {
2681            for col in 0..width {
2682                let offset = (row * row_step + col * point_step) as usize;
2683                let val = (row * width + col) as f32;
2684                data[offset..offset + 4].copy_from_slice(&val.to_le_bytes());
2685            }
2686        }
2687
2688        let pc = PointCloud2::new(
2689            Time::new(0, 0),
2690            "padded",
2691            height,
2692            width,
2693            &fields,
2694            false,
2695            point_step,
2696            row_step,
2697            &data,
2698            true,
2699        )
2700        .unwrap();
2701        let cdr = pc.to_cdr();
2702        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2703
2704        // Test both DynPointCloud and PointCloud<P>
2705        let dyn_cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2706        let typed_cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
2707
2708        for row in 0..height {
2709            for col in 0..width {
2710                let expected = (row * width + col) as f32;
2711                let dp = dyn_cloud.point_at(row, col).unwrap();
2712                assert_eq!(dp.read_f32("x"), Some(expected), "dyn row={row} col={col}");
2713                let tp = typed_cloud.get_at(row, col).unwrap();
2714                assert_eq!(tp.x, expected, "typed row={row} col={col}");
2715            }
2716        }
2717    }
2718
2719    // ── PointScalar trait coverage ──────────────────────────────────
2720
2721    define_point! {
2722        /// Point with all supported scalar types.
2723        struct TestAllTypesPoint {
2724            a: i8 => 0,
2725            b: u8 => 1,
2726            c: i16 => 2,
2727            d: u16 => 4,
2728            e: i32 => 6,
2729            f: u32 => 10,
2730            g: f32 => 14,
2731            h: f64 => 18,
2732        }
2733    }
2734
2735    #[test]
2736    fn static_cloud_all_scalar_types() {
2737        use crate::sensor_msgs::pointcloud::Point;
2738
2739        assert_eq!(TestAllTypesPoint::FIELD_COUNT, 8);
2740        assert_eq!(TestAllTypesPoint::point_size(), 26); // 18 + 8
2741
2742        let fields = TestAllTypesPoint::expected_fields();
2743        assert_eq!(fields[0].field_type, PointFieldType::Int8);
2744        assert_eq!(fields[1].field_type, PointFieldType::Uint8);
2745        assert_eq!(fields[2].field_type, PointFieldType::Int16);
2746        assert_eq!(fields[3].field_type, PointFieldType::Uint16);
2747        assert_eq!(fields[4].field_type, PointFieldType::Int32);
2748        assert_eq!(fields[5].field_type, PointFieldType::Uint32);
2749        assert_eq!(fields[6].field_type, PointFieldType::Float32);
2750        assert_eq!(fields[7].field_type, PointFieldType::Float64);
2751
2752        // Read from raw bytes
2753        let mut data = vec![0u8; 26];
2754        data[0] = 0xFE; // i8 = -2
2755        data[1] = 200; // u8
2756        data[2..4].copy_from_slice(&(-500i16).to_le_bytes());
2757        data[4..6].copy_from_slice(&60000u16.to_le_bytes());
2758        data[6..10].copy_from_slice(&(-1_000_000i32).to_le_bytes());
2759        data[10..14].copy_from_slice(&4_000_000u32.to_le_bytes());
2760        data[14..18].copy_from_slice(&std::f32::consts::E.to_le_bytes());
2761        data[18..26].copy_from_slice(&std::f64::consts::PI.to_le_bytes());
2762
2763        let p = TestAllTypesPoint::read_from(&data, 0);
2764        assert_eq!(p.a, -2);
2765        assert_eq!(p.b, 200);
2766        assert_eq!(p.c, -500);
2767        assert_eq!(p.d, 60000);
2768        assert_eq!(p.e, -1_000_000);
2769        assert_eq!(p.f, 4_000_000);
2770        assert_eq!(p.g, std::f32::consts::E);
2771        assert_eq!(p.h, std::f64::consts::PI);
2772    }
2773
2774    // ── Coverage tests for signed/f64 accessors (PR #12 Comment 4) ──
2775
2776    /// Helper: build the 8-field all-types cloud CDR used by multiple tests.
2777    fn make_all_types_cloud_cdr() -> Vec<u8> {
2778        let fields = [
2779            PointFieldView {
2780                name: "i8_field",
2781                offset: 0,
2782                datatype: 1,
2783                count: 1,
2784            },
2785            PointFieldView {
2786                name: "u8_field",
2787                offset: 1,
2788                datatype: 2,
2789                count: 1,
2790            },
2791            PointFieldView {
2792                name: "i16_field",
2793                offset: 2,
2794                datatype: 3,
2795                count: 1,
2796            },
2797            PointFieldView {
2798                name: "u16_field",
2799                offset: 4,
2800                datatype: 4,
2801                count: 1,
2802            },
2803            PointFieldView {
2804                name: "i32_field",
2805                offset: 6,
2806                datatype: 5,
2807                count: 1,
2808            },
2809            PointFieldView {
2810                name: "u32_field",
2811                offset: 10,
2812                datatype: 6,
2813                count: 1,
2814            },
2815            PointFieldView {
2816                name: "f32_field",
2817                offset: 14,
2818                datatype: 7,
2819                count: 1,
2820            },
2821            PointFieldView {
2822                name: "f64_field",
2823                offset: 18,
2824                datatype: 8,
2825                count: 1,
2826            },
2827        ];
2828        let point_step = 26u32;
2829        let mut data = vec![0u8; point_step as usize];
2830        data[0] = 0xFE_u8; // i8 = -2
2831        data[1] = 42;
2832        data[2..4].copy_from_slice(&(-300i16).to_le_bytes());
2833        data[4..6].copy_from_slice(&1000u16.to_le_bytes());
2834        data[6..10].copy_from_slice(&(-100_000i32).to_le_bytes());
2835        data[10..14].copy_from_slice(&3_000_000u32.to_le_bytes());
2836        data[14..18].copy_from_slice(&std::f32::consts::PI.to_le_bytes());
2837        data[18..26].copy_from_slice(&std::f64::consts::E.to_le_bytes());
2838
2839        PointCloud2::new(
2840            Time::new(0, 0),
2841            "test",
2842            1,
2843            1,
2844            &fields,
2845            false,
2846            point_step,
2847            point_step,
2848            &data,
2849            true,
2850        )
2851        .unwrap()
2852        .to_cdr()
2853    }
2854
2855    #[test]
2856    fn dyn_point_signed_and_f64_by_name() {
2857        let cdr = make_all_types_cloud_cdr();
2858        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2859        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2860        let p = cloud.point(0).unwrap();
2861
2862        assert_eq!(p.read_i8("i8_field"), Some(-2));
2863        assert_eq!(p.read_i16("i16_field"), Some(-300));
2864        assert_eq!(p.read_i32("i32_field"), Some(-100_000));
2865        assert_eq!(p.read_f64("f64_field"), Some(std::f64::consts::E));
2866    }
2867
2868    #[test]
2869    fn dyn_point_signed_and_f64_by_descriptor() {
2870        let cdr = make_all_types_cloud_cdr();
2871        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2872        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2873        let p = cloud.point(0).unwrap();
2874
2875        let i8_desc = cloud.field("i8_field").unwrap();
2876        let i16_desc = cloud.field("i16_field").unwrap();
2877        let i32_desc = cloud.field("i32_field").unwrap();
2878        let f64_desc = cloud.field("f64_field").unwrap();
2879
2880        assert_eq!(p.read_i8_at(i8_desc).unwrap(), -2);
2881        assert_eq!(p.read_i16_at(i16_desc).unwrap(), -300);
2882        assert_eq!(p.read_i32_at(i32_desc).unwrap(), -100_000);
2883        assert_eq!(p.read_f64_at(f64_desc).unwrap(), std::f64::consts::E);
2884    }
2885
2886    #[test]
2887    fn dyn_cloud_gather_signed_and_f64() {
2888        let cdr = make_all_types_cloud_cdr();
2889        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2890        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2891
2892        assert_eq!(cloud.gather_i8("i8_field"), Some(vec![-2]));
2893        assert_eq!(cloud.gather_i16("i16_field"), Some(vec![-300]));
2894        assert_eq!(cloud.gather_i32("i32_field"), Some(vec![-100_000]));
2895        assert_eq!(
2896            cloud.gather_f64("f64_field"),
2897            Some(vec![std::f64::consts::E])
2898        );
2899    }
2900
2901    #[test]
2902    fn dyn_point_signed_f64_wrong_type_returns_none() {
2903        let cdr = make_all_types_cloud_cdr();
2904        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2905        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2906        let p = cloud.point(0).unwrap();
2907
2908        // read_i8 on non-i8 field
2909        assert!(p.read_i8("f32_field").is_none());
2910        assert!(p.read_i16("i8_field").is_none());
2911        assert!(p.read_i32("i16_field").is_none());
2912        assert!(p.read_f64("f32_field").is_none());
2913        assert!(p.read_f32("f64_field").is_none());
2914
2915        // gather wrong type
2916        assert!(cloud.gather_i8("f64_field").is_none());
2917        assert!(cloud.gather_f64("i8_field").is_none());
2918        assert!(cloud.gather_i16("i32_field").is_none());
2919        assert!(cloud.gather_i32("i16_field").is_none());
2920    }
2921
2922    #[test]
2923    fn dyn_point_at_invalid_descriptor_returns_error() {
2924        let cdr = make_all_types_cloud_cdr();
2925        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
2926        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
2927        let p = cloud.point(0).unwrap();
2928
2929        let bad = FieldDesc {
2930            name: "fake",
2931            byte_offset: 9999,
2932            field_type: PointFieldType::Float32,
2933            count: 1,
2934        };
2935        assert!(matches!(
2936            p.read_f32_at(&bad),
2937            Err(PointCloudError::FieldAccessOutOfBounds { byte_offset: 9999 })
2938        ));
2939        assert!(p.read_u8_at(&bad).is_err());
2940        assert!(p.read_i8_at(&bad).is_err());
2941        assert!(p.read_u16_at(&bad).is_err());
2942        assert!(p.read_i16_at(&bad).is_err());
2943        assert!(p.read_u32_at(&bad).is_err());
2944        assert!(p.read_i32_at(&bad).is_err());
2945        assert!(p.read_f64_at(&bad).is_err());
2946    }
2947
2948    #[test]
2949    fn dyn_cloud_gather_with_row_padding() {
2950        let fields = [
2951            PointFieldView {
2952                name: "x",
2953                offset: 0,
2954                datatype: 7,
2955                count: 1,
2956            },
2957            PointFieldView {
2958                name: "y",
2959                offset: 4,
2960                datatype: 7,
2961                count: 1,
2962            },
2963            PointFieldView {
2964                name: "z",
2965                offset: 8,
2966                datatype: 7,
2967                count: 1,
2968            },
2969        ];
2970        let point_step = 12u32;
2971        let width = 2u32;
2972        let height = 2u32;
2973        let row_step = 32u32; // 8 bytes padding per row
2974        let total = (row_step * height) as usize;
2975        let mut data = vec![0xFFu8; total];
2976
2977        // Write known x values at correct padded offsets
2978        for row in 0..height {
2979            for col in 0..width {
2980                let off = (row * row_step + col * point_step) as usize;
2981                let val = (row * width + col) as f32;
2982                data[off..off + 4].copy_from_slice(&val.to_le_bytes());
2983            }
2984        }
2985
2986        let pc = PointCloud2::new(
2987            Time::new(0, 0),
2988            "pad",
2989            height,
2990            width,
2991            &fields,
2992            false,
2993            point_step,
2994            row_step,
2995            &data,
2996            true,
2997        )
2998        .unwrap();
2999        let cdr = pc.to_cdr();
3000        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3001        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3002
3003        let gathered = cloud.gather_f32("x").unwrap();
3004        assert_eq!(gathered, vec![0.0, 1.0, 2.0, 3.0]);
3005    }
3006
3007    #[test]
3008    fn dyn_cloud_max_fields_boundary() {
3009        // Build 16 u8 fields — should succeed
3010        let names: Vec<String> = (0..17).map(|i| format!("f{i}")).collect();
3011        let fields_16: Vec<PointFieldView<'_>> = (0..16)
3012            .map(|i| PointFieldView {
3013                name: &names[i],
3014                offset: i as u32,
3015                datatype: 2, // Uint8
3016                count: 1,
3017            })
3018            .collect();
3019        let data = vec![0u8; 16];
3020        let pc = PointCloud2::new(
3021            Time::new(0, 0),
3022            "max",
3023            1,
3024            1,
3025            &fields_16,
3026            false,
3027            16,
3028            16,
3029            &data,
3030            true,
3031        )
3032        .unwrap();
3033        let cdr = pc.to_cdr();
3034        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3035        assert!(DynPointCloud::from_pointcloud2(&decoded).is_ok());
3036
3037        // Build 17 fields — should fail with TooManyFields
3038        let fields_17: Vec<PointFieldView<'_>> = (0..17)
3039            .map(|i| PointFieldView {
3040                name: &names[i],
3041                offset: i as u32,
3042                datatype: 2,
3043                count: 1,
3044            })
3045            .collect();
3046        let data = vec![0u8; 17];
3047        let pc = PointCloud2::new(
3048            Time::new(0, 0),
3049            "max",
3050            1,
3051            1,
3052            &fields_17,
3053            false,
3054            17,
3055            17,
3056            &data,
3057            true,
3058        )
3059        .unwrap();
3060        let cdr = pc.to_cdr();
3061        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3062        assert!(matches!(
3063            DynPointCloud::from_pointcloud2(&decoded),
3064            Err(PointCloudError::TooManyFields { found: 17 })
3065        ));
3066    }
3067
3068    #[test]
3069    fn dyn_cloud_rejects_row_step_too_small() {
3070        let fields = [PointFieldView {
3071            name: "x",
3072            offset: 0,
3073            datatype: 7,
3074            count: 1,
3075        }];
3076        let data = vec![0u8; 48];
3077        // row_step = 2, but width * point_step = 2 * 4 = 8
3078        let pc = PointCloud2::new(
3079            Time::new(0, 0),
3080            "bad",
3081            3,
3082            2,
3083            &fields,
3084            false,
3085            4,
3086            2,
3087            &data,
3088            true,
3089        )
3090        .unwrap();
3091        let cdr = pc.to_cdr();
3092        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3093        assert!(matches!(
3094            DynPointCloud::from_pointcloud2(&decoded),
3095            Err(PointCloudError::InvalidLayout { .. })
3096        ));
3097    }
3098
3099    #[test]
3100    fn static_cloud_rejects_row_step_too_small() {
3101        let fields = [
3102            PointFieldView {
3103                name: "x",
3104                offset: 0,
3105                datatype: 7,
3106                count: 1,
3107            },
3108            PointFieldView {
3109                name: "y",
3110                offset: 4,
3111                datatype: 7,
3112                count: 1,
3113            },
3114            PointFieldView {
3115                name: "z",
3116                offset: 8,
3117                datatype: 7,
3118                count: 1,
3119            },
3120        ];
3121        let data = vec![0u8; 48];
3122        let pc = PointCloud2::new(
3123            Time::new(0, 0),
3124            "bad",
3125            2,
3126            2,
3127            &fields,
3128            false,
3129            12,
3130            4,
3131            &data,
3132            true,
3133        )
3134        .unwrap();
3135        let cdr = pc.to_cdr();
3136        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3137        assert!(matches!(
3138            PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded),
3139            Err(PointCloudError::InvalidLayout { .. })
3140        ));
3141    }
3142
3143    #[test]
3144    fn static_cloud_iter_with_row_padding() {
3145        let fields = [
3146            PointFieldView {
3147                name: "x",
3148                offset: 0,
3149                datatype: 7,
3150                count: 1,
3151            },
3152            PointFieldView {
3153                name: "y",
3154                offset: 4,
3155                datatype: 7,
3156                count: 1,
3157            },
3158            PointFieldView {
3159                name: "z",
3160                offset: 8,
3161                datatype: 7,
3162                count: 1,
3163            },
3164        ];
3165        let point_step = 12u32;
3166        let width = 2u32;
3167        let height = 2u32;
3168        let row_step = 32u32; // 8 bytes padding per row
3169        let total = (row_step * height) as usize;
3170        let mut data = vec![0xFFu8; total];
3171
3172        for row in 0..height {
3173            for col in 0..width {
3174                let off = (row * row_step + col * point_step) as usize;
3175                let val = (row * width + col) as f32;
3176                data[off..off + 4].copy_from_slice(&val.to_le_bytes());
3177            }
3178        }
3179
3180        let pc = PointCloud2::new(
3181            Time::new(0, 0),
3182            "pad",
3183            height,
3184            width,
3185            &fields,
3186            false,
3187            point_step,
3188            row_step,
3189            &data,
3190            true,
3191        )
3192        .unwrap();
3193        let cdr = pc.to_cdr();
3194        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3195        let cloud = PointCloud::<TestXyzPoint>::from_pointcloud2(&decoded).unwrap();
3196
3197        // Test iter() correctness across row-padded boundaries
3198        let xs: Vec<f32> = cloud.iter().map(|p| p.x).collect();
3199        assert_eq!(xs, vec![0.0, 1.0, 2.0, 3.0]);
3200
3201        // Test get() also uses correct offsets
3202        assert_eq!(cloud.get(2).unwrap().x, 2.0);
3203        assert_eq!(cloud.get(3).unwrap().x, 3.0);
3204    }
3205
3206    // ── Type-coercing field access tests ────────────────────────────
3207
3208    /// Helper: build a single-point cloud with all 8 field types for coercion tests.
3209    fn make_coercion_cloud_cdr() -> Vec<u8> {
3210        let fields = [
3211            PointFieldView {
3212                name: "fi8",
3213                offset: 0,
3214                datatype: 1,
3215                count: 1,
3216            },
3217            PointFieldView {
3218                name: "fu8",
3219                offset: 1,
3220                datatype: 2,
3221                count: 1,
3222            },
3223            PointFieldView {
3224                name: "fi16",
3225                offset: 2,
3226                datatype: 3,
3227                count: 1,
3228            },
3229            PointFieldView {
3230                name: "fu16",
3231                offset: 4,
3232                datatype: 4,
3233                count: 1,
3234            },
3235            PointFieldView {
3236                name: "fi32",
3237                offset: 6,
3238                datatype: 5,
3239                count: 1,
3240            },
3241            PointFieldView {
3242                name: "fu32",
3243                offset: 10,
3244                datatype: 6,
3245                count: 1,
3246            },
3247            PointFieldView {
3248                name: "ff32",
3249                offset: 14,
3250                datatype: 7,
3251                count: 1,
3252            },
3253            PointFieldView {
3254                name: "ff64",
3255                offset: 18,
3256                datatype: 8,
3257                count: 1,
3258            },
3259        ];
3260        let point_step = 26u32;
3261        let mut data = vec![0u8; point_step as usize];
3262        data[0] = 0xFF_u8; // i8 = -1
3263        data[1] = 200; // u8 = 200
3264        data[2..4].copy_from_slice(&(-1000i16).to_le_bytes());
3265        data[4..6].copy_from_slice(&60000u16.to_le_bytes());
3266        data[6..10].copy_from_slice(&(-1_000_000i32).to_le_bytes());
3267        data[10..14].copy_from_slice(&3_000_000_000u32.to_le_bytes());
3268        data[14..18].copy_from_slice(&1.5f32.to_le_bytes());
3269        data[18..26].copy_from_slice(&2.5f64.to_le_bytes());
3270
3271        PointCloud2::new(
3272            Time::new(0, 0),
3273            "coerce",
3274            1,
3275            1,
3276            &fields,
3277            false,
3278            point_step,
3279            point_step,
3280            &data,
3281            true,
3282        )
3283        .unwrap()
3284        .to_cdr()
3285    }
3286
3287    #[test]
3288    fn field_desc_read_as_f64_all_types() {
3289        let cdr = make_coercion_cloud_cdr();
3290        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3291        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3292        let p = cloud.point(0).unwrap();
3293        let data = p.data();
3294
3295        assert_eq!(cloud.field("fi8").unwrap().read_as_f64(data), Some(-1.0));
3296        assert_eq!(cloud.field("fu8").unwrap().read_as_f64(data), Some(200.0));
3297        assert_eq!(
3298            cloud.field("fi16").unwrap().read_as_f64(data),
3299            Some(-1000.0)
3300        );
3301        assert_eq!(
3302            cloud.field("fu16").unwrap().read_as_f64(data),
3303            Some(60000.0)
3304        );
3305        assert_eq!(
3306            cloud.field("fi32").unwrap().read_as_f64(data),
3307            Some(-1_000_000.0)
3308        );
3309        assert_eq!(
3310            cloud.field("fu32").unwrap().read_as_f64(data),
3311            Some(3_000_000_000.0)
3312        );
3313        assert_eq!(
3314            cloud.field("ff32").unwrap().read_as_f64(data),
3315            Some(1.5f32 as f64)
3316        );
3317        assert_eq!(cloud.field("ff64").unwrap().read_as_f64(data), Some(2.5));
3318    }
3319
3320    #[test]
3321    fn field_desc_read_as_f32_all_types() {
3322        let cdr = make_coercion_cloud_cdr();
3323        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3324        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3325        let p = cloud.point(0).unwrap();
3326        let data = p.data();
3327
3328        assert_eq!(cloud.field("fi8").unwrap().read_as_f32(data), Some(-1.0f32));
3329        assert_eq!(
3330            cloud.field("fu8").unwrap().read_as_f32(data),
3331            Some(200.0f32)
3332        );
3333        assert_eq!(
3334            cloud.field("fi16").unwrap().read_as_f32(data),
3335            Some(-1000.0f32)
3336        );
3337        assert_eq!(
3338            cloud.field("fu16").unwrap().read_as_f32(data),
3339            Some(60000.0f32)
3340        );
3341        assert_eq!(
3342            cloud.field("fi32").unwrap().read_as_f32(data),
3343            Some(-1_000_000.0f32)
3344        );
3345        // u32: 3_000_000_000 loses precision in f32 — use as-cast for expected value
3346        assert_eq!(
3347            cloud.field("fu32").unwrap().read_as_f32(data),
3348            Some(3_000_000_000u32 as f32)
3349        );
3350        assert_eq!(cloud.field("ff32").unwrap().read_as_f32(data), Some(1.5f32));
3351        assert_eq!(cloud.field("ff64").unwrap().read_as_f32(data), Some(2.5f32));
3352    }
3353
3354    #[test]
3355    fn field_desc_read_as_out_of_bounds() {
3356        let bad = FieldDesc {
3357            name: "fake",
3358            byte_offset: 100,
3359            field_type: PointFieldType::Float32,
3360            count: 1,
3361        };
3362        let short_data = [0u8; 4];
3363        assert!(bad.read_as_f64(&short_data).is_none());
3364        assert!(bad.read_as_f32(&short_data).is_none());
3365
3366        let bad_f64 = FieldDesc {
3367            name: "fake64",
3368            byte_offset: 100,
3369            field_type: PointFieldType::Float64,
3370            count: 1,
3371        };
3372        assert!(bad_f64.read_as_f64(&short_data).is_none());
3373        assert!(bad_f64.read_as_f32(&short_data).is_none());
3374
3375        // 1-byte and 2-byte types with offset past end
3376        let bad_u8 = FieldDesc {
3377            name: "oob_u8",
3378            byte_offset: 100,
3379            field_type: PointFieldType::Uint8,
3380            count: 1,
3381        };
3382        assert!(bad_u8.read_as_f64(&short_data).is_none());
3383        assert!(bad_u8.read_as_f32(&short_data).is_none());
3384
3385        let bad_i16 = FieldDesc {
3386            name: "oob_i16",
3387            byte_offset: 100,
3388            field_type: PointFieldType::Int16,
3389            count: 1,
3390        };
3391        assert!(bad_i16.read_as_f64(&short_data).is_none());
3392        assert!(bad_i16.read_as_f32(&short_data).is_none());
3393    }
3394
3395    #[test]
3396    fn dynpoint_read_as_convenience() {
3397        let cdr = make_coercion_cloud_cdr();
3398        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3399        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3400        let p = cloud.point(0).unwrap();
3401
3402        // f64 coercion from various source types
3403        assert_eq!(p.read_as_f64("fu16"), Some(60000.0));
3404        assert_eq!(p.read_as_f64("fi32"), Some(-1_000_000.0));
3405        assert_eq!(p.read_as_f64("ff32"), Some(1.5f32 as f64));
3406
3407        // f32 coercion
3408        assert_eq!(p.read_as_f32("fu8"), Some(200.0f32));
3409        assert_eq!(p.read_as_f32("fi16"), Some(-1000.0f32));
3410        assert_eq!(p.read_as_f32("ff64"), Some(2.5f32));
3411    }
3412
3413    #[test]
3414    fn dynpoint_read_as_missing_field() {
3415        let cdr = make_coercion_cloud_cdr();
3416        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3417        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3418        let p = cloud.point(0).unwrap();
3419
3420        assert!(p.read_as_f64("nonexistent").is_none());
3421        assert!(p.read_as_f32("nonexistent").is_none());
3422    }
3423
3424    #[test]
3425    fn dynpoint_data_and_cloud_accessors() {
3426        let cdr = make_coercion_cloud_cdr();
3427        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3428        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3429        let p = cloud.point(0).unwrap();
3430
3431        assert_eq!(p.data().len(), cloud.point_step());
3432        assert!(p.cloud().field("fi8").is_some());
3433        assert!(p.cloud().field("nonexistent").is_none());
3434    }
3435
3436    #[test]
3437    fn dynpoint_hot_loop_pattern() {
3438        let cdr = make_coercion_cloud_cdr();
3439        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3440        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3441
3442        // Resolve descriptors once
3443        let f32_desc = cloud.field("ff32").unwrap();
3444        let u16_desc = cloud.field("fu16").unwrap();
3445
3446        // Read per-point via data()
3447        for point in cloud.iter() {
3448            assert_eq!(f32_desc.read_as_f64(point.data()), Some(1.5f32 as f64));
3449            assert_eq!(u16_desc.read_as_f32(point.data()), Some(60000.0f32));
3450        }
3451    }
3452
3453    #[test]
3454    fn dyn_cloud_gather_as_f64() {
3455        let cdr = make_coercion_cloud_cdr();
3456        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3457        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3458
3459        // u16 field gathered as f64
3460        assert_eq!(cloud.gather_as_f64("fu16"), Some(vec![60000.0f64]));
3461        // i32 field gathered as f64
3462        assert_eq!(cloud.gather_as_f64("fi32"), Some(vec![-1_000_000.0f64]));
3463    }
3464
3465    #[test]
3466    fn dyn_cloud_gather_as_f32() {
3467        let cdr = make_coercion_cloud_cdr();
3468        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3469        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3470
3471        // u32 field gathered as f32 (precision loss expected)
3472        assert_eq!(
3473            cloud.gather_as_f32("fu32"),
3474            Some(vec![3_000_000_000u32 as f32])
3475        );
3476        // f64 field gathered as f32
3477        assert_eq!(cloud.gather_as_f32("ff64"), Some(vec![2.5f32]));
3478    }
3479
3480    #[test]
3481    fn dyn_cloud_gather_as_missing() {
3482        let cdr = make_coercion_cloud_cdr();
3483        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3484        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3485
3486        assert!(cloud.gather_as_f64("nonexistent").is_none());
3487        assert!(cloud.gather_as_f32("nonexistent").is_none());
3488    }
3489
3490    #[test]
3491    fn dyn_cloud_gather_as_multipoint_with_row_padding() {
3492        // 2×2 organized cloud with row padding, u16 field
3493        let fields = [
3494            PointFieldView {
3495                name: "val",
3496                offset: 0,
3497                datatype: 4,
3498                count: 1,
3499            }, // Uint16
3500        ];
3501        let point_step = 4u32; // 2 bytes val + 2 padding
3502        let width = 2u32;
3503        let height = 2u32;
3504        let row_step = 16u32; // 8 bytes padding per row
3505        let total = (row_step * height) as usize;
3506        let mut data = vec![0xFFu8; total];
3507
3508        let values: [u16; 4] = [100, 200, 300, 400];
3509        for row in 0..height {
3510            for col in 0..width {
3511                let off = (row * row_step + col * point_step) as usize;
3512                let idx = (row * width + col) as usize;
3513                data[off..off + 2].copy_from_slice(&values[idx].to_le_bytes());
3514            }
3515        }
3516
3517        let pc = PointCloud2::new(
3518            Time::new(0, 0),
3519            "multi",
3520            height,
3521            width,
3522            &fields,
3523            false,
3524            point_step,
3525            row_step,
3526            &data,
3527            true,
3528        )
3529        .unwrap();
3530        let cdr = pc.to_cdr();
3531        let decoded = PointCloud2::from_cdr(&cdr).unwrap();
3532        let cloud = DynPointCloud::from_pointcloud2(&decoded).unwrap();
3533
3534        // gather_as_f64: u16 → f64 across 4 points with row padding
3535        assert_eq!(
3536            cloud.gather_as_f64("val"),
3537            Some(vec![100.0, 200.0, 300.0, 400.0])
3538        );
3539
3540        // gather_as_f32: u16 → f32 across 4 points with row padding
3541        assert_eq!(
3542            cloud.gather_as_f32("val"),
3543            Some(vec![100.0f32, 200.0f32, 300.0f32, 400.0f32])
3544        );
3545    }
3546
3547    #[test]
3548    fn field_desc_read_as_f32_infinity_narrowing() {
3549        // Float64 value outside f32 range should produce ±inf
3550        let desc = FieldDesc {
3551            name: "big",
3552            byte_offset: 0,
3553            field_type: PointFieldType::Float64,
3554            count: 1,
3555        };
3556        let big = 1e40_f64.to_le_bytes();
3557        assert_eq!(desc.read_as_f32(&big), Some(f32::INFINITY));
3558
3559        let neg_big = (-1e40_f64).to_le_bytes();
3560        assert_eq!(desc.read_as_f32(&neg_big), Some(f32::NEG_INFINITY));
3561    }
3562}