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