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}