diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c1a33..d6e6518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2.5.0] - yyyy-mm-dd + +### Added + - Implements natural neighbor interpolation. See type `NaturalNeighbor` for more on this! +### Fix + - Fixes `PointProjection::reversed()` - the method would return a projection that gives sometimes incorrect results. + ## [2.4.1] - 2023-12-01 ### Fix diff --git a/Cargo.toml b/Cargo.toml index 863b1d0..9b4274f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,11 @@ rand = "0.8.3" cgmath = "0.18.0" svg = "0.14.0" float_next_after = "1" +image = "0.24.7" +tiny-skia = "0.11.3" criterion = { version = "0.5.1", features = ["html_reports"] } - +base64 = "0.21.5" +anyhow = "1.0.75" [[bench]] name = "benchmarks" diff --git a/README.md b/README.md index 1146322..f39fc3d 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,15 @@ Delaunay triangulations for the rust ecosystem. -- 2D [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation), optionally backed by a hierarchy - structure for improved nearest neighbor and insertion performance. -- Allows both incremental and bulk loading creation of triangulations -- Support for vertex removal -- 2D constrained Delaunay triangulation (CDT) -- [Delaunay refinement](https://en.wikipedia.org/wiki/Delaunay_refinement) -- Uses precise geometric predicates to prevent incorrect geometries due to rounding issues -- Supports extracting the Voronoi diagram + - 2D [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation), optionally backed + by a hierarchy structure for improved nearest neighbor and insertion performance. + - Allows both incremental and bulk loading creation of triangulations + - Support for vertex removal + - 2D constrained Delaunay triangulation (CDT) + - [Delaunay refinement](https://en.wikipedia.org/wiki/Delaunay_refinement) + - Uses precise geometric predicates to prevent incorrect geometries due to rounding issues + - Supports extracting the Voronoi diagram + - Natural neighbor interpolation --------------------------- @@ -31,19 +32,10 @@ Project goals, in the order of their importance: # Roadmap -For Spade 2.x: - - Add back the removed interpolation methods (natural neighbor interpolation, #67) - For Spade 3: - - Possibly base `spade` on `nalgebra` as underlying vector and matrix library. Not much else planned yet! - -# Project state and contributing - -Looking for co-maintainers! Projects with just a single maintainer can be a little unreliable due to the single point of failure. I would love to see this burden being distributed on more shoulders! This is less about *implementing* things but rather about general maintenance tasks, e.g. package updates, minor bug fixes, reviewing PRs, etc... - -If you want to contribute, please consider opening an issue first. I'm happy to help out with any questions! + - Possibly API simplification by un-supporting non-f64 outputs. -# Performance and comparison to other Delaunay crates +# Performance and comparison to other crates Refer to [the delaunay_compare readme](./delaunay_compare/README.md) for some benchmarks and a comparison with other crates. diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index 4b11b85..707cc13 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -5,15 +5,17 @@ use criterion::*; mod benchmark_utilities; mod bulk_load_benchmark; mod bulk_load_vs_incremental; +mod interpolation_benchmark; mod locate_benchmark; criterion_group! { name = benches; config = Criterion::default().sample_size(50).warm_up_time(Duration::from_secs(1)); targets = - locate_benchmark::locate_benchmark, bulk_load_benchmark::bulk_load_benchmark, bulk_load_vs_incremental::bulk_load_vs_incremental_benchmark, + interpolation_benchmark::interpolation_benchmark, + locate_benchmark::locate_benchmark, } criterion_main!(benches); diff --git a/benches/interpolation_benchmark.rs b/benches/interpolation_benchmark.rs new file mode 100644 index 0000000..a352ab8 --- /dev/null +++ b/benches/interpolation_benchmark.rs @@ -0,0 +1,47 @@ +use criterion::*; +use spade::Triangulation; +use spade::{DelaunayTriangulation, FloatTriangulation, Point2}; + +use crate::benchmark_utilities::*; + +pub fn interpolation_benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("interpolation benchmarks"); + + let points = uniform_distribution(*SEED, 1.0) + .take(50) + .collect::>(); + let triangulation = DelaunayTriangulation::<_>::bulk_load(points).unwrap(); + + let query_point = Point2::new(0.5, -0.2); + + group.bench_function("nearest neighbor interpolation", |b| { + b.iter(|| { + triangulation + .nearest_neighbor(query_point) + .unwrap() + .position() + }); + }); + + let barycentric = triangulation.barycentric(); + group.bench_function("barycentric interpolation", |b| { + b.iter(|| barycentric.interpolate(|v| v.position().x + v.position().y, query_point)); + }); + + let natural_neighbor = &triangulation.natural_neighbor(); + group.bench_function("natural neighbor interpolation (c0)", |b| { + b.iter(|| natural_neighbor.interpolate(|v| v.position().x + v.position().y, query_point)); + }); + + group.bench_function("natural neighbor interpolation (c1)", |b| { + b.iter(|| { + natural_neighbor.interpolate_gradient( + |v| v.position().x + v.position().y, + |_| [0.0, 0.0], + 0.5, + query_point, + ) + }); + }); + group.finish(); +} diff --git a/examples/interpolation/main.rs b/examples/interpolation/main.rs new file mode 100644 index 0000000..1fc9526 --- /dev/null +++ b/examples/interpolation/main.rs @@ -0,0 +1,214 @@ +use anyhow::*; +use image::{ImageBuffer, Rgba}; +use std::io::{Cursor, Write}; + +use base64::Engine; +use tiny_skia::*; + +use spade::{DelaunayTriangulation, FloatTriangulation, HasPosition, Point2, Triangulation}; + +#[derive(Debug, Copy, Clone)] +struct PointWithHeight { + position: Point2, + height: f64, +} + +impl PointWithHeight { + const fn new(x: f64, y: f64, height: f64) -> Self { + Self { + position: Point2::new(x, y), + height, + } + } +} + +impl HasPosition for PointWithHeight { + type Scalar = f64; + + fn position(&self) -> Point2 { + self.position + } +} + +type TriangulationType = DelaunayTriangulation; + +const VERTICES: &[PointWithHeight] = &[ + PointWithHeight::new(20.0, 20.0, 0.9), + PointWithHeight::new(20.0, -20.0, 0.9), + PointWithHeight::new(-20.0, 20.0, 0.9), + PointWithHeight::new(-20.0, -20.0, 0.9), + PointWithHeight::new(20.0, 10.0, 0.0), + PointWithHeight::new(10.0, 10.0, 0.5), + PointWithHeight::new(0.0, 23.0, 0.1), + PointWithHeight::new(0.0, -23.0, 0.1), + PointWithHeight::new(-10.0, 10.0, 0.0), + PointWithHeight::new(-10.0, -10.0, 0.1), + PointWithHeight::new(-15.0, 20.0, 0.5), + PointWithHeight::new(-20.0, 20.0, 0.0), + PointWithHeight::new(-5.0, 0.0, 0.25), + PointWithHeight::new(5.0, 7.0, 0.75), + PointWithHeight::new(12.0, -10.0, 0.4), + PointWithHeight::new(5.0, 10.0, 0.3), + PointWithHeight::new(5.0, 1.0, 0.2), +]; + +pub fn main() -> anyhow::Result<()> { + let mut t = TriangulationType::default(); + for vertex in VERTICES { + t.insert(*vertex)?; + } + + const OFFSET: f32 = 25.0; + const SCALAR: f32 = 50.0 / 512.0; + + fn px_to_coords(x: u32, y: u32) -> Point2 { + Point2::new( + x as f64 * SCALAR as f64 - OFFSET as f64, + y as f64 * SCALAR as f64 - OFFSET as f64, + ) + } + + let dimensions = 512; + let mut nn_c0_pixmap = + Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?; + let mut nn_c1_pixmap = + Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?; + let mut barycentric_pixmap = + Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?; + let mut nearest_neighbor_pixmap = + Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?; + + fn set_pixel(pixmap: &mut Pixmap, x: u32, y: u32, value: Option) { + let background_color = [255, 255, 255]; + let [r, g, b] = value.map(float_to_color).unwrap_or(background_color); + let base = (y * pixmap.height() + x) as usize * 4; + pixmap.data_mut()[base] = r; + pixmap.data_mut()[base + 1] = g; + pixmap.data_mut()[base + 2] = b; + // Alpha + pixmap.data_mut()[base + 3] = 255; + } + + let nn = t.natural_neighbor(); + let barycentric = t.barycentric(); + + for y in 0..dimensions { + for x in 0..dimensions { + let coords = px_to_coords(x, y); + let value_nn_c0 = nn.interpolate(|v| v.data().height, coords); + set_pixel(&mut nn_c0_pixmap, x, y, value_nn_c0); + + let value_nn_c1 = + nn.interpolate_gradient(|v| v.data().height, |_| [0.0, 0.0], 1.0, coords); + set_pixel(&mut nn_c1_pixmap, x, y, value_nn_c1); + + let value_barycentric = barycentric.interpolate(|v| v.data().height, coords); + set_pixel(&mut barycentric_pixmap, x, y, value_barycentric); + + let value_nearest_neighbor = t.nearest_neighbor(coords).map(|v| v.data().height); + set_pixel(&mut nearest_neighbor_pixmap, x, y, value_nearest_neighbor); + } + } + + let path = { + let mut pb = PathBuilder::new(); + + for edge in t.undirected_edges() { + let [from, to] = edge.positions(); + pb.move_to(from.x as f32, from.y as f32); + pb.line_to(to.x as f32, to.y as f32); + } + + pb.finish().context("Could not build path")? + }; + + let stroke = Stroke { + width: 0.15, + ..Default::default() + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(0, 0, 0, 70); + paint.anti_alias = true; + + for pixmap in [ + &mut nn_c0_pixmap, + &mut nn_c1_pixmap, + &mut barycentric_pixmap, + &mut nearest_neighbor_pixmap, + ] { + pixmap.stroke_path( + &path, + &paint, + &stroke, + Transform::from_translate(OFFSET, OFFSET).post_scale(1.0 / SCALAR, 1.0 / SCALAR), + None, + ); + } + + fn save_pixmap(pixmap: Pixmap, name: &str) -> anyhow::Result<()> { + // tiny_skia doesn't support jpg encoding which is required for small file size when embedding this into + // the documentation. We'll have to convert the data into ImageBuffer from the image crate and then do + // the jpeg encoding. + let (width, height) = (pixmap.width(), pixmap.height()); + let data = pixmap.take(); + let buffer = ImageBuffer::, _>::from_vec(width, height, data) + .context("Failed to convert to ImageBuffer")?; + + let mut data_jpeg: Cursor> = Cursor::new(Vec::new()); + buffer.write_to(&mut data_jpeg, image::ImageFormat::Jpeg)?; + + std::fs::write(format!("images/{}.jpg", name), data_jpeg.get_ref())?; + + // Encode image as tag for inclusion into the documentation + let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(data_jpeg.get_ref()); + let mut file = std::fs::File::create(format!("images/{}.img", name))?; + write!(file, r#""#, encoded)?; + Ok(()) + } + + save_pixmap(nn_c0_pixmap, "interpolation_nn_c0")?; + save_pixmap(nn_c1_pixmap, "interpolation_nn_c1")?; + save_pixmap(barycentric_pixmap, "interpolation_barycentric")?; + save_pixmap(nearest_neighbor_pixmap, "interpolation_nearest_neighbor")?; + + Ok(()) +} + +fn float_to_color(value: f64) -> [u8; 3] { + // mostly AI generated.. + // Converts a hue value in the range 0.0 ..= 1.0 from HLS to RGB + let value = value.clamp(0.0, 1.0); + + const LIGHTNESS: f64 = 0.45; + const SATURATION: f64 = 0.55; + + let c = (1.0 - (2.0 * LIGHTNESS - 1.0).abs()) * SATURATION; + + let hue = value * 360.0; + let hs = hue / 60.0; + + let x = c * (1.0 - (hs % 2.0 - 1.0).abs()); + let m = LIGHTNESS - c * 0.5; + + let (r, g, b) = if (0.0..60.0).contains(&hue) { + (c, x, 0.0) + } else if (60.0..120.0).contains(&hue) { + (x, c, 0.0) + } else if (120.0..180.0).contains(&hue) { + (0.0, c, x) + } else if (180.0..240.0).contains(&hue) { + (0.0, x, c) + } else if (240.0..300.0).contains(&hue) { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + + // Convert RGB to 8-bit values + let r = ((r + m) * 255.0) as u8; + let g = ((g + m) * 255.0) as u8; + let b = ((b + m) * 255.0) as u8; + + [r, g, b] +} diff --git a/examples/svg_renderer/main.rs b/examples/svg_renderer/main.rs index 2b8a8db..febb032 100644 --- a/examples/svg_renderer/main.rs +++ b/examples/svg_renderer/main.rs @@ -1,12 +1,26 @@ pub mod quicksketch; mod scenario; mod scenario_list; - -type Result = core::result::Result<(), Box>; +use anyhow::Result; /// Used for rendering SVGs for documentation. These are inlined (via #[doc = include_str!(...)]) /// into the doc comment of a few items. That makes sure they will be visible even for offline users. -fn main() -> Result { +fn main() -> Result<()> { + scenario_list::natural_neighbor_area_scenario(false)?.save_to_svg( + "natural_neighbor_insertion_cell", + "images/natural_neighbor_insertion_cell.svg", + )?; + + scenario_list::natural_neighbor_area_scenario(true)?.save_to_svg( + "natural_neighbor_polygon", + "images/natural_neighbor_polygon.svg", + )?; + + scenario_list::natural_neighbors_scenario().save_to_svg( + "natural_neighbor_scenario", + "images/natural_neighbor_scenario.svg", + )?; + scenario_list::refinement_maximum_area_scenario(None).save_to_svg( "refinement_maximum_area_no_limit", "images/refinement_maximum_area_no_limit.svg", diff --git a/examples/svg_renderer/quicksketch/mod.rs b/examples/svg_renderer/quicksketch/mod.rs index 1909ca4..dd86461 100644 --- a/examples/svg_renderer/quicksketch/mod.rs +++ b/examples/svg_renderer/quicksketch/mod.rs @@ -125,6 +125,7 @@ pub struct Style { stroke_color: Option, stroke_style: Option, fill: Option, + opacity: Option, marker_start: Option, marker_end: Option, } @@ -144,6 +145,11 @@ impl Style { .as_ref() .map(|color| format!("stroke: {}", color)); + let opacity = self + .opacity + .as_ref() + .map(|opacity| format!("opacity: {:.2}", opacity)); + let fill = format!( "fill: {}", self.fill @@ -175,6 +181,7 @@ impl Style { stroke_color, marker_start, marker_end, + opacity, stroke_dash_array, ]) .flatten() @@ -356,6 +363,11 @@ impl SketchPath { self } + pub fn opacity(mut self, opacity: f64) -> Self { + self.style.opacity = Some(opacity); + self + } + pub fn fill(mut self, fill: SketchFill) -> Self { self.style.fill = Some(fill); self diff --git a/examples/svg_renderer/scenario.rs b/examples/svg_renderer/scenario.rs index c7e8b51..5791676 100644 --- a/examples/svg_renderer/scenario.rs +++ b/examples/svg_renderer/scenario.rs @@ -3,9 +3,10 @@ use spade::{ConstrainedDelaunayTriangulation, DelaunayTriangulation, HasPosition use crate::convert_point; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq, Debug)] pub struct VertexType { position: Point, + pub color: Option, pub radius: f64, } @@ -21,6 +22,7 @@ impl VertexType { Self { position: Point::new(x, y), + color: None, radius: DEFAULT_CIRCLE_RADIUS, } } @@ -34,6 +36,7 @@ impl HasPosition for VertexType { } } +#[derive(Clone, Copy, Debug)] pub struct UndirectedEdgeType { pub color: SketchColor, } @@ -52,7 +55,7 @@ impl Default for UndirectedEdgeType { } } -#[derive(Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct DirectedEdgeType {} #[derive(Clone, Copy, Debug)] @@ -166,7 +169,9 @@ where for vertex in triangulation.vertices() { sketch.add_with_layer( SketchElement::circle(convert_point(vertex.position()), vertex.data().radius) - .fill(SketchFill::solid(options.vertex_color)) + .fill(SketchFill::solid( + vertex.data().color.unwrap_or(options.vertex_color), + )) .stroke_width(0.5) .stroke_color(options.vertex_stroke_color), crate::quicksketch::SketchLayer::VERTICES, diff --git a/examples/svg_renderer/scenario_list.rs b/examples/svg_renderer/scenario_list.rs index 042848f..55ea56b 100644 --- a/examples/svg_renderer/scenario_list.rs +++ b/examples/svg_renderer/scenario_list.rs @@ -3,13 +3,17 @@ use super::quicksketch::{ SketchLayer, StrokeStyle, Vector, }; +use anyhow::{Context, Result}; + use cgmath::{Angle, Bounded, Deg, EuclideanSpace, InnerSpace, Point2, Vector2}; + use spade::{ handles::{ FixedDirectedEdgeHandle, VoronoiVertex::{self, Inner, Outer}, }, - AngleLimit, FloatTriangulation as _, InsertionError, RefinementParameters, Triangulation as _, + AngleLimit, FloatTriangulation as _, HasPosition, InsertionError, RefinementParameters, + Triangulation as _, }; use crate::{ @@ -974,6 +978,254 @@ pub fn shape_iterator_scenario(use_circle_metric: bool, iterate_vertices: bool) result } +pub fn natural_neighbors_scenario() -> Sketch { + let triangulation = big_triangulation().unwrap(); + + let nn = triangulation.natural_neighbor(); + + let mut nns = Vec::new(); + let query_point = spade::Point2::new(-1.0, 4.0); + nn.get_weights(query_point, &mut nns); + + let mut result = convert_triangulation(&triangulation, &Default::default()); + + let offsets = [ + Vector2::new(2.0, 5.0), + Vector2::new(-4.0, 6.0), + Vector2::new(0.0, 6.0), + Vector2::new(1.0, 6.0), + Vector2::new(0.0, 8.0), + Vector2::new(3.0, 4.0), + Vector2::new(5.0, 2.0), + ]; + + for (index, (neighbor, weight)) in nns.iter().enumerate() { + let neighbor = triangulation.vertex(*neighbor); + let position = convert_point(neighbor.position()); + result.add( + SketchElement::circle(position, 2.0) + .fill(SketchFill::Solid(SketchColor::SALMON)) + .stroke_width(0.5) + .stroke_color(SketchColor::BLACK), + ); + result.add( + SketchElement::text(index.to_string()) + .position(position) + .font_size(3.0) + .horizontal_alignment(HorizontalAlignment::Middle) + .dy(1.15), + ); + + result.add( + SketchElement::text(format!("{:.2}", weight)) + .position(position + offsets[index]) + .font_size(5.0), + ); + } + + result.add( + SketchElement::circle(convert_point(query_point), 1.5) + .fill(SketchFill::Solid(SketchColor::ROYAL_BLUE)) + .stroke_width(0.2) + .stroke_color(SketchColor::BLACK), + ); + + result.set_view_box_min(Point2::new(-40.0, -30.0)); + result.set_view_box_max(Point2::new(30.0, 45.0)); + result.set_width(400); + result +} + +/// Only used for internal documentation of natural neighbor area calculation +pub fn natural_neighbor_area_scenario(include_faces: bool) -> Result { + let mut triangulation: Triangulation = Default::default(); + + triangulation.insert(VertexType::new(45.0, 30.0))?; + triangulation.insert(VertexType::new(7.5, 40.0))?; + triangulation.insert(VertexType::new(-45.0, 42.0))?; + triangulation.insert(VertexType::new(-55.0, 0.0))?; + triangulation.insert(VertexType::new(-32.0, -42.0))?; + triangulation.insert(VertexType::new(-2.0, -32.0))?; + triangulation.insert(VertexType::new(25.0, -32.0))?; + + triangulation.insert(VertexType::new(70.0, 40.0))?; + triangulation.insert(VertexType::new(20.0, 65.0))?; + triangulation.insert(VertexType::new(-60.0, 50.0))?; + triangulation.insert(VertexType::new(-70.0, -15.5))?; + triangulation.insert(VertexType::new(-50.0, -60.0))?; + triangulation.insert(VertexType::new(-9.0, -70.0))?; + triangulation.insert(VertexType::new(42.0, -50.0))?; + + for edge in triangulation.fixed_undirected_edges() { + triangulation.undirected_edge_data_mut(edge).color = SketchColor::LIGHT_GRAY; + } + + for face in triangulation.fixed_inner_faces() { + triangulation.face_data_mut(face).fill = SketchFill::solid(SketchColor::ANTIQUE_WHITE); + } + + let query_vertex = VertexType::new(-5.0, -5.0); + let mut nns = Vec::new(); + triangulation + .natural_neighbor() + .get_weights(query_vertex.position(), &mut nns); + + for (vertex, _) in &nns { + triangulation.vertex_data_mut(*vertex).color = Some(SketchColor::CRIMSON); + } + + let mut result = convert_triangulation(&triangulation, &Default::default()); + + for (index, (vertex, _)) in nns.iter().enumerate() { + let vertex = triangulation.vertex(*vertex); + result.add( + SketchElement::text(format!("{index}")) + .position(convert_point(vertex.position())) + .font_size(2.5) + .horizontal_alignment(HorizontalAlignment::Middle) + .dy(0.9), + ); + } + + for edge in triangulation.undirected_voronoi_edges() { + let [v0, v1] = edge.vertices(); + + if let (Some(v0), Some(v1)) = (v0.position(), v1.position()) { + result.add( + SketchElement::line(convert_point(v0), convert_point(v1)) + .stroke_color(SketchColor::SALMON) + .stroke_width(0.5), + ); + } + } + + let mut circumcenters = Vec::new(); + for face in triangulation.inner_faces() { + let circumcenter = convert_point(face.circumcenter()); + circumcenters.push(circumcenter); + + result.add( + SketchElement::circle(circumcenter, 1.0) + .fill(SketchFill::Solid(SketchColor::ROYAL_BLUE)), + ); + } + + if !include_faces { + result.add( + SketchElement::circle(convert_point(query_vertex.position()), 1.0) + .fill(SketchFill::Solid(SketchColor::RED)), + ); + } + + let mut insertion_cell_points = Vec::new(); + let inserted = triangulation.insert(query_vertex)?; + for edge in triangulation + .vertex(inserted) + .as_voronoi_face() + .adjacent_edges() + { + let context = "Edge was infinite - is insertion position correct?"; + let from = edge.from().position().context(context)?; + let to = edge.to().position().context(context)?; + insertion_cell_points.push(convert_point(from)); + + result.add( + SketchElement::line(convert_point(from), convert_point(to)) + .stroke_color(SketchColor::ORANGE_RED), + ); + } + for cell_point in &insertion_cell_points { + result.add( + SketchElement::circle(*cell_point, 1.0) + .fill(SketchFill::solid(SketchColor::DARK_GREEN)), + ); + } + + if include_faces { + let nn3 = convert_point(triangulation.vertex(nns[3].0).position()); + let nn4 = convert_point(triangulation.vertex(nns[4].0).position()); + let nn5 = convert_point(triangulation.vertex(nns[5].0).position()); + + let last_edge = SketchElement::line(nn3, nn4) + .with_arrow_end(ArrowType::FilledArrow) + .stroke_color(SketchColor::ROYAL_BLUE) + .shift_from(-2.3) + .shift_to(-7.0); + + result.add( + last_edge + .create_adjacent_text("last_edge") + .font_size(3.0) + .dy(3.5), + ); + result.add(last_edge); + let stop_edge = SketchElement::line(nn4, nn5) + .with_arrow_end(ArrowType::FilledArrow) + .stroke_color(SketchColor::ROYAL_BLUE) + .shift_from(-2.3) + .shift_to(-7.0); + result.add( + stop_edge + .create_adjacent_text("stop_edge") + .font_size(3.0) + .dy(3.5), + ); + result.add(stop_edge); + + result.add( + SketchElement::text("first") + .position(insertion_cell_points[6] + Vector2::new(0.0, 0.0)) + .dy(3.3) + .font_size(2.5), + ); + result.add( + SketchElement::text("last") + .position(insertion_cell_points[0] + Vector2::new(-5.5, 0.0)) + .dy(3.0) + .font_size(2.5), + ); + + result.add( + SketchElement::text("c2") + .position(circumcenters[4] + Vector2::new(-3.5, 0.0)) + .dy(-1.0) + .font_size(2.5), + ); + + result.add( + SketchElement::text("c1") + .position(circumcenters[2]) + .dy(-2.0) + .font_size(2.5), + ); + + result.add( + SketchElement::text("c0") + .position(circumcenters[3] + Vector2::new(1.5, 0.0)) + .dy(0.5) + .font_size(2.5), + ); + + let path = SketchElement::path() + .move_to(insertion_cell_points[6]) + .line_to(insertion_cell_points[0]) + .line_to(circumcenters[4]) + .line_to(circumcenters[2]) + .line_to(circumcenters[3]) + .close() + .fill(SketchFill::solid(SketchColor::ORANGE)) + .opacity(0.75); + + result.add_with_layer(path, SketchLayer::EDGES); + } + + result + .set_view_box_min(Point2::new(-60.0, -45.0)) + .set_view_box_max(Point2::new(45.0, 60.0)); + + Ok(result) +} + pub fn refinement_scenario(do_refine: bool) -> Sketch { let mut cdt = create_refinement_cdt(); diff --git a/images/grid_3d.svg b/images/grid_3d.svg new file mode 100644 index 0000000..cfcfbe6 --- /dev/null +++ b/images/grid_3d.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/images/interpolation_barycentric.img b/images/interpolation_barycentric.img new file mode 100644 index 0000000..91a7dab --- /dev/null +++ b/images/interpolation_barycentric.img @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/interpolation_barycentric.jpg b/images/interpolation_barycentric.jpg new file mode 100644 index 0000000..ea36e85 Binary files /dev/null and b/images/interpolation_barycentric.jpg differ diff --git a/images/interpolation_nearest_neighbor.img b/images/interpolation_nearest_neighbor.img new file mode 100644 index 0000000..aa91cc0 --- /dev/null +++ b/images/interpolation_nearest_neighbor.img @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/interpolation_nearest_neighbor.jpg b/images/interpolation_nearest_neighbor.jpg new file mode 100644 index 0000000..8a935bf Binary files /dev/null and b/images/interpolation_nearest_neighbor.jpg differ diff --git a/images/interpolation_nn_c0.img b/images/interpolation_nn_c0.img new file mode 100644 index 0000000..9412ea4 --- /dev/null +++ b/images/interpolation_nn_c0.img @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/interpolation_nn_c0.jpg b/images/interpolation_nn_c0.jpg new file mode 100644 index 0000000..1541960 Binary files /dev/null and b/images/interpolation_nn_c0.jpg differ diff --git a/images/interpolation_nn_c1.img b/images/interpolation_nn_c1.img new file mode 100644 index 0000000..0c16345 --- /dev/null +++ b/images/interpolation_nn_c1.img @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/interpolation_nn_c1.jpg b/images/interpolation_nn_c1.jpg new file mode 100644 index 0000000..bb196da Binary files /dev/null and b/images/interpolation_nn_c1.jpg differ diff --git a/images/natural_neighbor_insertion_cell.svg b/images/natural_neighbor_insertion_cell.svg new file mode 100644 index 0000000..caf83c6 --- /dev/null +++ b/images/natural_neighbor_insertion_cell.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +1 + + +2 + + +3 + + +4 + + +5 + + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/natural_neighbor_polygon.svg b/images/natural_neighbor_polygon.svg new file mode 100644 index 0000000..a31d799 --- /dev/null +++ b/images/natural_neighbor_polygon.svg @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +1 + + +2 + + +3 + + +4 + + +5 + + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +last_edge + + + +stop_edge + + + +first + + +last + + +c2 + + +c1 + + +c0 + + + \ No newline at end of file diff --git a/images/natural_neighbor_scenario.svg b/images/natural_neighbor_scenario.svg new file mode 100644 index 0000000..3aa4db6 --- /dev/null +++ b/images/natural_neighbor_scenario.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0.27 + + + +1 + + +0.48 + + + +2 + + +0.01 + + + +3 + + +0.04 + + + +4 + + +0.19 + + + +5 + + +0.00 + + + +6 + + +0.01 + + + + \ No newline at end of file diff --git a/images/preview.html b/images/preview.html index 0097e83..a7da86c 100644 --- a/images/preview.html +++ b/images/preview.html @@ -23,7 +23,7 @@ } - + \ No newline at end of file diff --git a/src/delaunay_core/bulk_load.rs b/src/delaunay_core/bulk_load.rs index ad60be9..63d4f0a 100644 --- a/src/delaunay_core/bulk_load.rs +++ b/src/delaunay_core/bulk_load.rs @@ -33,9 +33,9 @@ impl Eq for FloatOrd {} /// Advances in Engineering Software, /// Volume 43, Issue 1, /// 2012, -/// https://doi.org/10.1016/j.advengsoft.2011.09.003 +/// /// -/// Or alternatively: http://cglab.ca/~biniaz/papers/Sweep%20Circle.pdf +/// Or alternatively: /// /// # Overview /// diff --git a/src/delaunay_core/hint_generator.rs b/src/delaunay_core/hint_generator.rs index 412c598..07b4626 100644 --- a/src/delaunay_core/hint_generator.rs +++ b/src/delaunay_core/hint_generator.rs @@ -20,7 +20,7 @@ use alloc::vec::Vec; /// site. /// /// Hints can also be given manually by using the `...with_hint` methods (e.g. -/// [Triangulation::insert_with_hint](crate::Triangulation::insert_with_hint)) +/// [Triangulation::insert_with_hint]) /// /// Usually, you should not need to implement this trait. Spade currently implements two common hint generators that should /// fulfill most needs: diff --git a/src/delaunay_core/interpolation.rs b/src/delaunay_core/interpolation.rs new file mode 100644 index 0000000..d10f414 --- /dev/null +++ b/src/delaunay_core/interpolation.rs @@ -0,0 +1,895 @@ +use core::cell::RefCell; + +use crate::{ + delaunay_core::math, + handles::{FixedDirectedEdgeHandle, FixedVertexHandle}, + DelaunayTriangulation, HasPosition, HintGenerator, Point2, PositionInTriangulation, + Triangulation, +}; +use num_traits::{one, zero, Float}; + +use alloc::vec::Vec; + +use super::VertexHandle; + +/// Implements methods for natural neighbor interpolation. +/// +/// Natural neighbor interpolation is a spatial interpolation method. For a given set of 2D input points with an +/// associated value (e.g. a height field of some terrain or temperature measurements at different locations in +/// a country), natural neighbor interpolation allows to smoothly interpolate the associated value for every +/// location within the convex hull of the input points. +/// +/// Spade currently assists with 4 interpolation strategies: +/// - **Nearest neighbor interpolation:** Fastest. Exhibits too poor quality for many tasks. Not continuous +/// along the edges of the voronoi diagram. Use [DelaunayTriangulation::nearest_neighbor]. +/// - **Barycentric interpolation:** Fast. Not smooth on the edges of the Delaunay triangulation. +/// See [Barycentric]. +/// - **Natural neighbor interpolation:** Slower. Smooth everywhere except the input points. The input points +/// have no derivative. See [NaturalNeighbor::interpolate] +/// - **Natural neighbor interpolation with gradients:** Slowest. Smooth everywhere, even at the input points. +/// See [NaturalNeighbor::interpolate_gradient]. +/// +/// # Performance comparison +/// +/// In general, the speed of interpolating random points in a triangulation will be determined by the speed of the +/// lookup operation after a certain triangulation size is reached. Thus, if random sampling is required, using a +/// [crate::HierarchyHintGenerator] may be beneficial. +/// +/// If subsequent queries are close to each other, [crate::LastUsedVertexHintGenerator] should also work fine. +/// In this case, interpolating a single value should result in the following (relative) run times: +/// +/// | nearest neighbor | barycentric | natural neighbor (no gradients) | natural neighbor (gradients) | +/// | ---------------- | ----------- | ------------------------------- | ---------------------------- | +/// | 23ns | 103ns | 307ns | 422ns | +/// +/// # Usage +/// +/// This type is created by calling [DelaunayTriangulation::natural_neighbor]. It contains a few internal buffers +/// that are used to prevent recurring allocations. For best performance it should be created only once per thread +/// and then used in all interpolation activities (see example). +/// +/// # Example +/// ``` +/// use spade::{Point2, HasPosition, DelaunayTriangulation, InsertionError, Triangulation as _}; +/// +/// struct PointWithHeight { +/// position: Point2, +/// height: f64, +/// } +/// +/// impl HasPosition for PointWithHeight { +/// type Scalar = f64; +/// +/// fn position(&self) -> Point2 { +/// self.position +/// } +/// } +/// +/// fn main() -> Result<(), InsertionError> { +/// let mut t = DelaunayTriangulation::::new(); +/// t.insert(PointWithHeight { position: Point2::new(-1.0, -1.0), height: 42.0})?; +/// t.insert(PointWithHeight { position: Point2::new(-1.0, 1.0), height: 13.37})?; +/// t.insert(PointWithHeight { position: Point2::new(1.0, -1.0), height: 27.18})?; +/// t.insert(PointWithHeight { position: Point2::new(1.0, 1.0), height: 31.41})?; +/// +/// // Set of query points (would be many more realistically): +/// let query_points = [Point2::new(0.0, 0.1), Point2::new(0.5, 0.1)]; +/// +/// // Good: Re-use interpolation object! +/// let nn = t.natural_neighbor(); +/// for p in &query_points { +/// println!("Value at {:?}: {:?}", p, nn.interpolate(|v| v.data().height, *p)); +/// } +/// +/// // Bad (slower): Don't re-use internal buffers +/// for p in &query_points { +/// println!("Value at {:?}: {:?}", p, t.natural_neighbor().interpolate(|v| v.data().height, *p)); +/// } +/// Ok(()) +/// } +/// ``` +/// +/// # Visual comparison of interpolation algorithms +/// +/// *Note: All of these images are generated by the "interpolation" example* +/// +/// Nearest neighbor interpolation exhibits discontinuities along the voronoi edges: +/// +#[doc = include_str!("../../images/interpolation_nearest_neighbor.img")] +/// +/// Barycentric interpolation, by contrast, is continuous everywhere but has no derivative on the edges of the +/// Delaunay triangulation. These show up as sharp corners in the color gradients on the drawn edges: +/// +#[doc = include_str!("../../images/interpolation_barycentric.img")] +/// +/// By contrast, natural neighbor interpolation is smooth on the edges - the previously sharp angles are now rounded +/// off. However, the vertices themselves are still not continuous and will form sharp "peaks" in the resulting +/// surface. +/// +#[doc = include_str!("../../images/interpolation_nn_c0.img")] +/// +/// With a gradient, the sharp peaks are gone - the surface will smoothly approximate a linear function as defined +/// by the gradient in the vicinity of each vertex. In the image below, a gradient of `(0.0, 0.0)` is used which +/// leads to a small "disc" around each vertex with values close to the vertex value. +/// +#[doc = include_str!("../../images/interpolation_nn_c1.img")] +/// +#[doc(alias = "Interpolation")] +pub struct NaturalNeighbor<'a, T> +where + T: Triangulation, + T::Vertex: HasPosition, +{ + triangulation: &'a T, + // Various buffers that are used for identifying natural neighbors and their weights. It's significantly faster + // to clear and re-use these buffers instead of allocating them anew. + // We also don't run the risk of mutably borrowing them twice (causing a RefCell panic) as the RefCells never leak + // any of the interpolation methods. + // Not implementing `Sync` is also fine as the workaround - creating a new `NaturalNeighbor` instance per thread - + // is very simple. + inspect_edges_buffer: RefCell>, + natural_neighbor_buffer: RefCell>, + insert_cell_buffer: RefCell::Scalar>>>, + weight_buffer: RefCell::Scalar)>>, +} + +/// Implements methods related to barycentric interpolation. +/// +/// Created by calling [crate::FloatTriangulation::barycentric]. +/// +/// Refer to the documentation of [NaturalNeighbor] for an overview of different interpolation methods. +#[doc(alias = "Interpolation")] +pub struct Barycentric<'a, T> +where + T: Triangulation, +{ + triangulation: &'a T, + weight_buffer: RefCell::Scalar)>>, +} + +impl<'a, T> Barycentric<'a, T> +where + T: Triangulation, + ::Scalar: Float, +{ + pub(crate) fn new(triangulation: &'a T) -> Self { + Self { + triangulation, + weight_buffer: Default::default(), + } + } + + /// Returns the barycentric coordinates and the respective vertices for a given query position. + /// + /// The resulting coordinates and vertices are stored within the given `result` `vec`` to prevent + /// unneeded allocations. `result` will be cleared initially. + /// + /// The number of returned elements depends on the query positions location: + /// - `result` will be **empty** if the query position lies outside of the triangulation's convex hull + /// - `result` will contain **a single element** (with weight 1.0) if the query position lies exactly on a vertex + /// - `result` will contain **two vertices** if the query point lies exactly on any edge of the triangulation. + /// - `result` will contain **exactly three** elements if the query point lies on an inner face of the + /// triangulation. + pub fn get_weights( + &self, + position: Point2<::Scalar>, + result: &mut Vec<(FixedVertexHandle, ::Scalar)>, + ) { + result.clear(); + match self.triangulation.locate(position) { + PositionInTriangulation::OnVertex(vertex) => { + result.push((vertex, ::Scalar::from(1.0))) + } + PositionInTriangulation::OnEdge(edge) => { + let [v0, v1] = self.triangulation.directed_edge(edge).vertices(); + let [w0, w1] = two_point_interpolation::(v0, v1, position); + result.push((v0.fix(), w0)); + result.push((v1.fix(), w1)); + } + PositionInTriangulation::OnFace(face) => { + let face = self.triangulation.face(face); + let [v0, v1, v2] = face.vertices(); + let [c0, c1, c2] = face.barycentric_interpolation(position); + result.extend([(v0.fix(), c0), (v1.fix(), c1), (v2.fix(), c2)]); + } + _ => {} + } + } + + /// Performs barycentric interpolation on this triangulation at a given position. + /// + /// Returns `None` for any value outside the triangulation's convex hull. + /// The value to interpolate is given by the `i` parameter. + /// + /// Refer to [NaturalNeighbor] for a comparison with other interpolation methods. + pub fn interpolate( + &self, + i: I, + position: Point2<::Scalar>, + ) -> Option<::Scalar> + where + I: Fn( + VertexHandle, + ) -> ::Scalar, + { + let nns = &mut *self.weight_buffer.borrow_mut(); + self.get_weights(position, nns); + if nns.is_empty() { + return None; + } + + let mut total_sum = zero(); + for (vertex, weight) in nns { + total_sum = total_sum + i(self.triangulation.vertex(*vertex)) * *weight; + } + Some(total_sum) + } +} + +impl<'a, V, DE, UE, F, L> NaturalNeighbor<'a, DelaunayTriangulation> +where + V: HasPosition, + DE: Default, + UE: Default, + F: Default, + L: HintGenerator<::Scalar>, + ::Scalar: Float, +{ + pub(crate) fn new(triangulation: &'a DelaunayTriangulation) -> Self { + Self { + triangulation, + inspect_edges_buffer: Default::default(), + insert_cell_buffer: Default::default(), + natural_neighbor_buffer: Default::default(), + weight_buffer: Default::default(), + } + } + + /// Calculates the natural neighbors and their weights (sibson coordinates) of a given query position. + /// + /// The neighbors are returned in clockwise order. The weights will add up to 1.0. + /// The neighbors are stored in the `result` parameter to prevent unnecessary allocations. + /// `result` will be cleared initially. + /// + /// The number of returned natural neighbors depends on the given query position: + /// - `result` will be **empty** if the query position lies outside of the triangulation's convex hull + /// - `result` will contain **exactly one** vertex if the query position is equal to that vertex position. + /// - `result` will contain **exactly two** entries if the query position lies exactly *on* an edge of the + /// convex hull. + /// - `result` will contain **at least three** `(vertex, weight)` tuples if the query point lies on an inner + /// face or an inner edge. + /// + /// *Example: The natural neighbors (red vertices) of the query point (blue dot) with their weights. + /// The elements will be returned in clockwise order as indicated by the indices drawn within the red circles.* + /// + #[doc = include_str!("../../images/natural_neighbor_scenario.svg")] + pub fn get_weights( + &self, + position: Point2<::Scalar>, + result: &mut Vec<(FixedVertexHandle, ::Scalar)>, + ) { + let nns = &mut *self.natural_neighbor_buffer.borrow_mut(); + get_natural_neighbor_edges( + self.triangulation, + &mut self.inspect_edges_buffer.borrow_mut(), + position, + nns, + ); + self.get_natural_neighbor_weights(position, nns, result); + } + + /// Interpolates a value at a given position. + /// + /// Returns `None` for any point outside the triangulations convex hull. + /// The value to interpolate is given by the `i` parameter. The resulting interpolation will be smooth + /// everywhere except at the input vertices. + /// + /// Refer to [NaturalNeighbor] for an example on how to use this function. + pub fn interpolate( + &self, + i: I, + position: Point2<::Scalar>, + ) -> Option<::Scalar> + where + I: Fn(VertexHandle) -> ::Scalar, + { + let nns = &mut *self.weight_buffer.borrow_mut(); + self.get_weights(position, nns); + if nns.is_empty() { + return None; + } + + let mut total_sum = zero(); + for (vertex, weight) in nns { + total_sum = total_sum + i(self.triangulation.vertex(*vertex)) * *weight; + } + Some(total_sum) + } + + /// Interpolates a value at a given position. + /// + /// In contrast to [Self::interpolate], this method has a well defined derivative at each vertex and will + /// approximate a linear function in the proximity of any vertex. + /// + /// The value to interpolate is given by the `i` parameter. The gradient that defines the derivative at + /// each input vertex is given by the `g` parameter. + /// + /// The `flatness` parameter blends between an interpolation that ignores the given gradients (value 0.0) + /// or adheres to it strongly (values larger than ~2.0) in the vicinity of any vertex. When in doubt, using + /// a value of 1.0 should result in a good interpolation and is also the fastest. + /// + /// Returns `None` for any point outside of the triangulation's convex hull. + /// + /// Refer to [NaturalNeighbor] for more information and a visual example. + /// + /// # Example + /// + /// ``` + /// use spade::{DelaunayTriangulation, HasPosition, Point2}; + /// # use spade::Triangulation; + /// + /// struct PointWithHeight { + /// position: Point2, + /// height: f64, + /// } + /// + /// impl HasPosition for PointWithHeight { + /// type Scalar = f64; + /// fn position(&self) -> Point2 { self.position } + /// } + /// + /// let mut triangulation: DelaunayTriangulation = Default::default(); + /// // Insert some points into the triangulation + /// triangulation.insert(PointWithHeight { position: Point2::new(10.0, 10.0), height: 0.0 }); + /// triangulation.insert(PointWithHeight { position: Point2::new(10.0, -10.0), height: 0.0 }); + /// triangulation.insert(PointWithHeight { position: Point2::new(-10.0, 10.0), height: 0.0 }); + /// triangulation.insert(PointWithHeight { position: Point2::new(-10.0, -10.0), height: 0.0 }); + /// + /// let nn = triangulation.natural_neighbor(); + /// + /// // Interpolate point at coordinates (1.0, 2.0). This example uses a fixed gradient of (0.0, 0.0) which + /// // means that the interpolation will have normal vector parallel to the z-axis at each input point. + /// // Realistically, the gradient might be stored as an additional property of `PointWithHeight`. + /// let query_point = Point2::new(1.0, 2.0); + /// let value: f64 = nn.interpolate_gradient(|v| v.data().height, |_| [0.0, 0.0], 1.0, query_point).unwrap(); + /// ``` + /// + /// # References + /// + /// This method uses the C1 extension proposed by Sibson in + /// "A brief description of natural neighbor interpolation, R. Sibson, 1981" + pub fn interpolate_gradient( + &self, + i: I, + g: G, + flatness: ::Scalar, + position: Point2<::Scalar>, + ) -> Option<::Scalar> + where + I: Fn(VertexHandle) -> ::Scalar, + G: Fn(VertexHandle) -> [::Scalar; 2], + { + let nns = &mut *self.weight_buffer.borrow_mut(); + self.get_weights(position, nns); + if nns.is_empty() { + return None; + } + + // Variable names should make more sense after looking into the paper! + // Roughly speaking, this approach works by blending a smooth c1 approximation into the + // regular natural neighbor interpolation ("c0 contribution"). + // The c0 / c1 contributions are stored in sum_c0 / sum_c1 and are weighted by alpha and beta + // respectively. + let mut sum_c0 = zero(); + let mut sum_c1 = zero(); + let mut sum_c1_weights = zero(); + let mut alpha: ::Scalar = zero(); + let mut beta: ::Scalar = zero(); + + for (handle, weight) in nns { + let handle = self.triangulation.vertex(*handle); + let pos_i = handle.position(); + let h_i = i(handle); + let diff = pos_i.sub(position); + let r_i2 = diff.length2(); + let r_i = r_i2.powf(flatness); + let c1_weight_i = *weight / r_i; + let grad_i = g(handle); + let zeta_i = h_i + diff.dot(grad_i.into()); + alpha = alpha + c1_weight_i * r_i; + beta = beta + c1_weight_i * r_i2; + sum_c1_weights = sum_c1_weights + c1_weight_i; + sum_c1 = sum_c1 + zeta_i * c1_weight_i; + sum_c0 = sum_c0 + h_i * *weight; + } + alpha = alpha / sum_c1_weights; + sum_c1 = sum_c1 / sum_c1_weights; + let result = (alpha * sum_c0 + beta * sum_c1) / (alpha + beta); + + Some(result) + } + + /// Calculates the natural neighbor weights corresponding to a given position. + /// + /// The weight of a natural neighbor n is defined as the size of the intersection of two areas: + /// - The existing voronoi cell of the natural neighbor + /// - The cell that would be created if a vertex was created at the given position. + /// + /// The area of this intersection can, surprisingly, be calculated without doing the actual insertion. + /// + /// Parameters: + /// - position refers to the position for which the weights should be calculated + /// - nns: A list of edges that connect the natural neighbors (e.g. `nns[0].to() == nns[1].from()`). + /// - result: Stores the resulting NNs (as vertex handle) and their weights. + /// + /// # Visualization + /// + /// Refer to these two .svg files for more detail on how the algorithm works, either by looking them up + /// directly or by running `cargo doc --document-private-items` and looking at the documentation of this + /// function. + /// + /// ## Insertion cell + /// + /// This .svg displays the *insertion cell* (thick orange line) which is the voronoi cell that gets + /// created if the query point (red dot) would be inserted. + /// Each point of the insertion cell lies on a circumcenter of a triangle formed by `position` and two + /// adjacent natural neighbors (red dots with their index shown inside). + #[doc = include_str!("../../images/natural_neighbor_insertion_cell.svg")] + /// + /// ## Inner loop + /// + /// This .svg illustrates the inner loop of the algorithm (see code below). The goal is to calculate + /// the weight of natural neighbor with index 4 which is proportional to the area of the orange polygon. + /// `last_edge`, `stop_edge`, `first`, `c0`, `c1` `c2` and `last` refer to variable names (see code below). + #[doc = include_str!("../../images/natural_neighbor_polygon.svg")] + fn get_natural_neighbor_weights( + &self, + position: Point2<::Scalar>, + nns: &[FixedDirectedEdgeHandle], + result: &mut Vec<(FixedVertexHandle, ::Scalar)>, + ) { + result.clear(); + + if nns.is_empty() { + return; + } + + if nns.len() == 1 { + let edge = self.triangulation.directed_edge(nns[0]); + result.push((edge.from().fix(), one())); + return; + } + + if nns.len() == 2 { + let [e0, e1] = [ + self.triangulation.directed_edge(nns[0]), + self.triangulation.directed_edge(nns[1]), + ]; + let [v0, v1] = [e0.from(), e1.from()]; + let [w0, w1] = + two_point_interpolation::>(v0, v1, position); + + result.push((v0.fix(), w0)); + result.push((v1.fix(), w1)); + return; + } + + // Get insertion cell vertices. The "insertion cell" refers to the voronoi cell that would be + // created if "position" would be inserted. + // These insertion cells happen to lie on the circumcenter of `position` and any two adjacent + // natural neighbors (e.g. [position, nn[2].position(), nn[3].position()]). + // + // `images/natural_neighbor_insertion_cell.svg` depicts the cell as thick orange line. + let mut insertion_cell = self.insert_cell_buffer.borrow_mut(); + insertion_cell.clear(); + for cur_nn in nns { + let cur_nn = self.triangulation.directed_edge(*cur_nn); + + let [from, to] = cur_nn.positions(); + insertion_cell.push(math::circumcenter([to, from, position]).0); + } + + let mut total_area = zero(); // Used to normalize weights at the end + + let mut last_edge = self.triangulation.directed_edge(*nns.last().unwrap()); + let mut last = *insertion_cell.last().unwrap(); + + for (stop_edge, first) in core::iter::zip(nns.iter(), &*insertion_cell) { + // Main loop + // + // Refer to images/natural_neighbor_polygon.svg for some visual aid. + // + // The outer loops calculates the weight of an individual natural neighbor. + // To do this, it calculates the intersection area of the insertion cell with the cell of the + // current natural neighbor. + // This intersection is a convex polygon with vertices `first, current0, current1 ... last` + // (ordered ccw, `currentX` refers to the variable in the inner loop) + // + // The area of a convex polygon [v0 ... vN] is given by + // 0.5 * ((v0.x * v1.y + ... vN.x * v0.y) - (v0.y * v1.x + ... vN.y * v0.x)) + // ⮤ positive_area ⮥ ⮤ negative_area ⮥ + // + // The positive and negative contributions are calculated separately to avoid precision issues. + // The factor of 0.5 can be omitted as the weights are normalized anyway. + + // `stop_edge` is used to know when to stop the inner loop (= once the polygon is finished) + let stop_edge = self.triangulation.directed_edge(*stop_edge); + assert!(!stop_edge.is_outer_edge()); + + let mut positive_area = first.x * last.y; + let mut negative_area = first.y * last.x; + + loop { + // All other polygon vertices happen lie on the circumcenter of a face adjacent to an + // out edge of the current natural neighbor. + // + // The natural_neighbor_polygon.svg refers to this variable as `c0`, `c1`, and `c2`. + let current = last_edge.face().as_inner().unwrap().circumcenter(); + positive_area = positive_area + last.x * current.y; + negative_area = negative_area + last.y * current.x; + + last_edge = last_edge.next().rev(); + last = current; + + if last_edge == stop_edge.rev() { + positive_area = positive_area + current.x * first.y; + negative_area = negative_area + current.y * first.x; + break; + } + } + + let polygon_area = positive_area - negative_area; + + total_area = total_area + polygon_area; + result.push((stop_edge.from().fix(), polygon_area)); + + last = *first; + last_edge = stop_edge; + } + + for tuple in result { + tuple.1 = tuple.1 / total_area; + } + } +} + +fn get_natural_neighbor_edges( + triangulation: &T, + inspect_buffer: &mut Vec, + position: Point2<::Scalar>, + result: &mut Vec, +) where + T: Triangulation, + ::Scalar: Float, +{ + inspect_buffer.clear(); + result.clear(); + match triangulation.locate(position) { + PositionInTriangulation::OnFace(face) => { + for edge in triangulation + .face(face) + .adjacent_edges() + .into_iter() + .rev() + .map(|e| e.rev()) + { + inspect_flips(triangulation, result, inspect_buffer, edge.fix(), position); + } + } + PositionInTriangulation::OnEdge(edge) => { + let edge = triangulation.directed_edge(edge); + + if edge.is_part_of_convex_hull() { + result.extend([edge.fix(), edge.fix().rev()]); + return; + } + + for edge in [edge, edge.rev()] { + inspect_flips(triangulation, result, inspect_buffer, edge.fix(), position); + } + } + PositionInTriangulation::OnVertex(fixed_handle) => { + let vertex = triangulation.vertex(fixed_handle); + result.push( + vertex + .out_edge() + .map(|e| e.fix()) + .unwrap_or(FixedDirectedEdgeHandle::new(0)), + ) + } + _ => {} + } + result.reverse(); +} + +/// Identifies natural neighbors. +/// +/// To do so, this function "simulates" the insertion of a vertex (located at `position) and keeps track of which +/// edges would need to be flipped. A vertex is a natural neighbor if it happens to be part of an edge that would +/// require to be flipped. +/// +/// Similar to function `legalize_edge` (which is used for *actual* insertions). +fn inspect_flips( + triangulation: &T, + result: &mut Vec, + buffer: &mut Vec, + edge_to_validate: FixedDirectedEdgeHandle, + position: Point2<::Scalar>, +) where + T: Triangulation, +{ + buffer.clear(); + buffer.push(edge_to_validate); + + while let Some(edge) = buffer.pop() { + let edge = triangulation.directed_edge(edge); + + let v2 = edge.opposite_vertex(); + let v1 = edge.from(); + + let mut should_flip = false; + + if let Some(v2) = v2 { + let v0 = edge.to().position(); + let v1 = v1.position(); + let v3 = position; + debug_assert!(math::is_ordered_ccw(v2.position(), v1, v0)); + should_flip = math::contained_in_circumference(v2.position(), v1, v0, v3); + + if should_flip { + let e1 = edge.next().fix().rev(); + let e2 = edge.prev().fix().rev(); + + buffer.push(e1); + buffer.push(e2); + } + } + + if !should_flip { + result.push(edge.fix().rev()); + } + } +} + +fn two_point_interpolation<'a, T>( + v0: VertexHandle<'a, T::Vertex, T::DirectedEdge, T::UndirectedEdge, T::Face>, + v1: VertexHandle<'a, T::Vertex, T::DirectedEdge, T::UndirectedEdge, T::Face>, + position: Point2<::Scalar>, +) -> [::Scalar; 2] +where + T: Triangulation, + ::Scalar: Float, +{ + let projection = math::project_point(v0.position(), v1.position(), position); + let rel = projection.relative_position(); + let one: ::Scalar = 1.0.into(); + [one - rel, rel] +} + +#[cfg(test)] +mod test { + use approx::assert_ulps_eq; + + use crate::test_utilities::{random_points_in_range, random_points_with_seed, SEED, SEED2}; + use crate::{DelaunayTriangulation, HasPosition, InsertionError, Point2, Triangulation}; + use alloc::vec; + use alloc::vec::Vec; + + struct PointWithHeight { + position: Point2, + height: f64, + } + + impl PointWithHeight { + fn new(position: Point2, height: f64) -> Self { + Self { position, height } + } + } + + impl HasPosition for PointWithHeight { + type Scalar = f64; + + fn position(&self) -> Point2 { + self.position + } + } + + #[test] + fn test_natural_neighbors() -> Result<(), InsertionError> { + let vertices = random_points_with_seed(50, SEED); + let t = DelaunayTriangulation::<_>::bulk_load(vertices)?; + + let mut nn_edges = Vec::new(); + + let mut buffer = Vec::new(); + let query_point = Point2::new(0.5, 0.2); + super::get_natural_neighbor_edges(&t, &mut buffer, query_point, &mut nn_edges); + + assert!(nn_edges.len() >= 3); + + for edge in &nn_edges { + let edge = t.directed_edge(*edge); + assert!(edge.side_query(query_point).is_on_left_side()); + } + + let mut nns = Vec::new(); + let natural_neighbor = &t.natural_neighbor(); + for v in t.vertices() { + natural_neighbor.get_weights(v.position(), &mut nns); + let expected = vec![(v.fix(), 1.0f64)]; + assert_eq!(nns, expected); + } + + Ok(()) + } + + #[test] + fn test_small_interpolation() -> Result<(), InsertionError> { + let mut t = DelaunayTriangulation::<_>::new(); + // Create symmetric triangle - the weights should be mirrored + t.insert(PointWithHeight::new(Point2::new(0.0, 2.0f64.sqrt()), 0.0))?; + let v1 = t.insert(PointWithHeight::new(Point2::new(-1.0, -1.0), 0.0))?; + let v2 = t.insert(PointWithHeight::new(Point2::new(1.0, -1.0), 0.0))?; + + let mut result = Vec::new(); + t.natural_neighbor() + .get_weights(Point2::new(0.0, 0.0), &mut result); + + assert_eq!(result.len(), 3); + let mut v1_weight = None; + let mut v2_weight = None; + for (v, weight) in result { + if v == v1 { + v1_weight = Some(weight); + } + if v == v2 { + v2_weight = Some(weight); + } else { + assert!(weight > 0.0); + } + } + + assert!(v1_weight.is_some()); + assert!(v2_weight.is_some()); + assert_ulps_eq!(v1_weight.unwrap(), v2_weight.unwrap()); + + Ok(()) + } + + #[test] + fn test_quad_interpolation() -> Result<(), InsertionError> { + let mut t = DelaunayTriangulation::<_>::new(); + // Insert points into the corners of the unit square. The weights at the origin should then + // all be 0.25 + t.insert(Point2::new(1.0, 1.0))?; + t.insert(Point2::new(1.0, -1.0))?; + t.insert(Point2::new(-1.0, 1.0))?; + t.insert(Point2::new(-1.0, -1.0))?; + + let mut result = Vec::new(); + t.natural_neighbor() + .get_weights(Point2::new(0.0, 0.0), &mut result); + + assert_eq!(result.len(), 4); + for (_, weight) in result { + assert_ulps_eq!(weight, 0.25); + } + + Ok(()) + } + + #[test] + fn test_constant_height_interpolation() -> Result<(), InsertionError> { + let mut t = DelaunayTriangulation::<_>::new(); + let vertices = random_points_with_seed(50, SEED); + let fixed_height = 1.5; + for v in vertices { + t.insert(PointWithHeight::new(v, fixed_height))?; + } + + for v in random_points_in_range(1.5, 50, SEED2) { + let value = t.natural_neighbor().interpolate(|p| p.data().height, v); + if let Some(value) = value { + assert_ulps_eq!(value, 1.5); + } + } + + Ok(()) + } + + #[test] + fn test_slope_interpolation() -> Result<(), InsertionError> { + // Insert vertices v with + // v.x in [-1.0 .. 1.0] + // and + // v.height = v.x . + // + // The expected side profile of the triangulation (YZ plane through y = 0.0) looks like this: + // + // + // ↑ /⇖x = 1, height = 1 + // height / + // / + // x→ / <---- x = -1, height = -1 + let mut t = DelaunayTriangulation::<_>::new(); + let grid_size = 32; + let scale = 1.0 / grid_size as f64; + for x in -grid_size..=grid_size { + for y in -grid_size..=grid_size { + let coords = Point2::new(x as f64, y as f64).mul(scale); + t.insert(PointWithHeight::new(coords, coords.x))?; + } + } + let query_points = random_points_with_seed(50, SEED); + + let check = |point, expected| { + let value = t + .natural_neighbor() + .interpolate(|v| v.data().height, point) + .unwrap(); + assert_ulps_eq!(value, expected, epsilon = 0.001); + }; + + // Check inside points + for point in query_points { + check(point, point.x); + } + + // Check positions on the boundary + check(Point2::new(-1.0, 0.0), -1.0); + check(Point2::new(-1.0, 0.2), -1.0); + check(Point2::new(-1.0, 0.5), -1.0); + check(Point2::new(-1.0, -1.0), -1.0); + check(Point2::new(1.0, 0.0), 1.0); + check(Point2::new(1.0, 0.2), 1.0); + check(Point2::new(1.0, 0.5), 1.0); + check(Point2::new(1.0, -1.0), 1.0); + + for x in [-1.0, -0.8, -0.5, 0.0, 0.3, 0.7, 1.0] { + check(Point2::new(x, 1.0), x); + check(Point2::new(x, -1.0), x); + } + + let expect_none = |point| { + assert!(t + .natural_neighbor() + .interpolate(|v| v.data().height, point) + .is_none()); + }; + + // Check positions outside of the triangulation. + for x in [-5.0f64, -4.0, 3.0, 2.0] { + expect_none(Point2::new(x, 0.5)); + expect_none(Point2::new(x, -0.5)); + expect_none(Point2::new(x, 0.1)); + } + + Ok(()) + } + + #[test] + fn test_parabola_interpolation() -> Result<(), InsertionError> { + // Insert vertices into a regular grid and assign a height of r*r (r = distance from origin). + let mut t = DelaunayTriangulation::<_>::new(); + let grid_size = 32; + let scale = 1.0 / grid_size as f64; + for x in -grid_size..=grid_size { + for y in -grid_size..=grid_size { + let coords = Point2::new(x as f64, y as f64).mul(scale); + t.insert(PointWithHeight::new(coords, coords.length2()))?; + } + } + let query_points = random_points_with_seed(50, SEED); + + for point in query_points { + let value = t + .natural_neighbor() + .interpolate(|v| v.data().height, point) + .unwrap(); + let r2 = point.length2(); + assert_ulps_eq!(value, r2, epsilon = 0.001); + } + + Ok(()) + } +} diff --git a/src/delaunay_core/math.rs b/src/delaunay_core/math.rs index bd8a08d..4005a55 100644 --- a/src/delaunay_core/math.rs +++ b/src/delaunay_core/math.rs @@ -1,11 +1,12 @@ use crate::{HasPosition, LineSideInfo, Point2, SpadeNum}; -use num_traits::Float; +use num_traits::{zero, Float}; /// Indicates a point's projected position relative to an edge. /// /// This struct is usually the result of calling /// [DirectedEdgeHandle::project_point](crate::handles::DirectedEdgeHandle::project_point), refer to its /// documentation for more information. +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug, Hash)] pub struct PointProjection { factor: S, length_2: S, @@ -196,11 +197,14 @@ impl PointProjection { /// Returns the inverse of this point projection. /// - /// The inverse projection projects the same point on the *reversed* edge used by the original projection. + /// The inverse projection projects the same point on the *reversed* edge used by the original projection. + /// + /// This method can return an incorrect projection due to rounding issues if the projected point is close to one of + /// the original edge's vertices. pub fn reversed(&self) -> Self { Self { - factor: -self.factor, - length_2: -self.length_2, + factor: self.length_2 - self.factor, + length_2: self.length_2, } } } @@ -215,7 +219,13 @@ impl PointProjection { /// point lies "before" `self.from`. Analogously, a value close to 1. or greater than 1. is /// returned if the projected point is equal to or lies behind `self.to`. pub fn relative_position(&self) -> S { - self.factor / self.length_2 + if self.length_2 >= zero() { + self.factor / self.length_2 + } else { + let l = -self.length_2; + let f = -self.factor; + (l - f) / l + } } } @@ -383,6 +393,58 @@ mod test { use crate::{InsertionError, Point2}; use approx::assert_relative_eq; + #[test] + fn test_point_projection() { + use super::project_point; + + let from = Point2::new(1.0f64, 1.0); + let to = Point2::new(4.0, 5.0); + let normal = Point2::new(4.0, -3.0); + + let projection = project_point(from, to, from); + let reversed = projection.reversed(); + + assert!(!projection.is_before_edge()); + assert!(!projection.is_behind_edge()); + assert!(!reversed.is_before_edge()); + assert!(!reversed.is_behind_edge()); + + assert!(projection.is_on_edge()); + assert!(reversed.is_on_edge()); + + assert_eq!(projection.relative_position(), 0.0); + assert_eq!(reversed.relative_position(), 1.0); + + assert_eq!(projection, reversed.reversed()); + + // Create point which projects onto the mid + let mid_point = Point2::new(2.5 + normal.x, 3.0 + normal.y); + + let projection = project_point(from, to, mid_point); + assert!(projection.is_on_edge()); + assert_eq!(projection.relative_position(), 0.5); + assert_eq!(projection.reversed().relative_position(), 0.5); + + // Create point which projects onto 20% of the line + let fifth = Point2::new(0.8 * from.x + 0.2 * to.x, 0.8 * from.y + 0.2 * to.y); + let fifth = Point2::new(fifth.x + normal.x, fifth.y + normal.y); + let projection = project_point(from, to, fifth); + assert!(projection.is_on_edge()); + assert_relative_eq!(projection.relative_position(), 0.2); + assert_relative_eq!(projection.reversed().relative_position(), 0.8); + + // Check point before / behind + let behind_point = Point2::new(0.0, 0.0); + let projection = project_point(from, to, behind_point); + let reversed = projection.reversed(); + + assert!(projection.is_before_edge()); + assert!(reversed.is_behind_edge()); + + assert!(!projection.is_on_edge()); + assert!(!reversed.is_on_edge()); + } + #[test] fn test_validate_coordinate() { use super::{validate_coordinate, InsertionError::*}; diff --git a/src/delaunay_core/mod.rs b/src/delaunay_core/mod.rs index 3217313..800537b 100644 --- a/src/delaunay_core/mod.rs +++ b/src/delaunay_core/mod.rs @@ -12,6 +12,7 @@ mod triangulation_ext; pub mod refinement; +pub mod interpolation; pub mod math; pub use bulk_load::bulk_load; diff --git a/src/delaunay_core/triangulation_ext.rs b/src/delaunay_core/triangulation_ext.rs index 160facf..a63f0d2 100644 --- a/src/delaunay_core/triangulation_ext.rs +++ b/src/delaunay_core/triangulation_ext.rs @@ -714,7 +714,7 @@ pub trait TriangulationExt: Triangulation { /// For more details, refer to /// Olivier Devillers. Vertex Removal in Two Dimensional Delaunay Triangulation: /// Speed-up by Low Degrees Optimization. - /// https://doi.org/10.1016/j.comgeo.2010.10.001 + /// /// /// Note that the described low degrees optimization is not yet part of this library. fn legalize_edges_after_removal( diff --git a/src/delaunay_triangulation.rs b/src/delaunay_triangulation.rs index b547120..c062a6d 100644 --- a/src/delaunay_triangulation.rs +++ b/src/delaunay_triangulation.rs @@ -1,9 +1,11 @@ use super::delaunay_core::Dcel; use crate::{ - handles::VertexHandle, HasPosition, HintGenerator, LastUsedVertexHintGenerator, Point2, - Triangulation, TriangulationExt, + handles::VertexHandle, HasPosition, HintGenerator, LastUsedVertexHintGenerator, + NaturalNeighbor, Point2, Triangulation, TriangulationExt, }; +use num_traits::Float; + #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -58,6 +60,7 @@ use serde::{Deserialize, Serialize}; /// #[doc = concat!(include_str!("../images/lhs.svg"), "",include_str!("../images/rhs.svg"), " ")] /// # Extracting geometry information +/// /// Spade uses [handles](crate::handles) to extract the triangulation's geometry. /// Handles are usually retrieved by inserting a vertex or by iterating. /// @@ -284,7 +287,7 @@ where /// Returns `None` if the triangulation is empty. /// /// # Runtime - /// This method take O(sqrt(n)) on average where n is the number of vertices. + /// This method takes `O(sqrt(n))` on average where n is the number of vertices. pub fn nearest_neighbor( &self, position: Point2<::Scalar>, @@ -318,6 +321,22 @@ where } } +impl DelaunayTriangulation +where + V: HasPosition, + DE: Default, + UE: Default, + F: Default, + V::Scalar: Float, + L: HintGenerator<::Scalar>, +{ + /// Allows using natural neighbor interpolation on this triangulation. Refer to the documentation + /// of [NaturalNeighbor] for more information. + pub fn natural_neighbor(&self) -> NaturalNeighbor { + NaturalNeighbor::new(self) + } +} + impl Triangulation for DelaunayTriangulation where V: HasPosition, diff --git a/src/lib.rs b/src/lib.rs index 0740a06..1393a66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ //! * Supports vertex removal //! * Serde support with the `serde` feature. //! * `no_std` support with `default-features = false` +//! * Natural neighbor interpolation: [NaturalNeighbor] #![no_std] #![forbid(unsafe_code)] @@ -45,6 +46,7 @@ pub use delaunay_core::{ LastUsedVertexHintGenerator, RefinementParameters, RefinementResult, }; +pub use crate::delaunay_core::interpolation::{Barycentric, NaturalNeighbor}; pub use delaunay_core::LineSideInfo; pub use triangulation::{FloatTriangulation, PositionInTriangulation, Triangulation}; diff --git a/src/triangulation.rs b/src/triangulation.rs index 87872f4..63218f7 100644 --- a/src/triangulation.rs +++ b/src/triangulation.rs @@ -8,6 +8,7 @@ use crate::flood_fill_iterator::FloodFillIterator; use crate::flood_fill_iterator::RectangleMetric; use crate::flood_fill_iterator::VerticesInShapeIterator; use crate::iterators::*; +use crate::Barycentric; use crate::HintGenerator; use crate::{delaunay_core::Dcel, handles::*}; use crate::{HasPosition, InsertionError, Point2, TriangulationExt}; @@ -700,6 +701,15 @@ where VerticesInShapeIterator::new(FloodFillIterator::new(self, distance_metric, center)) } + + /// Used for barycentric interpolation on this triangulation. Refer to the documentation of + /// [Barycentric] and [crate::NaturalNeighbor] for more information. + /// + /// *Note:* In contrast to the other interpolation algorithms, barycentric interpolation also works + /// for [crate::ConstrainedDelaunayTriangulation]s. + fn barycentric(&self) -> Barycentric { + Barycentric::new(self) + } } impl FloatTriangulation for T