Skip to main content

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}