Skip to main content

oxigdal_algorithms/raster/
tin_interp.rs

1//! TIN (Triangulated Irregular Network) interpolation using Delaunay triangulation.
2//!
3//! A Triangulated Irregular Network (TIN) is a vector-based representation of a
4//! surface, built from a set of arbitrarily distributed `(x, y, z)` samples by
5//! triangulating their 2D footprint and interpolating elevations within each
6//! triangle. TINs are a fundamental data structure in GIS, used for terrain
7//! modelling, scattered-data interpolation, watershed analysis and conversion
8//! between point clouds and raster digital elevation models.
9//!
10//! This module provides:
11//!
12//! - [`build_tin`]: build a TIN from a slice of [`TinPoint`] samples by
13//!   computing the Delaunay triangulation of their 2D projection.
14//! - [`interpolate_idw_tin`]: query a height at `(x, y)` using inverse-distance
15//!   weighting from the enclosing triangle's three vertices.
16//! - [`interpolate_natural_neighbor`]: query a height at `(x, y)` using
17//!   barycentric (natural-neighbor-style) interpolation within the enclosing
18//!   triangle. Full Sibson area-stealing is a documented v0.2.0 enhancement.
19//! - [`rasterize_tin`]: rasterise the TIN over a regular grid, producing a
20//!   row-major `Vec<f32>` with `NaN` outside the convex hull.
21//!
22//! # Choosing an interpolation method
23//!
24//! - **IDW** is simple, robust and continuous everywhere inside the convex
25//!   hull. With `power = 2.0` it falls off quadratically, giving more weight
26//!   to the nearest vertex of the enclosing triangle.
27//! - **Natural neighbor** (barycentric within the triangle) is C0-continuous
28//!   and exactly reproduces linear data — every point of a planar input
29//!   surface returns the exact analytic height — making it the preferred
30//!   choice for terrain rasterisation of well-distributed samples.
31//!
32//! Both methods return `None` for query points that lie outside the convex
33//! hull of the input samples (no extrapolation is performed).
34//!
35//! # Examples
36//!
37//! ```
38//! use oxigdal_algorithms::raster::{build_tin, interpolate_idw_tin, TinPoint};
39//!
40//! let points = vec![
41//!     TinPoint { x: 0.0, y: 0.0, z: 0.0 },
42//!     TinPoint { x: 1.0, y: 0.0, z: 1.0 },
43//!     TinPoint { x: 0.5, y: 1.0, z: 0.5 },
44//! ];
45//! let tin = build_tin(&points).expect("build TIN");
46//! assert_eq!(tin.triangle_count(), 1);
47//!
48//! let z = interpolate_idw_tin(&tin, 0.5, 0.25, 2.0)
49//!     .expect("query inside hull");
50//! assert!(z.is_finite());
51//! ```
52
53use crate::error::Result;
54use crate::vector::delaunay::{DelaunayOptions, delaunay_triangulation};
55use oxigdal_core::vector::Point;
56
57// ---------------------------------------------------------------------------
58// Public types
59// ---------------------------------------------------------------------------
60
61/// A 3D sample point used as input to TIN construction.
62///
63/// `x` and `y` define the horizontal location of the sample; `z` is the
64/// scalar attribute being interpolated (typically elevation, but the TIN
65/// machinery is agnostic to physical meaning).
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct TinPoint {
68    /// Horizontal x coordinate (planimetric).
69    pub x: f64,
70    /// Horizontal y coordinate (planimetric).
71    pub y: f64,
72    /// Scalar value at `(x, y)` (e.g. elevation).
73    pub z: f64,
74}
75
76impl TinPoint {
77    /// Construct a new `TinPoint` from its three coordinates.
78    pub fn new(x: f64, y: f64, z: f64) -> Self {
79        Self { x, y, z }
80    }
81}
82
83/// A Triangulated Irregular Network: a set of 3D samples together with the
84/// Delaunay triangulation of their 2D projection.
85///
86/// Triangle vertex indices are stored as triples into [`Self::points`]. The
87/// orientation produced by [`delaunay_triangulation`] is implementation-defined
88/// but consistent: `find_triangle` performs orientation-agnostic
89/// barycentric containment tests, so consumers do not need to assume CCW.
90#[derive(Debug, Clone)]
91pub struct Tin {
92    /// The input sample points, preserved in their original order so that
93    /// triangle indices remain valid for downstream consumers.
94    pub points: Vec<TinPoint>,
95    /// Triangle vertex indices, three per triangle.
96    pub triangles: Vec<[usize; 3]>,
97}
98
99impl Tin {
100    /// Number of triangles in the TIN.
101    pub fn triangle_count(&self) -> usize {
102        self.triangles.len()
103    }
104
105    /// Number of input sample points (regardless of whether they participated
106    /// in any triangle, e.g. for degenerate inputs).
107    pub fn point_count(&self) -> usize {
108        self.points.len()
109    }
110
111    /// Returns `true` if the TIN contains no triangles.
112    pub fn is_empty(&self) -> bool {
113        self.triangles.is_empty()
114    }
115
116    /// Axis-aligned bounding box of the input points as
117    /// `(min_x, min_y, max_x, max_y)`, or `None` if the TIN is empty of points.
118    pub fn bounding_box(&self) -> Option<(f64, f64, f64, f64)> {
119        let mut iter = self.points.iter();
120        let first = iter.next()?;
121        let mut min_x = first.x;
122        let mut min_y = first.y;
123        let mut max_x = first.x;
124        let mut max_y = first.y;
125        for p in iter {
126            if p.x < min_x {
127                min_x = p.x;
128            }
129            if p.y < min_y {
130                min_y = p.y;
131            }
132            if p.x > max_x {
133                max_x = p.x;
134            }
135            if p.y > max_y {
136                max_y = p.y;
137            }
138        }
139        Some((min_x, min_y, max_x, max_y))
140    }
141}
142
143/// Interpolation method used by [`rasterize_tin`].
144///
145/// See module-level documentation for guidance on choosing between the two
146/// variants.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub enum TinInterpMethod {
149    /// Inverse-distance weighting from the enclosing triangle's three
150    /// vertices. `power` controls the falloff exponent; `power = 2.0` is the
151    /// classical Shepard interpolation. Must be strictly positive.
152    Idw {
153        /// Falloff exponent applied to the inverse vertex-distance weights.
154        power: f64,
155    },
156    /// Barycentric (natural-neighbor-style) interpolation within the
157    /// enclosing Delaunay triangle. Exactly reproduces linear surfaces.
158    NaturalNeighbor,
159}
160
161impl Default for TinInterpMethod {
162    fn default() -> Self {
163        Self::NaturalNeighbor
164    }
165}
166
167// ---------------------------------------------------------------------------
168// Construction
169// ---------------------------------------------------------------------------
170
171/// Build a TIN from a point cloud by computing the Delaunay triangulation of
172/// the 2D projection of `points`.
173///
174/// Returns a [`Tin`] whose `points` field is a copy of the input slice and
175/// whose `triangles` field lists vertex-index triples. If `points.len() < 3`
176/// the returned TIN contains the input points but no triangles (this is a
177/// well-defined degenerate result rather than an error, since callers may
178/// chain TIN construction over streaming chunks).
179///
180/// # Errors
181///
182/// Propagates any error from [`delaunay_triangulation`].
183pub fn build_tin(points: &[TinPoint]) -> Result<Tin> {
184    if points.len() < 3 {
185        return Ok(Tin {
186            points: points.to_vec(),
187            triangles: Vec::new(),
188        });
189    }
190
191    // Convert to oxigdal-core Points (2D only — z is preserved separately).
192    let core_points: Vec<Point> = points.iter().map(|p| Point::new(p.x, p.y)).collect();
193
194    let options = DelaunayOptions::default();
195    let triangulation = delaunay_triangulation(&core_points, &options)?;
196
197    let triangles: Vec<[usize; 3]> = triangulation
198        .triangles
199        .into_iter()
200        .map(|t| t.vertices)
201        .collect();
202
203    Ok(Tin {
204        points: points.to_vec(),
205        triangles,
206    })
207}
208
209// ---------------------------------------------------------------------------
210// Triangle location
211// ---------------------------------------------------------------------------
212
213/// Tolerance, in barycentric-coordinate space, used when deciding whether a
214/// query point lies inside a triangle. Generous enough to handle floating
215/// point noise on edges and corners without rejecting valid queries.
216const BARY_EPS: f64 = 1e-9;
217
218/// Tolerance for treating a query point as coincident with a triangle vertex.
219const COINCIDENT_EPS: f64 = 1e-12;
220
221/// Find the triangle containing `(qx, qy)` and return its index together with
222/// the barycentric coordinates of the query within that triangle.
223///
224/// Performs a brute-force linear scan over all triangles. For typical TIN
225/// sizes (tens of thousands of triangles) this is acceptable; a future
226/// enhancement could plug in the R-tree from `vector::clustering` for
227/// sub-linear queries. Returns `None` if the query lies outside every
228/// triangle (i.e. outside the convex hull of the input points).
229fn find_triangle(tin: &Tin, qx: f64, qy: f64) -> Option<(usize, [f64; 3])> {
230    for (ti, tri) in tin.triangles.iter().enumerate() {
231        let [a, b, c] = *tri;
232        let pa = tin.points[a];
233        let pb = tin.points[b];
234        let pc = tin.points[c];
235
236        // Compute the (signed) twice-area determinant used to normalise the
237        // barycentric coordinates. A near-zero determinant signals a
238        // degenerate (collinear) triangle that we skip.
239        let det = (pb.y - pc.y) * (pa.x - pc.x) + (pc.x - pb.x) * (pa.y - pc.y);
240        if det.abs() < COINCIDENT_EPS {
241            continue;
242        }
243
244        let l1 = ((pb.y - pc.y) * (qx - pc.x) + (pc.x - pb.x) * (qy - pc.y)) / det;
245        let l2 = ((pc.y - pa.y) * (qx - pc.x) + (pa.x - pc.x) * (qy - pc.y)) / det;
246        let l3 = 1.0 - l1 - l2;
247
248        // Inside-or-on-boundary test. We accept negative values within
249        // BARY_EPS to absorb floating-point noise along shared edges.
250        if l1 >= -BARY_EPS && l2 >= -BARY_EPS && l3 >= -BARY_EPS {
251            return Some((ti, [l1, l2, l3]));
252        }
253    }
254    None
255}
256
257// ---------------------------------------------------------------------------
258// Single-point interpolation
259// ---------------------------------------------------------------------------
260
261/// Inverse-distance-weighted interpolation at `(qx, qy)` using the three
262/// vertices of the enclosing Delaunay triangle.
263///
264/// `power` is the falloff exponent; `power = 2.0` is classical Shepard
265/// interpolation. If `(qx, qy)` coincides with a vertex (within
266/// `COINCIDENT_EPS`) the corresponding vertex's `z` value is returned
267/// directly to avoid a division by zero. Returns `None` if the query lies
268/// outside the convex hull of the input samples.
269///
270/// # Panics
271///
272/// Does not panic. A non-positive `power` is silently treated as `power = 1.0`
273/// (the safest non-degenerate fallback): a real geospatial pipeline would
274/// surface this as a configuration error upstream of the inner loop.
275pub fn interpolate_idw_tin(tin: &Tin, qx: f64, qy: f64, power: f64) -> Option<f64> {
276    let (tri_idx, _bary) = find_triangle(tin, qx, qy)?;
277    let [a, b, c] = tin.triangles[tri_idx];
278    let pa = tin.points[a];
279    let pb = tin.points[b];
280    let pc = tin.points[c];
281
282    let effective_power = if power.is_finite() && power > 0.0 {
283        power
284    } else {
285        1.0
286    };
287
288    let dist = |p: TinPoint| -> f64 {
289        let dx = p.x - qx;
290        let dy = p.y - qy;
291        (dx * dx + dy * dy).sqrt()
292    };
293    let da = dist(pa);
294    let db = dist(pb);
295    let dc = dist(pc);
296
297    if da < COINCIDENT_EPS {
298        return Some(pa.z);
299    }
300    if db < COINCIDENT_EPS {
301        return Some(pb.z);
302    }
303    if dc < COINCIDENT_EPS {
304        return Some(pc.z);
305    }
306
307    let wa = 1.0 / da.powf(effective_power);
308    let wb = 1.0 / db.powf(effective_power);
309    let wc = 1.0 / dc.powf(effective_power);
310    let total = wa + wb + wc;
311    if total <= 0.0 || !total.is_finite() {
312        return None;
313    }
314    Some((pa.z * wa + pb.z * wb + pc.z * wc) / total)
315}
316
317/// Natural-neighbor-style interpolation at `(qx, qy)` using barycentric
318/// coordinates within the enclosing Delaunay triangle.
319///
320/// This is exact for linear surfaces (planar inputs reproduce z everywhere)
321/// and continuous across triangle edges. Full Sibson area-stealing — which
322/// considers the second ring of natural neighbours — is documented as a
323/// v0.2.0 enhancement; barycentric interpolation is the standard fallback
324/// in production TIN rasterisers (matching e.g. GDAL's
325/// `gdal_grid -a linear`).
326///
327/// Returns `None` if the query lies outside the convex hull of the input
328/// samples.
329pub fn interpolate_natural_neighbor(tin: &Tin, qx: f64, qy: f64) -> Option<f64> {
330    let (tri_idx, bary) = find_triangle(tin, qx, qy)?;
331    let [a, b, c] = tin.triangles[tri_idx];
332    let pa = tin.points[a];
333    let pb = tin.points[b];
334    let pc = tin.points[c];
335    Some(pa.z * bary[0] + pb.z * bary[1] + pc.z * bary[2])
336}
337
338// ---------------------------------------------------------------------------
339// Rasterisation
340// ---------------------------------------------------------------------------
341
342/// Rasterise a TIN over a regular grid covering the bounding box
343/// `(min_x, min_y, max_x, max_y)`.
344///
345/// Returns a row-major `Vec<f32>` of length `width * height`. Pixel
346/// `(i, j)` — column `i`, row `j` — corresponds to the sample at the centre
347/// of cell `(i, j)`, where row `0` is at the top (`max_y` end) and row
348/// `height - 1` is at the bottom (`min_y` end). Pixels whose centres lie
349/// outside the convex hull of the input samples are written as `f32::NAN`.
350///
351/// Returns an all-NaN buffer for degenerate dimensions (zero width/height or
352/// inverted bounds).
353pub fn rasterize_tin(
354    tin: &Tin,
355    min_x: f64,
356    min_y: f64,
357    max_x: f64,
358    max_y: f64,
359    width: usize,
360    height: usize,
361    method: TinInterpMethod,
362) -> Vec<f32> {
363    let mut out = vec![f32::NAN; width * height];
364    if width == 0 || height == 0 || max_x <= min_x || max_y <= min_y {
365        return out;
366    }
367
368    let dx = (max_x - min_x) / width as f64;
369    let dy = (max_y - min_y) / height as f64;
370
371    for j in 0..height {
372        // Image-space convention: row 0 is the top, mapped to max_y.
373        let qy = max_y - (j as f64 + 0.5) * dy;
374        for i in 0..width {
375            let qx = min_x + (i as f64 + 0.5) * dx;
376            let v = match method {
377                TinInterpMethod::Idw { power } => interpolate_idw_tin(tin, qx, qy, power),
378                TinInterpMethod::NaturalNeighbor => interpolate_natural_neighbor(tin, qx, qy),
379            };
380            if let Some(z) = v {
381                out[j * width + i] = z as f32;
382            }
383        }
384    }
385
386    out
387}
388
389// ---------------------------------------------------------------------------
390// Unit tests
391// ---------------------------------------------------------------------------
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn sample_triangle() -> Tin {
398        let pts = vec![
399            TinPoint::new(0.0, 0.0, 0.0),
400            TinPoint::new(1.0, 0.0, 10.0),
401            TinPoint::new(0.5, 1.0, 5.0),
402        ];
403        build_tin(&pts).expect("build TIN")
404    }
405
406    #[test]
407    fn unit_build_tin_three_points() {
408        let tin = sample_triangle();
409        assert_eq!(tin.triangle_count(), 1);
410        assert_eq!(tin.point_count(), 3);
411    }
412
413    #[test]
414    fn unit_build_tin_too_few() {
415        let tin = build_tin(&[]).expect("empty ok");
416        assert!(tin.is_empty());
417        let tin = build_tin(&[TinPoint::new(0.0, 0.0, 1.0)]).expect("single ok");
418        assert!(tin.is_empty());
419        let tin = build_tin(&[TinPoint::new(0.0, 0.0, 1.0), TinPoint::new(1.0, 0.0, 2.0)])
420            .expect("pair ok");
421        assert!(tin.is_empty());
422    }
423
424    #[test]
425    fn unit_idw_at_vertex() {
426        let tin = sample_triangle();
427        let z = interpolate_idw_tin(&tin, 0.0, 0.0, 2.0).expect("inside hull");
428        assert!((z - 0.0).abs() < 1e-9);
429    }
430
431    #[test]
432    fn unit_natural_neighbor_at_vertex() {
433        let tin = sample_triangle();
434        let z = interpolate_natural_neighbor(&tin, 1.0, 0.0).expect("inside hull");
435        assert!((z - 10.0).abs() < 1e-9);
436    }
437
438    #[test]
439    fn unit_outside_hull_returns_none() {
440        let tin = sample_triangle();
441        assert!(interpolate_idw_tin(&tin, 10.0, 10.0, 2.0).is_none());
442        assert!(interpolate_natural_neighbor(&tin, 10.0, 10.0).is_none());
443    }
444
445    #[test]
446    fn unit_bounding_box() {
447        let tin = sample_triangle();
448        let bbox = tin.bounding_box().expect("non-empty");
449        assert!((bbox.0 - 0.0).abs() < 1e-12);
450        assert!((bbox.1 - 0.0).abs() < 1e-12);
451        assert!((bbox.2 - 1.0).abs() < 1e-12);
452        assert!((bbox.3 - 1.0).abs() < 1e-12);
453    }
454
455    #[test]
456    fn unit_rasterize_idw_dims() {
457        let tin = sample_triangle();
458        let grid = rasterize_tin(
459            &tin,
460            0.0,
461            0.0,
462            1.0,
463            1.0,
464            8,
465            6,
466            TinInterpMethod::Idw { power: 2.0 },
467        );
468        assert_eq!(grid.len(), 8 * 6);
469    }
470
471    #[test]
472    fn unit_rasterize_natural_neighbor_dims() {
473        let tin = sample_triangle();
474        let grid = rasterize_tin(
475            &tin,
476            0.0,
477            0.0,
478            1.0,
479            1.0,
480            4,
481            4,
482            TinInterpMethod::NaturalNeighbor,
483        );
484        assert_eq!(grid.len(), 16);
485    }
486
487    #[test]
488    fn unit_rasterize_degenerate_returns_all_nan() {
489        let tin = sample_triangle();
490        let grid = rasterize_tin(
491            &tin,
492            1.0,
493            1.0,
494            0.0,
495            0.0,
496            4,
497            4,
498            TinInterpMethod::NaturalNeighbor,
499        );
500        assert_eq!(grid.len(), 16);
501        assert!(grid.iter().all(|v| v.is_nan()));
502    }
503}