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}