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/// 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}