diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 00a3ec4..82a8fa6 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -38,10 +38,15 @@ jobs: 8.0.x 10.0.x - # Windows: restore full solution (includes Windows-only CDT.Viz) + # Windows: restore cross-platform + Windows-only projects (CDT.Viz targets Windows only) + # CDT.Comparison.Benchmarks is excluded: its native cmake/cargo build is too slow for CI - name: Restore dependencies if: runner.os == 'Windows' - run: dotnet restore + run: | + dotnet restore src/CDT.Core/CDT.Core.csproj + dotnet restore test/CDT.Tests/CDT.Tests.csproj + dotnet restore benchmark/CDT.Benchmarks/CDT.Benchmarks.csproj + dotnet restore viz/CDT.Viz/CDT.Viz.csproj # Linux: restore only cross-platform projects (CDT.Viz targets Windows only) - name: Restore dependencies @@ -50,10 +55,15 @@ jobs: dotnet restore src/CDT.Core/CDT.Core.csproj dotnet restore test/CDT.Tests/CDT.Tests.csproj - # Windows: build full solution + # Windows: build cross-platform + Windows-only projects + # CDT.Comparison.Benchmarks is excluded: its native cmake/cargo build is too slow for CI - name: Build if: runner.os == 'Windows' - run: dotnet build --no-restore -c Release + run: | + dotnet build --no-restore -c Release src/CDT.Core/CDT.Core.csproj + dotnet build --no-restore -c Release test/CDT.Tests/CDT.Tests.csproj + dotnet build --no-restore -c Release benchmark/CDT.Benchmarks/CDT.Benchmarks.csproj + dotnet build --no-restore -c Release viz/CDT.Viz/CDT.Viz.csproj # Linux: build only cross-platform projects - name: Build @@ -62,10 +72,10 @@ jobs: dotnet build --no-restore -c Release src/CDT.Core/CDT.Core.csproj dotnet build --no-restore -c Release test/CDT.Tests/CDT.Tests.csproj - # Windows: test full solution + # Windows: test CDT.Tests only (CDT.Comparison.Benchmarks excluded from CI build) - name: Test if: runner.os == 'Windows' - run: dotnet test --no-build -c Release --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:CoverletOutput=${{ github.workspace }}/coverage/coverage.cobertura.xml + run: dotnet test --no-build -c Release --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:CoverletOutput=${{ github.workspace }}/coverage/coverage.cobertura.xml test/CDT.Tests/CDT.Tests.csproj # Linux: test only CDT.Tests (CDT.Viz has no tests; benchmark is not a test project) - name: Test @@ -110,11 +120,12 @@ jobs: with: fetch-depth: 0 # Required for SourceLink + # Only CDT.Core is needed for pack; CDT.Comparison.Benchmarks is excluded - name: Restore dependencies - run: dotnet restore + run: dotnet restore src/CDT.Core/CDT.Core.csproj - name: Build - run: dotnet build --no-restore -c Release + run: dotnet build --no-restore -c Release src/CDT.Core/CDT.Core.csproj - name: Pack shell: pwsh diff --git a/.gitignore b/.gitignore index 60348ec..07655e0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,11 @@ _ReSharper*/ # BenchmarkDotNet BenchmarkDotNet.Artifacts/ +# Native build artifacts +benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/build/ +benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/build/ +benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/target/ + # OS generated files .DS_Store .DS_Store? @@ -69,3 +74,5 @@ lcov.info # Temp files *.tmp *.temp +_codeql_detected_source_root +Program.cs diff --git a/CDT.NET.slnx b/CDT.NET.slnx index 341cb0c..8c1599f 100644 --- a/CDT.NET.slnx +++ b/CDT.NET.slnx @@ -1,6 +1,7 @@ + diff --git a/benchmark/CDT.Comparison.Benchmarks/CDT.Comparison.Benchmarks.csproj b/benchmark/CDT.Comparison.Benchmarks/CDT.Comparison.Benchmarks.csproj new file mode 100644 index 0000000..ad2b546 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/CDT.Comparison.Benchmarks.csproj @@ -0,0 +1,157 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + $(DefaultItemExcludes);native\** + + + + + + + + + + + + + + + + + + + + + + + <_CdtNativeDir>$(MSBuildThisFileDirectory)native/cdt_wrapper + <_CdtBuildDir>$(_CdtNativeDir)/build + <_CgalNativeDir>$(MSBuildThisFileDirectory)native/cgal_wrapper + <_CgalBuildDir>$(_CgalNativeDir)/build + <_SpadeNativeDir>$(MSBuildThisFileDirectory)native/spade_wrapper + <_SpadeReleaseDir>$(_SpadeNativeDir)/target/release + + + <_NativeLibPrefix Condition="'$(OS)' != 'Windows_NT'">lib + + + <_NativeLibExt Condition="'$(OS)' == 'Windows_NT'">.dll + <_NativeLibExt Condition="$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))">.dylib + <_NativeLibExt Condition="'$(_NativeLibExt)' == ''">.so + + + <_CdtLibFile>$(_CdtBuildDir)/$(_NativeLibPrefix)cdt_wrapper$(_NativeLibExt) + <_CgalLibFile>$(_CgalBuildDir)/$(_NativeLibPrefix)cgal_wrapper$(_NativeLibExt) + <_SpadeLibFile>$(_SpadeReleaseDir)/$(_NativeLibPrefix)spade_wrapper$(_NativeLibExt) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmark/CDT.Comparison.Benchmarks/ComparisonBenchmarks.cs b/benchmark/CDT.Comparison.Benchmarks/ComparisonBenchmarks.cs new file mode 100644 index 0000000..64b91a6 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/ComparisonBenchmarks.cs @@ -0,0 +1,306 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using CDT; +using CdtEdge = CDT.Edge; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; +using NtsLineString = NetTopologySuite.Geometries.LineString; +using NtsMultiLineString = NetTopologySuite.Geometries.MultiLineString; +using NtsMultiPoint = NetTopologySuite.Geometries.MultiPoint; +using NtsPoint = NetTopologySuite.Geometries.Point; +using TnMesher = TriangleNet.Meshing.GenericMesher; +using TnPolygon = TriangleNet.Geometry.Polygon; +using TnSegment = TriangleNet.Geometry.Segment; +using TnVertex = TriangleNet.Geometry.Vertex; +using System.Runtime.InteropServices; + +// --------------------------------------------------------------------------- +// Shared input reader +// Reads the same .txt files used by CDT.Tests / CDT.Benchmarks. +// Format: nVerts nEdges\n x y\n… v1 v2\n… +// --------------------------------------------------------------------------- +internal static class InputReader +{ + public static (double[] Xs, double[] Ys, int[] EdgeV1, int[] EdgeV2) Read(string fileName) + { + var path = Path.Combine(AppContext.BaseDirectory, "inputs", fileName); + if (!File.Exists(path)) + throw new FileNotFoundException($"Benchmark input not found: {fileName}"); + + using var sr = new StreamReader(path); + var header = sr.ReadLine()!.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + int nVerts = int.Parse(header[0]); + int nEdges = int.Parse(header[1]); + + var xs = new double[nVerts]; + var ys = new double[nVerts]; + for (int i = 0; i < nVerts; i++) + { + var tok = sr.ReadLine()!.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + xs[i] = double.Parse(tok[0], System.Globalization.CultureInfo.InvariantCulture); + ys[i] = double.Parse(tok[1], System.Globalization.CultureInfo.InvariantCulture); + } + + var ev1 = new int[nEdges]; + var ev2 = new int[nEdges]; + for (int i = 0; i < nEdges; i++) + { + var tok = sr.ReadLine()!.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + ev1[i] = int.Parse(tok[0]); + ev2[i] = int.Parse(tok[1]); + } + + return (xs, ys, ev1, ev2); + } +} + +// --------------------------------------------------------------------------- +// Adapter — CDT.NET (baseline) +// --------------------------------------------------------------------------- +internal static class CdtNetAdapter +{ + public static int VerticesOnly(double[] xs, double[] ys) + { + var verts = new List>(xs.Length); + for (int i = 0; i < xs.Length; i++) + verts.Add(new V2d(xs[i], ys[i])); + + var cdt = new Triangulation(VertexInsertionOrder.Auto); + cdt.InsertVertices(verts); + return cdt.Triangles.Length; + } + + public static int Constrained(double[] xs, double[] ys, int[] ev1, int[] ev2) + { + var verts = new List>(xs.Length); + for (int i = 0; i < xs.Length; i++) + verts.Add(new V2d(xs[i], ys[i])); + + var edges = new List(ev1.Length); + for (int i = 0; i < ev1.Length; i++) + edges.Add(new CdtEdge(ev1[i], ev2[i])); + + var cdt = new Triangulation(VertexInsertionOrder.Auto); + cdt.InsertVertices(verts); + cdt.InsertEdges(edges); + return cdt.Triangles.Length; + } +} + +// --------------------------------------------------------------------------- +// Adapter — Triangle.NET (Unofficial.Triangle.NET 0.0.1) +// True CDT: segments become hard constraint edges in the mesh. +// --------------------------------------------------------------------------- +internal static class TriangleNetAdapter +{ + public static int VerticesOnly(double[] xs, double[] ys) + { + var polygon = new TnPolygon(xs.Length); + for (int i = 0; i < xs.Length; i++) + polygon.Add(new TnVertex(xs[i], ys[i])); + + return new TnMesher().Triangulate(polygon).Triangles.Count; + } + + public static int Constrained(double[] xs, double[] ys, int[] ev1, int[] ev2) + { + var polygon = new TnPolygon(xs.Length); + var verts = new TnVertex[xs.Length]; + for (int i = 0; i < xs.Length; i++) + { + verts[i] = new TnVertex(xs[i], ys[i]); + polygon.Add(verts[i]); + } + for (int i = 0; i < ev1.Length; i++) + polygon.Add(new TnSegment(verts[ev1[i]], verts[ev2[i]])); + + return new TnMesher().Triangulate(polygon).Triangles.Count; + } +} + +// --------------------------------------------------------------------------- +// Adapter — NetTopologySuite (2.6.0) +// Conforming CDT: constraint edges are honoured but Steiner points may be +// inserted to satisfy the Delaunay criterion (results differ from true CDT). +// VerticesOnly uses the plain DelaunayTriangulationBuilder. +// --------------------------------------------------------------------------- +internal static class NtsAdapter +{ + private static readonly NtsGeometryFactory Gf = new(); + + public static int VerticesOnly(double[] xs, double[] ys) + { + var coords = new NtsCoordinate[xs.Length]; + for (int i = 0; i < xs.Length; i++) + coords[i] = new NtsCoordinate(xs[i], ys[i]); + + var builder = new NetTopologySuite.Triangulate.DelaunayTriangulationBuilder(); + builder.SetSites(coords); + return builder.GetTriangles(Gf).NumGeometries; + } + + public static int Conforming(double[] xs, double[] ys, int[] ev1, int[] ev2) + { + var pts = new NtsPoint[xs.Length]; + for (int i = 0; i < xs.Length; i++) + pts[i] = Gf.CreatePoint(new NtsCoordinate(xs[i], ys[i])); + + var segments = new NtsLineString[ev1.Length]; + for (int i = 0; i < ev1.Length; i++) + segments[i] = Gf.CreateLineString(new[] + { + new NtsCoordinate(xs[ev1[i]], ys[ev1[i]]), + new NtsCoordinate(xs[ev2[i]], ys[ev2[i]]), + }); + + var builder = new NetTopologySuite.Triangulate.ConformingDelaunayTriangulationBuilder(); + builder.SetSites(new NtsMultiPoint(pts)); + builder.Constraints = new NtsMultiLineString(segments); + return builder.GetTriangles(Gf).NumGeometries; + } +} + +// --------------------------------------------------------------------------- +// Adapter — artem-ogre/CDT (C++ via P/Invoke) +// The original C++ CDT library that CDT.NET is ported from. +// Built from source via CMake + FetchContent; produces libcdt_wrapper.so. +// --------------------------------------------------------------------------- +internal static partial class NativeCdtAdapter +{ + private const string Lib = "cdt_wrapper"; + + [LibraryImport(Lib, EntryPoint = "cdt_triangulate_d")] + private static partial int Triangulate( + double[] xs, double[] ys, int nVerts, + int[] ev1, int[] ev2, int nEdges); + + public static int VerticesOnly(double[] xs, double[] ys) => + Triangulate(xs, ys, xs.Length, [], [], 0); + + public static int Constrained(double[] xs, double[] ys, int[] ev1, int[] ev2) => + Triangulate(xs, ys, xs.Length, ev1, ev2, ev1.Length); +} + + +// --------------------------------------------------------------------------- +// Adapter — Spade (Rust via P/Invoke, spade 2.15.0) +// Incremental CDT using Spade's ConstrainedDelaunayTriangulation. +// Returns num_inner_faces() (finite triangles, excludes the infinite face). +// Built from source via cargo; produces libspade_wrapper.so. +// --------------------------------------------------------------------------- +internal static partial class SpadeAdapter +{ + private const string Lib = "spade_wrapper"; + + [LibraryImport(Lib, EntryPoint = "spade_cdt")] + private static partial int SpadeTriangulate( + double[] xs, double[] ys, int nVerts, + int[] ev1, int[] ev2, int nEdges); + + public static int VerticesOnly(double[] xs, double[] ys) => + SpadeTriangulate(xs, ys, xs.Length, [], [], 0); + + public static int Constrained(double[] xs, double[] ys, int[] ev1, int[] ev2) => + SpadeTriangulate(xs, ys, xs.Length, ev1, ev2, ev1.Length); +} + + +// --------------------------------------------------------------------------- +// Adapter — CGAL (C++ via P/Invoke, CGAL 5.x/6.x) +// Uses the Exact_predicates_inexact_constructions_kernel (Epick): +// - exact predicates via interval arithmetic (no GMP needed at runtime) +// - double-precision constructions +// Returns cdt.number_of_faces() which counts all finite triangles including +// those in the convex hull, consistent with artem-ogre/CDT's count. +// Built via cmake using a system-installed CGAL (apt/brew/vcpkg). +// --------------------------------------------------------------------------- +internal static partial class CgalAdapter +{ + private const string Lib = "cgal_wrapper"; + + [LibraryImport(Lib, EntryPoint = "cgal_cdt")] + private static partial int CgalTriangulate( + double[] xs, double[] ys, int nVerts, + int[] ev1, int[] ev2, int nEdges); + + public static int VerticesOnly(double[] xs, double[] ys) => + CgalTriangulate(xs, ys, xs.Length, [], [], 0); + + public static int Constrained(double[] xs, double[] ys, int[] ev1, int[] ev2) => + CgalTriangulate(xs, ys, xs.Length, ev1, ev2, ev1.Length); +} + + +// (~2 600 vertices, ~2 600 constraint edges) +// --------------------------------------------------------------------------- +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[ShortRunJob] +public class ComparisonBenchmarks +{ + private double[] _xs = null!; + private double[] _ys = null!; + private int[] _ev1 = null!; + private int[] _ev2 = null!; + + [GlobalSetup] + public void Setup() => + (_xs, _ys, _ev1, _ev2) = InputReader.Read("Constrained Sweden.txt"); + + // -- VerticesOnly -------------------------------------------------------- + + [Benchmark(Baseline = true, Description = "CDT.NET")] + [BenchmarkCategory("VerticesOnly")] + public int VO_CdtNet() => CdtNetAdapter.VerticesOnly(_xs, _ys); + + [Benchmark(Description = "Triangle.NET")] + [BenchmarkCategory("VerticesOnly")] + public int VO_TriangleNet() => TriangleNetAdapter.VerticesOnly(_xs, _ys); + + [Benchmark(Description = "NTS")] + [BenchmarkCategory("VerticesOnly")] + public int VO_Nts() => NtsAdapter.VerticesOnly(_xs, _ys); + + [Benchmark(Description = "artem-ogre/CDT (C++)")] + [BenchmarkCategory("VerticesOnly")] + public int VO_NativeCdt() => NativeCdtAdapter.VerticesOnly(_xs, _ys); + + [Benchmark(Description = "CGAL (C++)")] + [BenchmarkCategory("VerticesOnly")] + public int VO_Cgal() => CgalAdapter.VerticesOnly(_xs, _ys); + + [Benchmark(Description = "Spade (Rust)")] + [BenchmarkCategory("VerticesOnly")] + public int VO_Spade() => SpadeAdapter.VerticesOnly(_xs, _ys); + + // -- Constrained --------------------------------------------------------- + + [Benchmark(Baseline = true, Description = "CDT.NET")] + [BenchmarkCategory("Constrained")] + public int CDT_CdtNet() => CdtNetAdapter.Constrained(_xs, _ys, _ev1, _ev2); + + [Benchmark(Description = "Triangle.NET")] + [BenchmarkCategory("Constrained")] + public int CDT_TriangleNet() => TriangleNetAdapter.Constrained(_xs, _ys, _ev1, _ev2); + + [Benchmark(Description = "NTS (Conforming CDT)")] + [BenchmarkCategory("Constrained")] + public int CDT_Nts() => NtsAdapter.Conforming(_xs, _ys, _ev1, _ev2); + + [Benchmark(Description = "artem-ogre/CDT (C++)")] + [BenchmarkCategory("Constrained")] + public int CDT_NativeCdt() => NativeCdtAdapter.Constrained(_xs, _ys, _ev1, _ev2); + + [Benchmark(Description = "CGAL (C++)")] + [BenchmarkCategory("Constrained")] + public int CDT_Cgal() => CgalAdapter.Constrained(_xs, _ys, _ev1, _ev2); + + [Benchmark(Description = "Spade (Rust)")] + [BenchmarkCategory("Constrained")] + public int CDT_Spade() => SpadeAdapter.Constrained(_xs, _ys, _ev1, _ev2); +} diff --git a/benchmark/CDT.Comparison.Benchmarks/Program.cs b/benchmark/CDT.Comparison.Benchmarks/Program.cs new file mode 100644 index 0000000..c9e5352 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/Program.cs @@ -0,0 +1,7 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(ComparisonBenchmarks).Assembly).Run(args); diff --git a/benchmark/CDT.Comparison.Benchmarks/README.md b/benchmark/CDT.Comparison.Benchmarks/README.md new file mode 100644 index 0000000..d1d712b --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/README.md @@ -0,0 +1,124 @@ +# CDT.Comparison.Benchmarks + +Compares the performance of **CDT.NET** against other C# and native CDT/Delaunay triangulation libraries on the same input datasets used by the CDT.NET test suite. + +## Libraries compared + +| # | Library | NuGet / Source | CDT type | Notes | +|---|---------|---------------|----------|-------| +| 1 | **CDT.NET** (baseline) | Project ref | True CDT | This repo | +| 2 | **Triangle.NET** | `Unofficial.Triangle.NET` 0.0.1 | True CDT | Classic robust C# triangulator | +| 3 | **NetTopologySuite** | `NetTopologySuite` 2.6.0 | Conforming CDT | May insert Steiner points | +| 4 | **artem-ogre/CDT** | C++ via P/Invoke | True CDT | Original C++ library CDT.NET is ported from | +| 5 | **CGAL** | C++ via P/Invoke | True CDT | Industry-standard C++ geometry library (Epick kernel) | +| 6 | **Spade** | Rust via P/Invoke | CDT | Rust `spade` crate 2.15.0 | + +## Benchmark categories + +| Category | Description | +|----------|-------------| +| `VerticesOnly` | Delaunay triangulation of all vertices, no constraint edges | +| `Constrained` | Full CDT — vertices + constraint edges | + +The dataset used is **"Constrained Sweden"** (~2 600 vertices, ~2 600 constraint edges), the same dataset used by the upstream C++ CDT benchmark suite. + +## Prerequisites + +The C# libraries (CDT.NET, Triangle.NET, NetTopologySuite) are fetched automatically by NuGet. +The three **native** wrappers (artem-ogre/CDT, CGAL, Spade) are **compiled from source** as part of `dotnet build`. CGAL and its required Boost sub-libraries are downloaded automatically by CMake FetchContent on first build (~20 MB total, cached afterwards). No manual installation of CGAL or Boost is required. + +### Linux / macOS + +| Tool | Purpose | Install | +|------|---------|---------| +| `cmake` ≥ 3.20 | Builds the C++ wrappers | Package manager or https://cmake.org/download/ | +| C++17 compiler | `gcc`/`clang` | `sudo apt install build-essential` / `xcode-select --install` | +| `git` | CMake FetchContent (downloads artem-ogre/CDT) | Usually pre-installed | +| `cargo` (Rust stable) | Builds the Rust wrapper | https://rustup.rs/ | + +```bash +# Ubuntu / Debian +sudo apt install cmake build-essential git +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +```bash +# macOS +brew install cmake +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +### Windows + +| Tool | Purpose | Install | +|------|---------|---------| +| **Visual Studio 2022** (or Build Tools) with the **"Desktop development with C++"** workload | C++17 compiler (MSVC) | https://visualstudio.microsoft.com/downloads/ | +| **CMake** ≥ 3.20 | Builds the C++ wrappers | Bundled with VS 2022, or https://cmake.org/download/ (tick "Add to PATH") | +| **Git for Windows** | CMake FetchContent (downloads artem-ogre/CDT, CGAL, Boost sub-libs) | https://git-scm.com/download/win | +| **Rust** (stable, MSVC ABI) | Builds the Rust wrapper | https://rustup.rs/ → choose `x86_64-pc-windows-msvc` | + +> **Tip:** Install Visual Studio first, then Rust. The Rust installer auto-detects the MSVC linker. +> Open a **Developer Command Prompt for VS 2022** (or a normal terminal after running `vcvarsall.bat`) so that `cmake`, `cl`, and `cargo` are all on your PATH. +> +> CGAL and Boost are downloaded automatically by CMake — no manual install required. + +If any of these tools are missing, `dotnet build` will print a clear error message pointing to the missing tool before failing. + +## Running the benchmarks + +```bash +# From the repository root — Release configuration is required for BenchmarkDotNet +dotnet run --project benchmark/CDT.Comparison.Benchmarks -c Release +``` + +BenchmarkDotNet will present an interactive menu to select which benchmark class / category to run. To run all benchmarks non-interactively: + +```bash +dotnet run --project benchmark/CDT.Comparison.Benchmarks -c Release -- --filter "*" +``` + +Results are written to `BenchmarkDotNet.Artifacts/` in the current directory. + +## Known limitations + +| Library | Limitation | +|---------|-----------| +| NetTopologySuite | Uses *conforming* CDT — Steiner points may be inserted, so the triangle count and layout differ from true CDT results | +| artem-ogre/CDT (C++) | Triangle count includes all convex-hull triangles (same behaviour as CDT.NET before `EraseSuperTriangle`) | +| CGAL (C++) | `number_of_faces()` counts all finite triangles in the triangulation, consistent with artem-ogre/CDT. First build downloads CGAL 6.1.1 library headers (~10 MB) and the required Boost sub-library headers (~10 MB ZIPs, ~60 MB staged); subsequent builds use the cmake cache. | +| Spade (Rust) | `num_inner_faces()` returns only inner (non-convex-hull) triangles, which is fewer than the C++ CDT counts | + +## Benchmark results + +Measured on **AMD EPYC 7763 2.45 GHz, 1 CPU, 4 logical / 2 physical cores, .NET 10.0.2, Linux Ubuntu 24.04** using `[ShortRunJob]` (3 warmup + 3 iterations). Dataset: **Constrained Sweden** (~2 600 vertices, ~2 600 constraint edges). + +### Constrained CDT + +| Library | Mean | Error | StdDev | Ratio | Allocated | Alloc Ratio | +|---------|-----:|------:|-------:|------:|----------:|------------:| +| **CDT.NET** *(baseline)* | 1,759 μs | 143.0 μs | 7.8 μs | 1.00 | 495 KB | 1.00 | +| artem-ogre/CDT (C++) | 1,961 μs | 85.4 μs | 4.7 μs | 1.11 | — | 0.00 | +| Spade (Rust) | 1,989 μs | 120.0 μs | 6.6 μs | 1.13 | — | 0.00 | +| CGAL (C++) | 3,659 μs | 288.2 μs | 15.8 μs | 2.08 | — | 0.00 | +| Triangle.NET | 6,405 μs | 2,193.7 μs | 120.2 μs | 3.64 | 2,817 KB | 5.70 | +| NTS (Conforming CDT) | 54,282 μs | 10,703.7 μs | 586.7 μs | 30.86 | 59,937 KB | 121.19 | + +### Vertices-only (Delaunay, no constraints) + +| Library | Mean | Error | StdDev | Ratio | Allocated | Alloc Ratio | +|---------|-----:|------:|-------:|------:|----------:|------------:| +| **CDT.NET** *(baseline)* | 1,584 μs | 135.3 μs | 7.4 μs | 1.00 | 322 KB | 1.00 | +| artem-ogre/CDT (C++) | 1,586 μs | 54.8 μs | 3.0 μs | 1.00 | — | 0.00 | +| Spade (Rust) | 1,613 μs | 27.3 μs | 1.5 μs | 1.02 | — | 0.00 | +| CGAL (C++) | 3,254 μs | 104.8 μs | 5.7 μs | 2.05 | — | 0.00 | +| Triangle.NET | 2,134 μs | 298.8 μs | 16.4 μs | 1.35 | 1,760 KB | 5.47 | +| NTS | 7,960 μs | 1,199.3 μs | 65.7 μs | 5.03 | 4,373 KB | 13.58 | + +### Key takeaways + +- **CDT.NET matches the original C++ implementation (artem-ogre/CDT) and Spade within ≤13%** on both constrained and unconstrained triangulation. +- **CGAL** runs at ~2× CDT.NET. CGAL's `Constrained_Delaunay_triangulation_2` uses a more complex data structure (half-edge DCEL) with additional bookkeeping overhead vs. CDT.NET's compact flat arrays. For raw triangulation throughput CDT.NET is faster. +- **CDT.NET allocates 5–120× less managed memory** than Triangle.NET and NTS: Triangle.NET allocates ~5.7× more, NTS ~121× more. +- **NTS (conforming CDT)** is ~30× slower and allocates ~120× more memory — Steiner-point insertion is the main cost, and the result is semantically different (not true CDT). +- Native wrappers (artem-ogre/CDT, CGAL, Spade) show zero managed allocations as expected for P/Invoke calls into unmanaged code. + diff --git a/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/CMakeLists.txt b/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/CMakeLists.txt new file mode 100644 index 0000000..a9d2fe4 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) +project(cdt_wrapper CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Export all symbols from the DLL on Windows so that the extern "C" entry +# points are visible to P/Invoke without explicit __declspec(dllexport). +set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) + +include(FetchContent) +FetchContent_Declare( + CDT_Upstream + GIT_REPOSITORY https://github.com/artem-ogre/CDT.git + GIT_TAG 1.4.4 + GIT_SHALLOW TRUE + SOURCE_SUBDIR CDT +) +FetchContent_MakeAvailable(CDT_Upstream) + +add_library(cdt_wrapper SHARED cdt_wrapper.cpp) +target_link_libraries(cdt_wrapper PRIVATE CDT) +set_target_properties(cdt_wrapper PROPERTIES + POSITION_INDEPENDENT_CODE ON + # Always place the output directly in the build root directory. + # Without this, multi-config generators (MSVC) put the DLL in + # build/Release/ instead of build/, breaking the MSBuild copy step. + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" +) diff --git a/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/cdt_wrapper.cpp b/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/cdt_wrapper.cpp new file mode 100644 index 0000000..81444c9 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/cdt_wrapper/cdt_wrapper.cpp @@ -0,0 +1,54 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "CDT.h" +#include +#include + +extern "C" { + +/// Triangulate points with optional constraint edges using artem-ogre/CDT. +/// Returns the number of triangles (including super-triangle triangles), +/// or -1 on error. +int32_t cdt_triangulate_d( + const double* xs, + const double* ys, + int32_t n_verts, + const int32_t* edge_v1, + const int32_t* edge_v2, + int32_t n_edges) +{ + try + { + CDT::Triangulation cdt( + CDT::VertexInsertionOrder::Auto, + CDT::IntersectingConstraintEdges::TryResolve, + 0.0); + + std::vector> verts; + verts.reserve(static_cast(n_verts)); + for (int32_t i = 0; i < n_verts; ++i) + verts.push_back(CDT::V2d(xs[i], ys[i])); + cdt.insertVertices(verts); + + if (n_edges > 0) + { + std::vector edges; + edges.reserve(static_cast(n_edges)); + for (int32_t i = 0; i < n_edges; ++i) + edges.emplace_back( + static_cast(edge_v1[i]), + static_cast(edge_v2[i])); + cdt.insertEdges(edges); + } + + return static_cast(cdt.triangles.size()); + } + catch (...) + { + return -1; + } +} + +} // extern "C" diff --git a/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/CMakeLists.txt b/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/CMakeLists.txt new file mode 100644 index 0000000..d1d351c --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/CMakeLists.txt @@ -0,0 +1,97 @@ +cmake_minimum_required(VERSION 3.20) +project(cgal_wrapper CXX) + +# ── CMake policy compatibility ──────────────────────────────────────────────── +# CMP0167 (cmake 3.30+): Use upstream BoostConfig.cmake over legacy FindBoost. +# CMP0169 (cmake 3.30+): Allow FetchContent_Populate() for header-only fetches +# where MakeAvailable would try to run the dependency's own CMakeLists.txt. +foreach(_pol CMP0167 CMP0169) + if(POLICY ${_pol}) + cmake_policy(SET ${_pol} OLD) + endif() +endforeach() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Export all symbols from the DLL on Windows so that the extern "C" entry +# points are visible to P/Invoke without explicit __declspec(dllexport). +set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) + +include(FetchContent) + +# ── Fetch CGAL 6.1.1 header-only library ZIP (~10 MB) ──────────────────────── +# The *-library.zip* is the header-only subset published on each CGAL release +# — far smaller than the full source archive. +FetchContent_Declare( + CGAL_zip + URL https://github.com/CGAL/cgal/releases/download/v6.1.1/CGAL-6.1.1-library.zip + URL_HASH SHA256=6c5d68be1d28cbee3c3e05003746ec4791d0018c770b4276b9e6d69c3a0a355a + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +FetchContent_GetProperties(CGAL_zip) +if(NOT cgal_zip_POPULATED) + FetchContent_Populate(CGAL_zip) +endif() + +# ── Fetch minimal Boost headers from individual boostorg GitHub repos ───────── +# CGAL 6.x needs several header-only Boost sub-libraries. Instead of +# downloading the full Boost archive (>100 MB), we fetch only the repos that +# are actually required. Each individual library ZIP is < 2 MB; total ≈ 20 MB. +# All headers are staged into a single directory so one include path covers all. +set(_BOOST_TAG "boost-1.87.0") + +# Complete list of Boost sub-libraries required by CGAL 6.1.1 CDT (Epick): +set(_BOOST_LIBS + algorithm any array assert concept_check config container container_hash + core describe detail exception foreach functional fusion integer + intrusive io iterator lexical_cast math move mp11 mpl multiprecision + numeric_conversion optional predef preprocessor property_map random + range smart_ptr static_assert throw_exception tuple type_index + type_traits utility +) + +set(_BOOST_STAGING "${CMAKE_BINARY_DIR}/boost_headers") +file(MAKE_DIRECTORY "${_BOOST_STAGING}") + +foreach(_lib IN LISTS _BOOST_LIBS) + set(_fc "boost_${_lib}") + FetchContent_Declare( + ${_fc} + URL "https://github.com/boostorg/${_lib}/archive/refs/tags/${_BOOST_TAG}.zip" + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + FetchContent_GetProperties(${_fc}) + if(NOT ${_fc}_POPULATED) + FetchContent_Populate(${_fc}) + endif() + # Each repo puts its headers under include/ — copy into the staging area + if(EXISTS "${${_fc}_SOURCE_DIR}/include") + file(COPY "${${_fc}_SOURCE_DIR}/include/" + DESTINATION "${_BOOST_STAGING}") + endif() +endforeach() + +# ── Build the shared library ────────────────────────────────────────────────── +add_library(cgal_wrapper SHARED cgal_wrapper.cpp) + +target_include_directories(cgal_wrapper PRIVATE + "${cgal_zip_SOURCE_DIR}/include" + "${_BOOST_STAGING}" +) + +# Tell CGAL to skip GMP/MPFR: the Epick kernel (exact predicates, inexact +# constructions) uses only floating-point interval arithmetic which is +# entirely self-contained in the CGAL headers — no GMP/MPFR needed. +target_compile_definitions(cgal_wrapper PRIVATE CGAL_DISABLE_GMP=1) + +set_target_properties(cgal_wrapper PROPERTIES + POSITION_INDEPENDENT_CODE ON + # Always place the output directly in the build root directory. + # Without this, multi-config generators (MSVC) put the DLL in + # build/Release/ instead of build/, breaking the MSBuild copy step. + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + LIBRARY_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" +) diff --git a/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/cgal_wrapper.cpp b/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/cgal_wrapper.cpp new file mode 100644 index 0000000..098934f --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/cgal_wrapper/cgal_wrapper.cpp @@ -0,0 +1,53 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// CGAL 2D Constrained Delaunay Triangulation wrapper. +// Uses the Exact_predicates_inexact_constructions_kernel (Epick): +// - exact geometric predicates via interval arithmetic (no GMP/MPFR needed) +// - inexact (double) constructions + +#include +#include +#include +#include + +typedef CGAL::Exact_predicates_inexact_constructions_kernel K; +typedef CGAL::Constrained_Delaunay_triangulation_2 CDT; +typedef CDT::Point Point; +typedef CDT::Vertex_handle Vertex_handle; + +extern "C" { + +/// Triangulate points with optional constraint edges using CGAL. +/// Returns the total number of faces (triangles) in the triangulation, +/// or -1 on error. +int32_t cgal_cdt( + const double* xs, + const double* ys, + int32_t n_verts, + const int32_t* edge_v1, + const int32_t* edge_v2, + int32_t n_edges) +{ + try + { + CDT cdt; + + std::vector handles; + handles.reserve(static_cast(n_verts)); + for (int32_t i = 0; i < n_verts; ++i) + handles.push_back(cdt.insert(Point(xs[i], ys[i]))); + + for (int32_t i = 0; i < n_edges; ++i) + cdt.insert_constraint(handles[edge_v1[i]], handles[edge_v2[i]]); + + return static_cast(cdt.number_of_faces()); + } + catch (...) + { + return -1; + } +} + +} // extern "C" diff --git a/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.lock b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.lock new file mode 100644 index 0000000..ae689c8 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.lock @@ -0,0 +1,78 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spade" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spade_wrapper" +version = "0.1.0" +dependencies = [ + "spade", +] diff --git a/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.toml b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.toml new file mode 100644 index 0000000..f29f931 --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "spade_wrapper" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spade = "2.15.0" diff --git a/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/src/lib.rs b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/src/lib.rs new file mode 100644 index 0000000..df58e0b --- /dev/null +++ b/benchmark/CDT.Comparison.Benchmarks/native/spade_wrapper/src/lib.rs @@ -0,0 +1,56 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::panic; +use spade::{ConstrainedDelaunayTriangulation, Point2, Triangulation}; + +/// Triangulate points with optional constraint edges using Spade. +/// Returns the number of inner (finite) triangles, or -1 on error/panic. +/// +/// # Safety +/// All pointer arguments must be valid for `n_verts` / `n_edges` reads. +#[no_mangle] +pub unsafe extern "C" fn spade_cdt( + xs: *const f64, + ys: *const f64, + n_verts: i32, + edge_v1: *const i32, + edge_v2: *const i32, + n_edges: i32, +) -> i32 { + let result = panic::catch_unwind(|| { + let n = n_verts as usize; + let ne = n_edges as usize; + + let mut cdt = ConstrainedDelaunayTriangulation::>::new(); + let mut handles = Vec::with_capacity(n); + + for i in 0..n { + let x = *xs.add(i); + let y = *ys.add(i); + match cdt.insert(Point2::new(x, y)) { + Ok(h) => handles.push(h), + // Duplicate vertex — reuse the previous valid handle so that + // constraint-edge indices still stay in bounds. + Err(_) => { + if let Some(&last) = handles.last() { + handles.push(last); + } + } + } + } + + for i in 0..ne { + let v1 = *edge_v1.add(i) as usize; + let v2 = *edge_v2.add(i) as usize; + if v1 < handles.len() && v2 < handles.len() && v1 != v2 { + let _ = cdt.add_constraint(handles[v1], handles[v2]); + } + } + + cdt.num_inner_faces() as i32 + }); + + result.unwrap_or(-1) +}