axiolid_pointcloud/
lib.rs

1//! Point-sampled geometry: an ordered set of 3D points with optional
2//! per-point channels.
3//!
4//! # What this is
5//!
6//! The value a laser scan or photogrammetry capture becomes once it is
7//! inside the kernel. Points carry no topology and no adjacency: a
8//! pointcloud is a *sample* of a surface, not a description of one, and
9//! pretending otherwise is where scan pipelines usually go wrong.
10//!
11//! # What this is not
12//!
13//! Not a file format. LAS, LAZ, E57, PCD and COPC parsing lives outside
14//! `crates/`, exactly as STEP and IFC do for B-rep. No wire, vendor, or
15//! source-format type may appear here — that boundary is what keeps the
16//! kernel portable, and it is recorded in the ingestion-boundary ADR.
17//!
18//! Not an algorithm. Queries live in `axiolid-spatial`, reconstruction
19//! behind the reconstruction contract. This crate owns the value and its
20//! validation, nothing else.
21//!
22//! # Attributes are optional and validated together
23//!
24//! A scan may carry normals, colour, or intensity — or none of them. Each
25//! channel is stored separately so a cloud with no extra data pays nothing,
26//! and each is required to have exactly one entry per point. A channel of
27//! the wrong length is a construction error rather than a hazard discovered
28//! later by an algorithm indexing off the end.
29
30#![forbid(unsafe_code)]
31
32use axiolid_core::{Point3, Scalar, Vec3};
33
34/// Why a pointcloud could not be constructed.
35#[derive(Debug, Clone, PartialEq)]
36#[non_exhaustive]
37pub enum PointCloudError {
38    /// A point has a non-finite coordinate.
39    ///
40    /// Carries the index so a caller can find the offending sample rather
41    /// than re-scanning the input to locate it.
42    NonFinitePoint {
43        /// Index of the offending point.
44        index: usize,
45        /// The value as supplied.
46        point: Point3,
47    },
48    /// A channel's length does not match the point count.
49    ChannelLengthMismatch {
50        /// Name of the offending channel.
51        channel: &'static str,
52        /// Entries the channel supplied.
53        found: usize,
54        /// Entries the cloud requires.
55        expected: usize,
56    },
57    /// A normal is not finite, or is too short to carry a direction.
58    ///
59    /// A zero-length normal is refused rather than normalised to an
60    /// arbitrary axis: the direction is genuinely unknown, and inventing
61    /// one would be indistinguishable downstream from a measured value.
62    DegenerateNormal {
63        /// Index of the offending normal.
64        index: usize,
65        /// The value as supplied.
66        normal: Vec3,
67    },
68    /// An intensity is not finite.
69    NonFiniteIntensity {
70        /// Index of the offending sample.
71        index: usize,
72        /// The value as supplied.
73        intensity: Scalar,
74    },
75}
76
77impl core::fmt::Display for PointCloudError {
78    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
79        match self {
80            Self::NonFinitePoint { index, point } => {
81                write!(f, "point {index} is not finite: {point:?}")
82            }
83            Self::ChannelLengthMismatch {
84                channel,
85                found,
86                expected,
87            } => write!(
88                f,
89                "channel `{channel}` has {found} entries but the cloud has {expected} points"
90            ),
91            Self::DegenerateNormal { index, normal } => {
92                write!(f, "normal {index} is not a usable direction: {normal:?}")
93            }
94            Self::NonFiniteIntensity { index, intensity } => {
95                write!(f, "intensity {index} is not finite: {intensity}")
96            }
97        }
98    }
99}
100
101impl core::error::Error for PointCloudError {}
102
103/// Per-point colour, in unsigned 8-bit channels.
104///
105/// Deliberately not a floating-point triple: capture hardware reports 8- or
106/// 16-bit colour, and widening it here would imply a precision the sensor
107/// never had.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct Colour {
110    /// Red channel.
111    pub red: u8,
112    /// Green channel.
113    pub green: u8,
114    /// Blue channel.
115    pub blue: u8,
116}
117
118impl Colour {
119    /// A colour from its three channels.
120    pub const fn new(red: u8, green: u8, blue: u8) -> Self {
121        Self { red, green, blue }
122    }
123}
124
125/// An ordered set of 3D points with optional per-point channels.
126///
127/// Order is meaningful: it is the caller's, and every channel is indexed by
128/// it. Operations that reorder points must say so, because a consumer
129/// holding a parallel array outside the cloud would otherwise be silently
130/// desynchronised.
131#[derive(Debug, Clone, PartialEq, Default)]
132pub struct PointCloud {
133    points: Vec<Point3>,
134    normals: Option<Vec<Vec3>>,
135    colours: Option<Vec<Colour>>,
136    intensities: Option<Vec<Scalar>>,
137}
138
139impl PointCloud {
140    /// A cloud of positions, with no attributes.
141    ///
142    /// # Errors
143    ///
144    /// Refuses a non-finite coordinate, naming the offending index.
145    pub fn new(points: Vec<Point3>) -> Result<Self, PointCloudError> {
146        for (index, point) in points.iter().enumerate() {
147            if !point.is_finite() {
148                return Err(PointCloudError::NonFinitePoint {
149                    index,
150                    point: *point,
151                });
152            }
153        }
154        Ok(Self {
155            points,
156            normals: None,
157            colours: None,
158            intensities: None,
159        })
160    }
161
162    /// An empty cloud.
163    ///
164    /// Legitimate rather than exceptional: a query that filters everything
165    /// out should return an empty cloud, not an error. Consumers that need
166    /// points must check [`is_empty`](Self::is_empty) themselves.
167    pub fn empty() -> Self {
168        Self::default()
169    }
170
171    /// Attach per-point normals.
172    ///
173    /// # Errors
174    ///
175    /// Refuses a length mismatch, and a normal that is non-finite or too
176    /// short to carry a direction.
177    pub fn with_normals(mut self, normals: Vec<Vec3>) -> Result<Self, PointCloudError> {
178        if normals.len() != self.points.len() {
179            return Err(PointCloudError::ChannelLengthMismatch {
180                channel: "normals",
181                found: normals.len(),
182                expected: self.points.len(),
183            });
184        }
185        for (index, normal) in normals.iter().enumerate() {
186            // The threshold is the smallest normal positive value rather
187            // than a modelling tolerance: this is a direction, not a
188            // length, so it is scale-free.
189            if !normal.is_finite() || normal.length() <= Scalar::MIN_POSITIVE {
190                return Err(PointCloudError::DegenerateNormal {
191                    index,
192                    normal: *normal,
193                });
194            }
195        }
196        self.normals = Some(normals);
197        Ok(self)
198    }
199
200    /// Attach per-point colours.
201    ///
202    /// # Errors
203    ///
204    /// Refuses a length mismatch. Colour channels cannot be individually
205    /// invalid, since every 8-bit value is meaningful.
206    pub fn with_colours(mut self, colours: Vec<Colour>) -> Result<Self, PointCloudError> {
207        if colours.len() != self.points.len() {
208            return Err(PointCloudError::ChannelLengthMismatch {
209                channel: "colours",
210                found: colours.len(),
211                expected: self.points.len(),
212            });
213        }
214        self.colours = Some(colours);
215        Ok(self)
216    }
217
218    /// Attach per-point intensities.
219    ///
220    /// # Errors
221    ///
222    /// Refuses a length mismatch and a non-finite intensity. The range is
223    /// not constrained: sensors report intensity on their own scale, and
224    /// normalising it here would discard information the caller may need.
225    pub fn with_intensities(mut self, intensities: Vec<Scalar>) -> Result<Self, PointCloudError> {
226        if intensities.len() != self.points.len() {
227            return Err(PointCloudError::ChannelLengthMismatch {
228                channel: "intensities",
229                found: intensities.len(),
230                expected: self.points.len(),
231            });
232        }
233        for (index, intensity) in intensities.iter().enumerate() {
234            if !intensity.is_finite() {
235                return Err(PointCloudError::NonFiniteIntensity {
236                    index,
237                    intensity: *intensity,
238                });
239            }
240        }
241        self.intensities = Some(intensities);
242        Ok(self)
243    }
244
245    /// The points, in the caller's order.
246    pub fn points(&self) -> &[Point3] {
247        &self.points
248    }
249
250    /// Per-point normals, if the cloud carries them.
251    pub fn normals(&self) -> Option<&[Vec3]> {
252        self.normals.as_deref()
253    }
254
255    /// Per-point colours, if the cloud carries them.
256    pub fn colours(&self) -> Option<&[Colour]> {
257        self.colours.as_deref()
258    }
259
260    /// Per-point intensities, if the cloud carries them.
261    pub fn intensities(&self) -> Option<&[Scalar]> {
262        self.intensities.as_deref()
263    }
264
265    /// Number of points.
266    pub fn len(&self) -> usize {
267        self.points.len()
268    }
269
270    /// Whether the cloud has no points.
271    pub fn is_empty(&self) -> bool {
272        self.points.is_empty()
273    }
274
275    /// Whether every point carries a normal.
276    ///
277    /// Reconstruction algorithms differ sharply on this: Poisson needs
278    /// oriented normals, ball pivoting does not. Asking is cheaper than
279    /// discovering it mid-operation.
280    pub fn has_normals(&self) -> bool {
281        self.normals.is_some()
282    }
283
284    /// Axis-aligned bounds as `(min, max)`, or `None` when empty.
285    ///
286    /// Provided here because it needs no algorithm and every consumer wants
287    /// it; anything beyond this belongs in a query crate.
288    pub fn bounds(&self) -> Option<(Point3, Point3)> {
289        let first = *self.points.first()?;
290        let mut min = first;
291        let mut max = first;
292        for p in &self.points[1..] {
293            min = Point3::new(min.x.min(p.x), min.y.min(p.y), min.z.min(p.z));
294            max = Point3::new(max.x.max(p.x), max.y.max(p.y), max.z.max(p.z));
295        }
296        Some((min, max))
297    }
298}