oxigdal_copc/point.rs
1//! LAS/LAZ point record types and 3D bounding box.
2//!
3//! Implements [`Point3D`] following the ASPRS LAS 1.4 specification (R15, November 2019)
4//! and [`BoundingBox3D`] for spatial queries.
5
6/// A single LAS/LAZ point record.
7///
8/// Covers the core fields present in all LAS point data format IDs (0–10).
9/// Optional fields (`gps_time`, `red`, `green`, `blue`) are `None` for format
10/// IDs that do not carry them.
11#[derive(Debug, Clone, PartialEq)]
12pub struct Point3D {
13 /// X coordinate (scaled and offset per LAS header).
14 pub x: f64,
15 /// Y coordinate (scaled and offset per LAS header).
16 pub y: f64,
17 /// Z coordinate (scaled and offset per LAS header).
18 pub z: f64,
19 /// Laser pulse return intensity (0–65535).
20 pub intensity: u16,
21 /// Return number within the pulse (1-based, ≤ `number_of_returns`).
22 pub return_number: u8,
23 /// Total number of returns for this pulse.
24 pub number_of_returns: u8,
25 /// ASPRS classification code (see [`Point3D::classification_name`]).
26 pub classification: u8,
27 /// Scan angle rank in degrees (−90 to +90, rounded to integer for formats 0-5).
28 pub scan_angle_rank: i8,
29 /// User-defined data byte.
30 pub user_data: u8,
31 /// Point source ID (flight line ID for airborne surveys).
32 pub point_source_id: u16,
33 /// GPS time of the point (present in formats 1, 3, 5, 6–10).
34 pub gps_time: Option<f64>,
35 /// Red channel colour value (present in formats 2, 3, 5, 7, 8).
36 pub red: Option<u16>,
37 /// Green channel colour value (present in formats 2, 3, 5, 7, 8).
38 pub green: Option<u16>,
39 /// Blue channel colour value (present in formats 2, 3, 5, 7, 8).
40 pub blue: Option<u16>,
41}
42
43impl Point3D {
44 /// Create a new point at `(x, y, z)` with all other fields zeroed / `None`.
45 #[inline]
46 pub fn new(x: f64, y: f64, z: f64) -> Self {
47 Self {
48 x,
49 y,
50 z,
51 intensity: 0,
52 return_number: 1,
53 number_of_returns: 1,
54 classification: 0,
55 scan_angle_rank: 0,
56 user_data: 0,
57 point_source_id: 0,
58 gps_time: None,
59 red: None,
60 green: None,
61 blue: None,
62 }
63 }
64
65 /// Builder: set the intensity value.
66 #[inline]
67 pub fn with_intensity(mut self, intensity: u16) -> Self {
68 self.intensity = intensity;
69 self
70 }
71
72 /// Builder: set the ASPRS classification code.
73 #[inline]
74 pub fn with_classification(mut self, class: u8) -> Self {
75 self.classification = class;
76 self
77 }
78
79 /// Builder: set red / green / blue colour values.
80 #[inline]
81 pub fn with_color(mut self, r: u16, g: u16, b: u16) -> Self {
82 self.red = Some(r);
83 self.green = Some(g);
84 self.blue = Some(b);
85 self
86 }
87
88 /// Builder: set the GPS timestamp.
89 #[inline]
90 pub fn with_gps_time(mut self, t: f64) -> Self {
91 self.gps_time = Some(t);
92 self
93 }
94
95 /// 3-D Euclidean distance to another point.
96 #[inline]
97 pub fn distance_to(&self, other: &Point3D) -> f64 {
98 let dx = self.x - other.x;
99 let dy = self.y - other.y;
100 let dz = self.z - other.z;
101 (dx * dx + dy * dy + dz * dz).sqrt()
102 }
103
104 /// 2-D horizontal distance (ignores Z) to another point.
105 #[inline]
106 pub fn distance_2d(&self, other: &Point3D) -> f64 {
107 let dx = self.x - other.x;
108 let dy = self.y - other.y;
109 (dx * dx + dy * dy).sqrt()
110 }
111
112 /// Human-readable name for the ASPRS LAS 1.4 classification code.
113 ///
114 /// Returns the standard name for codes 0–18 and `"Reserved/Unknown"` for
115 /// everything else.
116 pub fn classification_name(&self) -> &'static str {
117 match self.classification {
118 0 => "Created, Never Classified",
119 1 => "Unclassified",
120 2 => "Ground",
121 3 => "Low Vegetation",
122 4 => "Medium Vegetation",
123 5 => "High Vegetation",
124 6 => "Building",
125 7 => "Low Point (Noise)",
126 8 => "Reserved",
127 9 => "Water",
128 10 => "Rail",
129 11 => "Road Surface",
130 12 => "Reserved",
131 13 => "Wire - Guard (Shield)",
132 14 => "Wire - Conductor (Phase)",
133 15 => "Transmission Tower",
134 16 => "Wire-Structure Connector (Insulator)",
135 17 => "Bridge Deck",
136 18 => "High Noise",
137 _ => "Reserved/Unknown",
138 }
139 }
140}
141
142// ---------------------------------------------------------------------------
143// BoundingBox3D
144// ---------------------------------------------------------------------------
145
146/// An axis-aligned 3-D bounding box.
147///
148/// Invariant: `min_x ≤ max_x`, `min_y ≤ max_y`, `min_z ≤ max_z`.
149/// This invariant is enforced by [`BoundingBox3D::new`].
150#[derive(Debug, Clone, PartialEq)]
151pub struct BoundingBox3D {
152 /// Minimum X coordinate.
153 pub min_x: f64,
154 /// Minimum Y coordinate.
155 pub min_y: f64,
156 /// Minimum Z coordinate.
157 pub min_z: f64,
158 /// Maximum X coordinate.
159 pub max_x: f64,
160 /// Maximum Y coordinate.
161 pub max_y: f64,
162 /// Maximum Z coordinate.
163 pub max_z: f64,
164}
165
166impl BoundingBox3D {
167 /// Construct a new bounding box.
168 ///
169 /// Returns `None` if any `min > max` invariant is violated.
170 pub fn new(
171 min_x: f64,
172 min_y: f64,
173 min_z: f64,
174 max_x: f64,
175 max_y: f64,
176 max_z: f64,
177 ) -> Option<Self> {
178 if min_x > max_x || min_y > max_y || min_z > max_z {
179 return None;
180 }
181 Some(Self {
182 min_x,
183 min_y,
184 min_z,
185 max_x,
186 max_y,
187 max_z,
188 })
189 }
190
191 /// Build the tightest bounding box that contains every point in `points`.
192 ///
193 /// Returns `None` when `points` is empty.
194 pub fn from_points(points: &[Point3D]) -> Option<Self> {
195 let first = points.first()?;
196 let mut min_x = first.x;
197 let mut min_y = first.y;
198 let mut min_z = first.z;
199 let mut max_x = first.x;
200 let mut max_y = first.y;
201 let mut max_z = first.z;
202
203 for p in points.iter().skip(1) {
204 if p.x < min_x {
205 min_x = p.x;
206 }
207 if p.y < min_y {
208 min_y = p.y;
209 }
210 if p.z < min_z {
211 min_z = p.z;
212 }
213 if p.x > max_x {
214 max_x = p.x;
215 }
216 if p.y > max_y {
217 max_y = p.y;
218 }
219 if p.z > max_z {
220 max_z = p.z;
221 }
222 }
223
224 Some(Self {
225 min_x,
226 min_y,
227 min_z,
228 max_x,
229 max_y,
230 max_z,
231 })
232 }
233
234 /// Return `true` when `p` is strictly inside or on the boundary.
235 #[inline]
236 pub fn contains(&self, p: &Point3D) -> bool {
237 p.x >= self.min_x
238 && p.x <= self.max_x
239 && p.y >= self.min_y
240 && p.y <= self.max_y
241 && p.z >= self.min_z
242 && p.z <= self.max_z
243 }
244
245 /// Return `true` when the XY footprints of `self` and `other` overlap (or
246 /// touch).
247 #[inline]
248 pub fn intersects_2d(&self, other: &BoundingBox3D) -> bool {
249 self.min_x <= other.max_x
250 && self.max_x >= other.min_x
251 && self.min_y <= other.max_y
252 && self.max_y >= other.min_y
253 }
254
255 /// Return `true` when `self` and `other` share any volume (or face).
256 #[inline]
257 pub fn intersects_3d(&self, other: &BoundingBox3D) -> bool {
258 self.intersects_2d(other) && self.min_z <= other.max_z && self.max_z >= other.min_z
259 }
260
261 /// Centre point of the box as `(cx, cy, cz)`.
262 #[inline]
263 pub fn center(&self) -> (f64, f64, f64) {
264 (
265 (self.min_x + self.max_x) * 0.5,
266 (self.min_y + self.max_y) * 0.5,
267 (self.min_z + self.max_z) * 0.5,
268 )
269 }
270
271 /// Length of the space diagonal (3-D).
272 #[inline]
273 pub fn diagonal(&self) -> f64 {
274 let dx = self.max_x - self.min_x;
275 let dy = self.max_y - self.min_y;
276 let dz = self.max_z - self.min_z;
277 (dx * dx + dy * dy + dz * dz).sqrt()
278 }
279
280 /// Return a box expanded symmetrically in every direction by `delta`.
281 ///
282 /// If `delta` is negative the box may collapse; the result will still
283 /// satisfy the `min ≤ max` invariant because `f64` arithmetic naturally
284 /// produces equal values when expansion < 0 produces min > max (the
285 /// caller should validate the result if that matters).
286 #[inline]
287 pub fn expand_by(&self, delta: f64) -> Self {
288 Self {
289 min_x: self.min_x - delta,
290 min_y: self.min_y - delta,
291 min_z: self.min_z - delta,
292 max_x: self.max_x + delta,
293 max_y: self.max_y + delta,
294 max_z: self.max_z + delta,
295 }
296 }
297
298 /// Volume of the box.
299 #[inline]
300 pub fn volume(&self) -> f64 {
301 (self.max_x - self.min_x) * (self.max_y - self.min_y) * (self.max_z - self.min_z)
302 }
303
304 /// Subdivide the box into eight equal octant children.
305 ///
306 /// Children are ordered by `(x_high, y_high, z_high)` bits:
307 /// index 0 = `(lo, lo, lo)`, index 7 = `(hi, hi, hi)`.
308 pub fn split_octants(&self) -> [BoundingBox3D; 8] {
309 let (cx, cy, cz) = self.center();
310 [
311 // 0: x-lo, y-lo, z-lo
312 BoundingBox3D {
313 min_x: self.min_x,
314 min_y: self.min_y,
315 min_z: self.min_z,
316 max_x: cx,
317 max_y: cy,
318 max_z: cz,
319 },
320 // 1: x-lo, y-lo, z-hi
321 BoundingBox3D {
322 min_x: self.min_x,
323 min_y: self.min_y,
324 min_z: cz,
325 max_x: cx,
326 max_y: cy,
327 max_z: self.max_z,
328 },
329 // 2: x-lo, y-hi, z-lo
330 BoundingBox3D {
331 min_x: self.min_x,
332 min_y: cy,
333 min_z: self.min_z,
334 max_x: cx,
335 max_y: self.max_y,
336 max_z: cz,
337 },
338 // 3: x-lo, y-hi, z-hi
339 BoundingBox3D {
340 min_x: self.min_x,
341 min_y: cy,
342 min_z: cz,
343 max_x: cx,
344 max_y: self.max_y,
345 max_z: self.max_z,
346 },
347 // 4: x-hi, y-lo, z-lo
348 BoundingBox3D {
349 min_x: cx,
350 min_y: self.min_y,
351 min_z: self.min_z,
352 max_x: self.max_x,
353 max_y: cy,
354 max_z: cz,
355 },
356 // 5: x-hi, y-lo, z-hi
357 BoundingBox3D {
358 min_x: cx,
359 min_y: self.min_y,
360 min_z: cz,
361 max_x: self.max_x,
362 max_y: cy,
363 max_z: self.max_z,
364 },
365 // 6: x-hi, y-hi, z-lo
366 BoundingBox3D {
367 min_x: cx,
368 min_y: cy,
369 min_z: self.min_z,
370 max_x: self.max_x,
371 max_y: self.max_y,
372 max_z: cz,
373 },
374 // 7: x-hi, y-hi, z-hi
375 BoundingBox3D {
376 min_x: cx,
377 min_y: cy,
378 min_z: cz,
379 max_x: self.max_x,
380 max_y: self.max_y,
381 max_z: self.max_z,
382 },
383 ]
384 }
385}