diff --git a/.github/scripts/check_file_version.ps1 b/.github/scripts/check_file_version.ps1
index 816a480c007..2d6bda9ac4e 100644
--- a/.github/scripts/check_file_version.ps1
+++ b/.github/scripts/check_file_version.ps1
@@ -45,7 +45,7 @@ $excludedFiles = @(
"libSkiaSharp.dll", # https://jira.autodesk.com/browse/DYN-6598
"LiveChartsCore*.dll",
"Lucene.Net*.dll",
- "MIConvexHull.NET Standard.dll",
+ "MIConvexHull.dll",
"Microsoft.*",
"MimeMapping.dll",
"Moq.dll",
diff --git a/doc/distrib/xml/en-US/Tessellation.XML b/doc/distrib/xml/en-US/Tessellation.XML
index 62822f529eb..5c83fcd186e 100644
--- a/doc/distrib/xml/en-US/Tessellation.XML
+++ b/doc/distrib/xml/en-US/Tessellation.XML
@@ -77,6 +77,19 @@
A set of points.
+
+
+ Utility functions for UV scaling on surfaces.
+
+
+
+
+ Computes normalized UV scaling factors for a surface to handle anisotropic parameter spaces.
+ The scaling preserves the aspect ratio while keeping values in a reasonable numerical range.
+
+ Surface to compute scaling factors for.
+ Tuple containing normalized U and V scale factors.
+
Functions for creating Voronoi tesselations.
diff --git a/src/Dynamo.All.sln b/src/Dynamo.All.sln
index c8bfd2a9c47..9d815fb219c 100644
--- a/src/Dynamo.All.sln
+++ b/src/Dynamo.All.sln
@@ -117,6 +117,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamoUtilitiesTests", "..\
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GeometryColorTests", "..\test\Libraries\GeometryColorTests\GeometryColorTests.csproj", "{F8D2E3E5-4505-4463-9367-2EB2629D7DCD}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TessellationTests", "..\test\Libraries\TessellationTests\TessellationTests.csproj", "{B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PackageManagerTests", "..\test\Libraries\PackageManagerTests\PackageManagerTests.csproj", "{2A70F3B5-7AEA-42FE-8DD5-8427C28D0025}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamoMSOfficeTests", "..\test\Libraries\DynamoMSOfficeTests\DynamoMSOfficeTests.csproj", "{269039F1-126A-46CF-8BEA-257587EDCA6B}"
@@ -522,6 +524,10 @@ Global
{C4964946-B367-44EE-9ED2-451FF2A83D32}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C4964946-B367-44EE-9ED2-451FF2A83D32}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C4964946-B367-44EE-9ED2-451FF2A83D32}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}.Release|Any CPU.Build.0 = Release|Any CPU
{773988FE-EDF6-45CB-A63F-482955EB3553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{773988FE-EDF6-45CB-A63F-482955EB3553}.Debug|Any CPU.Build.0 = Debug|Any CPU
{773988FE-EDF6-45CB-A63F-482955EB3553}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -581,6 +587,7 @@ Global
{4A707244-154B-428F-B589-D65361F24401} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
{AB735028-22B0-4083-9CC5-805C582818EA} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
{F8D2E3E5-4505-4463-9367-2EB2629D7DCD} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
{2A70F3B5-7AEA-42FE-8DD5-8427C28D0025} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
{269039F1-126A-46CF-8BEA-257587EDCA6B} = {4CA0BC62-DCB3-456B-80D6-348247640BAB}
{2C6A6573-28B0-4239-BD33-6AECCD25106C} = {5B9B5B6B-0BA7-4606-B8E5-70C958346D57}
diff --git a/src/Libraries/Tesellation/ConvexHull.cs b/src/Libraries/Tesellation/ConvexHull.cs
index a6bce67c955..b37ddebb5d4 100644
--- a/src/Libraries/Tesellation/ConvexHull.cs
+++ b/src/Libraries/Tesellation/ConvexHull.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Autodesk.DesignScript.Geometry;
using MIConvexHull;
@@ -18,11 +18,10 @@ public static class ConvexHull
public static IEnumerable ByPoints(IEnumerable points)
{
var verts = points.Select(Vertex3.FromPoint).ToList();
- const double DefaultPlaneDistanceTolerance = 1e-6;
- var triResult = ConvexHull.Create(verts, DefaultPlaneDistanceTolerance);
+ var triResult = MIConvexHull.ConvexHull.Create(verts);
// make edges
- foreach (var face in triResult.Faces)
+ foreach (var face in triResult.Result.Faces)
{
// form lines for use in dynamo or revit
var start1 = face.Vertices[0].AsPoint();
diff --git a/src/Libraries/Tesellation/Delaunay.cs b/src/Libraries/Tesellation/Delaunay.cs
index 5fc2169210c..386af52608c 100644
--- a/src/Libraries/Tesellation/Delaunay.cs
+++ b/src/Libraries/Tesellation/Delaunay.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Autodesk.DesignScript.Geometry;
using MIConvexHull;
@@ -19,8 +19,18 @@ public static class Delaunay
/// uvs
public static IEnumerable ByParametersOnSurface(IEnumerable uvs, Surface face)
{
- var verts = uvs.Select(Vertex2.FromUV).ToList();
- var triangulation = DelaunayTriangulation.Create(verts);
+ var uvList = uvs?.ToList();
+ if (uvList == null || uvList.Count == 0 || face == null)
+ yield break;
+
+ // Get normalized UV scaling factors to handle anisotropic parameter spaces
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+
+ // Build Delaunay in anisotropically scaled (u*normU, v*normV) space.
+ var verts = uvList.Select(uv => new Vertex2(uv.U * normU, uv.V * normV)).ToList();
+
+ const double PlaneDistanceTolerance = 1e-6;
+ var triangulation = DelaunayTriangulation.Create(verts, PlaneDistanceTolerance);
// there are three vertices per cell in 2D
foreach (var cell in triangulation.Cells)
@@ -29,9 +39,9 @@ public static IEnumerable ByParametersOnSurface(IEnumerable uvs, Surf
var v2 = cell.Vertices[1].AsVector();
var v3 = cell.Vertices[2].AsVector();
- var xyz1 = face.PointAtParameter(v1.X, v1.Y);
- var xyz2 = face.PointAtParameter(v2.X, v2.Y);
- var xyz3 = face.PointAtParameter(v3.X, v3.Y);
+ var xyz1 = face.PointAtParameter(v1.X / normU, v1.Y / normV);
+ var xyz2 = face.PointAtParameter(v2.X / normU, v2.Y / normV);
+ var xyz3 = face.PointAtParameter(v3.X / normU, v3.Y / normV);
if (xyz1.DistanceTo(xyz2) > 0.1)
{
@@ -41,14 +51,14 @@ public static IEnumerable ByParametersOnSurface(IEnumerable uvs, Surf
if (xyz2.DistanceTo(xyz3) > 0.1)
{
- var l1 = Line.ByStartPointEndPoint(xyz3, xyz2);
- yield return l1;
+ var l2 = Line.ByStartPointEndPoint(xyz3, xyz2);
+ yield return l2;
}
if (xyz3.DistanceTo(xyz1) > 0.1)
{
- var l1 = Line.ByStartPointEndPoint(xyz1, xyz3);
- yield return l1;
+ var l3 = Line.ByStartPointEndPoint(xyz1, xyz3);
+ yield return l3;
}
}
}
@@ -60,7 +70,8 @@ public static IEnumerable ByParametersOnSurface(IEnumerable uvs, Surf
public static IEnumerable ByPoints(IEnumerable points)
{
var verts = points.Select(Vertex3.FromPoint).ToList();
- var triResult = DelaunayTriangulation.Create(verts);
+ const double PlaneDistanceTolerance = 1e-6;
+ var triResult = DelaunayTriangulation.Create(verts, PlaneDistanceTolerance);
// make edges
foreach (var cell in triResult.Cells)
@@ -88,4 +99,4 @@ public static IEnumerable ByPoints(IEnumerable points)
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Libraries/Tesellation/Properties/AssemblyInfo.cs b/src/Libraries/Tesellation/Properties/AssemblyInfo.cs
index 57f5acfe7ec..356c158e4bb 100644
--- a/src/Libraries/Tesellation/Properties/AssemblyInfo.cs
+++ b/src/Libraries/Tesellation/Properties/AssemblyInfo.cs
@@ -1,4 +1,5 @@
-using System.Reflection;
+using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
@@ -9,3 +10,6 @@
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("89ae6d07-95ef-49e7-b607-22dc7a7013cf")]
+
+// Make internal types visible to test assembly
+[assembly: InternalsVisibleTo("TessellationTests")]
diff --git a/src/Libraries/Tesellation/Tessellation.csproj b/src/Libraries/Tesellation/Tessellation.csproj
index b289ca45785..5df081b13e2 100644
--- a/src/Libraries/Tesellation/Tessellation.csproj
+++ b/src/Libraries/Tesellation/Tessellation.csproj
@@ -1,4 +1,4 @@
-
+
@@ -14,10 +14,9 @@
MSB3539;CS1591;NUnit2005;NUnit2007;CS0618;CS0612;CS0672
+
+
-
-
-
diff --git a/src/Libraries/Tesellation/UvScalingUtilities.cs b/src/Libraries/Tesellation/UvScalingUtilities.cs
new file mode 100644
index 00000000000..8ab39cc9b9d
--- /dev/null
+++ b/src/Libraries/Tesellation/UvScalingUtilities.cs
@@ -0,0 +1,43 @@
+using Autodesk.DesignScript.Geometry;
+
+namespace Tessellation
+{
+ ///
+ /// Utility functions for UV scaling on surfaces.
+ ///
+ internal static class UvScalingUtilities
+ {
+ ///
+ /// Computes normalized UV scaling factors for a surface to handle anisotropic parameter spaces.
+ /// The scaling preserves the aspect ratio while keeping values in a reasonable numerical range.
+ ///
+ /// Surface to compute scaling factors for.
+ /// Tuple containing normalized U and V scale factors.
+ internal static (double normU, double normV) GetNormalizedUvScales(Surface face)
+ {
+ // Physical scale per unit U/V based on iso-curve lengths along surface edges.
+ double scaleU;
+ double scaleV;
+ using (var uCurveV0 = face.GetIsoline(0, 0))
+ using (var uCurveV1 = face.GetIsoline(0, 1))
+ using (var vCurveU0 = face.GetIsoline(1, 0))
+ using (var vCurveU1 = face.GetIsoline(1, 1))
+ {
+ scaleU = (uCurveV0.Length + uCurveV1.Length) / 2.0;
+ scaleV = (vCurveU0.Length + vCurveU1.Length) / 2.0;
+ }
+
+ // Normalize scales to keep values in a reasonable range, preserve aspect ratio
+ var max = System.Math.Max(scaleU, scaleV);
+ if (max <= 1e-9) max = 1.0;
+
+ var normU = scaleU / max;
+ var normV = scaleV / max;
+
+ if (normU <= 1e-9) normU = 1.0;
+ if (normV <= 1e-9) normV = 1.0;
+
+ return (normU, normV);
+ }
+ }
+}
diff --git a/src/Libraries/Tesellation/Voronoi.cs b/src/Libraries/Tesellation/Voronoi.cs
index 95c429aeb73..1ba1517bcac 100644
--- a/src/Libraries/Tesellation/Voronoi.cs
+++ b/src/Libraries/Tesellation/Voronoi.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Autodesk.DesignScript.Geometry;
using MIConvexHull;
@@ -19,16 +19,34 @@ public static class Voronoi
/// uvs
public static IEnumerable ByParametersOnSurface(IEnumerable uvs, Surface face)
{
- var verts = uvs.Select(Vertex2.FromUV).ToList();
+ var uvList = uvs?.ToList();
+ if (uvList == null || uvList.Count == 0 || face == null)
+ yield break;
+
+ // Get normalized UV scaling factors to handle anisotropic parameter spaces
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+
+ // Anisotropic scaling only by aspect ratio
+ var verts = uvList.Select(uv => new Vertex2(uv.U * normU, uv.V * normV)).ToList();
var voronoiMesh = VoronoiMesh.Create(verts);
- return from edge in voronoiMesh.Edges
- let _from = edge.Source.Circumcenter
- let to = edge.Target.Circumcenter
- let start = face.PointAtParameter(_from.X, _from.Y)
- let end = face.PointAtParameter(to.X, to.Y)
- where start.DistanceTo(end) > 0.1
- select Line.ByStartPointEndPoint(start, end);
+ foreach (var edge in voronoiMesh.Edges)
+ {
+ // Map circumcenters back to UV
+ var cFrom = edge.Source.Circumcenter;
+ var cTo = edge.Target.Circumcenter;
+
+ var u1 = cFrom.X / normU;
+ var v1 = cFrom.Y / normV;
+ var u2 = cTo.X / normU;
+ var v2 = cTo.Y / normV;
+
+ var start = face.PointAtParameter(u1, v1);
+ var end = face.PointAtParameter(u2, v2);
+
+ if (start.DistanceTo(end) > 0.1)
+ yield return Line.ByStartPointEndPoint(start, end);
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/test/Libraries/TessellationTests/DelaunayVoronoiOnSurfaceTests.cs b/test/Libraries/TessellationTests/DelaunayVoronoiOnSurfaceTests.cs
new file mode 100644
index 00000000000..450ccacfd19
--- /dev/null
+++ b/test/Libraries/TessellationTests/DelaunayVoronoiOnSurfaceTests.cs
@@ -0,0 +1,518 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Autodesk.DesignScript.Geometry;
+using MIConvexHull;
+using NUnit.Framework;
+using Tessellation;
+using Tessellation.Adapters;
+
+namespace Dynamo.Tests
+{
+ [TestFixture]
+ public class DelaunayVoronoiOnSurfaceTests : DynamoModelTestBase
+ {
+ private const double Epsilon = 1e-9;
+
+ // Validates that Delaunay.ByParametersOnSurface produces the expected triangulation edges
+ // (in world space) on a strongly anisotropic surface, and that the triangulation satisfies
+ // the empty-circumcircle property in the same scaled UV space used by production.
+ [Test]
+ public void Delaunay_ByParametersOnSurface_SatisfiesEmptyCircumcircleProperty_WithScaledSurface()
+ {
+ using var surface = CreateSurface(width: 1000, height: 1); // extremely anisotropic to exercise UV scaling
+ var uvs = CreateUvGrid(countU: 5, countV: 5);
+
+ var triangles = ComputeDelaunayTrianglesInScaledUvSpace(uvs, surface);
+ Assert.That(triangles.Count, Is.GreaterThan(0));
+
+ AssertTrianglesSatisfyEmptyCircumcircle(triangles);
+
+ // Basic sanity: API returns some edges.
+ var edges = Delaunay.ByParametersOnSurface(uvs, surface).ToList();
+ Assert.That(edges.Count, Is.GreaterThan(0));
+
+ // Reconstruct triangles from API edges and verify empty circumcircle on production output.
+ AssertApiEdgesSatisfyEmptyCircumcircle(uvs, surface, edges);
+
+ // Validate that the API output matches the edges implied by the Delaunay triangles.
+ AssertDelaunayEdgesMatchTriangulation(triangles, surface, edges);
+ }
+
+ // Validates that Voronoi.ByParametersOnSurface returns edges whose vertices behave like the
+ // dual of a scaled-UV Delaunay triangulation. The assertion compares API vertices against
+ // scaled-vs-unscaled dual circumcenters and requires scaled geometry to be measurably closer.
+ [Test]
+ public void Voronoi_ByParametersOnSurface_ProducesEdges_AndDelaunayDualIsValid_WithScaledSurface()
+ {
+ using var surface = CreateSurface(width: 1000, height: 1); // extremely anisotropic to exercise UV scaling
+ var uvs = CreateUvGrid(countU: 5, countV: 5);
+
+ // Voronoi edges should be generated.
+ var edges = Voronoi.ByParametersOnSurface(uvs, surface).ToList();
+ Assert.That(edges.Count, Is.GreaterThan(0));
+
+ // Voronoi is the dual of Delaunay. The Voronoi vertices are the circumcenters of the Delaunay triangles.
+ // Compute the correct circumcenters in scaled UV space (as Delaunay does).
+ var triangles = ComputeDelaunayTrianglesInScaledUvSpace(uvs, surface);
+ Assert.That(triangles.Count, Is.GreaterThan(0));
+
+ // Validate that Delaunay triangulation satisfies empty circumcircle property.
+ AssertTrianglesSatisfyEmptyCircumcircle(triangles);
+
+ // Validate that the API output matches the Voronoi edges implied by the Delaunay dual.
+ AssertVoronoiEdgesMatchDelaunayDual(uvs, triangles, surface, edges);
+ }
+
+ private static Surface CreateSurface(double width, double height)
+ {
+ // Construct a planar surface whose param space is [0,1]x[0,1] with strong anisotropy in world units.
+ using var rect = Rectangle.ByWidthLength(width, height);
+ return Surface.ByPatch(rect);
+ }
+
+ private static List CreateUvGrid(int countU, int countV)
+ {
+ if (countU < 2) throw new ArgumentOutOfRangeException(nameof(countU));
+ if (countV < 2) throw new ArgumentOutOfRangeException(nameof(countV));
+
+ var uvs = new List(countU * countV);
+ for (int i = 0; i < countU; i++)
+ {
+ var u = i / (double)(countU - 1);
+ for (int j = 0; j < countV; j++)
+ {
+ var v = j / (double)(countV - 1);
+ // Slightly jitter interior points to reduce degenerate cocircular cases.
+ if (i != 0 && i != countU - 1 && j != 0 && j != countV - 1)
+ {
+ u += (i + j) * 1e-6;
+ v += (i - j) * 1e-6;
+ }
+
+ uvs.Add(UV.ByCoordinates(u, v));
+ }
+ }
+
+ return uvs;
+ }
+
+ ///
+ /// Extracts triangles from the actual Delaunay implementation using the same algorithm as production code.
+ /// This calls the real MIConvexHull library that Delaunay.ByParametersOnSurface uses internally.
+ ///
+ private static List ComputeDelaunayTrianglesInScaledUvSpace(IReadOnlyList uvs, Surface face)
+ {
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+
+ return ComputeDelaunayTrianglesInUvSpace(uvs, normU, normV);
+ }
+
+ private static List ComputeDelaunayTrianglesInUnscaledUvSpace(IReadOnlyList uvs)
+ {
+ return ComputeDelaunayTrianglesInUvSpace(uvs, 1.0, 1.0);
+ }
+
+ private static List ComputeDelaunayTrianglesInUvSpace(IReadOnlyList uvs, double scaleU, double scaleV)
+ {
+ // Use the same triangulation algorithm as the production Delaunay.ByParametersOnSurface method.
+ var verts = uvs.Select(uv => new Vertex2(uv.U * scaleU, uv.V * scaleV)).ToList();
+
+ const double PlaneDistanceTolerance = 1e-6;
+ var triangulation = DelaunayTriangulation.Create(verts, PlaneDistanceTolerance);
+
+ // Convert each cell (triangle) to our test Triangle structure
+ var triangles = new List();
+ foreach (var cell in triangulation.Cells)
+ {
+ var v1 = cell.Vertices[0].AsVector();
+ var v2 = cell.Vertices[1].AsVector();
+ var v3 = cell.Vertices[2].AsVector();
+
+ var a = new Pt(v1.X, v1.Y);
+ var b = new Pt(v2.X, v2.Y);
+ var c = new Pt(v3.X, v3.Y);
+
+ triangles.Add(new Triangle(a, b, c));
+ }
+
+ return triangles;
+ }
+
+ private static void AssertTrianglesSatisfyEmptyCircumcircle(IReadOnlyList triangles)
+ {
+ var allPoints = triangles.SelectMany(t => t.Vertices).Distinct().ToList();
+
+ foreach (var t in triangles)
+ {
+ var (center, radius) = t.Circumcircle;
+
+ foreach (var p in allPoints)
+ {
+ if (t.ContainsVertex(p))
+ continue;
+
+ var dist = center.DistanceTo(p);
+ Assert.That(dist + 1e-9, Is.GreaterThanOrEqualTo(radius),
+ $"Point should be outside circumcircle. dist={dist}, radius={radius}, triangle={t}");
+ }
+ }
+ }
+
+ private static void AssertVoronoiEdgesMatchDelaunayDual(
+ IReadOnlyList uvs,
+ IReadOnlyList triangles,
+ Surface face,
+ IReadOnlyList apiEdges)
+ {
+ const double spatialTolerance = 1e-2;
+ const double requiredRelativeImprovement = 0.003;
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+ var unscaledTriangles = ComputeDelaunayTrianglesInUnscaledUvSpace(uvs);
+
+ var expectedScaledVertices = new List(triangles.Count);
+ foreach (var t in triangles)
+ {
+ var (center, _) = t.Circumcircle;
+ var unscaledU = center.X / normU;
+ var unscaledV = center.Y / normV;
+ using var point = face.PointAtParameter(unscaledU, unscaledV);
+ AddUniquePoint(expectedScaledVertices, new WorldPoint(point.X, point.Y, point.Z), spatialTolerance);
+ }
+
+ var expectedUnscaledVertices = new List(unscaledTriangles.Count);
+ foreach (var t in unscaledTriangles)
+ {
+ var (center, _) = t.Circumcircle;
+ using var point = face.PointAtParameter(center.X, center.Y);
+ AddUniquePoint(expectedUnscaledVertices, new WorldPoint(point.X, point.Y, point.Z), spatialTolerance);
+ }
+
+ var apiVertices = new List();
+ foreach (var edge in apiEdges)
+ {
+ using var start = edge.StartPoint;
+ using var end = edge.EndPoint;
+
+ AddUniquePoint(apiVertices, new WorldPoint(start.X, start.Y, start.Z), spatialTolerance);
+ AddUniquePoint(apiVertices, new WorldPoint(end.X, end.Y, end.Z), spatialTolerance);
+ edge.Dispose();
+ }
+
+ Assert.That(apiVertices.Count, Is.GreaterThan(0));
+
+ var meanDistanceToScaled = apiVertices
+ .Select(api => expectedScaledVertices.Min(expected => expected.DistanceTo(api)))
+ .Average();
+
+ var meanDistanceToUnscaled = apiVertices
+ .Select(api => expectedUnscaledVertices.Min(expected => expected.DistanceTo(api)))
+ .Average();
+
+ var relativeImprovement = (meanDistanceToUnscaled - meanDistanceToScaled) /
+ Math.Max(meanDistanceToUnscaled, 1e-9);
+
+ Assert.That(relativeImprovement, Is.GreaterThanOrEqualTo(requiredRelativeImprovement),
+ $"Voronoi dual mismatch. API Voronoi vertices are not sufficiently closer to scaled-dual " +
+ $"circumcenters than to unscaled-dual circumcenters. meanScaled={meanDistanceToScaled:F6}, " +
+ $"meanUnscaled={meanDistanceToUnscaled:F6}, relativeImprovement={relativeImprovement:F6}, " +
+ $"requiredRelativeImprovement={requiredRelativeImprovement:F6}.");
+ }
+
+ private static void AssertDelaunayEdgesMatchTriangulation(
+ IReadOnlyList triangles,
+ Surface face,
+ IReadOnlyList apiEdges)
+ {
+ const double spatialTolerance = 1e-3;
+ const double minEdgeLength = 0.1;
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+
+ var expectedEdges = new List();
+ void AddExpectedEdge(Pt a, Pt b)
+ {
+ using var start = face.PointAtParameter(a.X / normU, a.Y / normV);
+ using var end = face.PointAtParameter(b.X / normU, b.Y / normV);
+
+ if (start.DistanceTo(end) <= minEdgeLength)
+ return;
+
+ AddUniqueSegment(expectedEdges, new WorldSegment(
+ new WorldPoint(start.X, start.Y, start.Z),
+ new WorldPoint(end.X, end.Y, end.Z)), spatialTolerance);
+ }
+
+ foreach (var t in triangles)
+ {
+ AddExpectedEdge(t.A, t.B);
+ AddExpectedEdge(t.B, t.C);
+ AddExpectedEdge(t.C, t.A);
+ }
+
+ var remainingApiEdges = new List();
+ foreach (var edge in apiEdges)
+ {
+ using var start = edge.StartPoint;
+ using var end = edge.EndPoint;
+ AddUniqueSegment(remainingApiEdges, new WorldSegment(
+ new WorldPoint(start.X, start.Y, start.Z),
+ new WorldPoint(end.X, end.Y, end.Z)), spatialTolerance);
+ }
+
+ try
+ {
+ foreach (var expected in expectedEdges)
+ {
+ var matchIndex = remainingApiEdges.FindIndex(e => e.Matches(expected, spatialTolerance));
+ if (matchIndex < 0)
+ {
+ Assert.Fail("Expected Delaunay edge was not found in API output.");
+ }
+
+ remainingApiEdges.RemoveAt(matchIndex);
+ }
+
+ if (remainingApiEdges.Count > 0)
+ {
+ Assert.Fail("API returned extra Delaunay edges not present in triangulation.");
+ }
+ }
+ finally
+ {
+ foreach (var edge in apiEdges)
+ edge.Dispose();
+ }
+ }
+
+ private static void AssertApiEdgesSatisfyEmptyCircumcircle(
+ IReadOnlyList uvs,
+ Surface face,
+ IReadOnlyList apiEdges)
+ {
+ const double vertexMatchTolerance = 1e-3;
+ const double edgeLengthTolerance = 1e-9;
+ var (normU, normV) = UvScalingUtilities.GetNormalizedUvScales(face);
+
+ var uvVertices = new List(uvs.Count);
+ foreach (var uv in uvs)
+ {
+ using var worldPoint = face.PointAtParameter(uv.U, uv.V);
+ uvVertices.Add(new UvVertex(
+ new Pt(uv.U * normU, uv.V * normV),
+ new WorldPoint(worldPoint.X, worldPoint.Y, worldPoint.Z)));
+ }
+
+ var edgeSet = new HashSet();
+ foreach (var edge in apiEdges)
+ {
+ using var start = edge.StartPoint;
+ using var end = edge.EndPoint;
+
+ var startIndex = FindClosestVertexIndex(
+ uvVertices,
+ new WorldPoint(start.X, start.Y, start.Z),
+ vertexMatchTolerance);
+ var endIndex = FindClosestVertexIndex(
+ uvVertices,
+ new WorldPoint(end.X, end.Y, end.Z),
+ vertexMatchTolerance);
+
+ if (startIndex == endIndex)
+ continue;
+
+ var a = uvVertices[startIndex].ScaledUv;
+ var b = uvVertices[endIndex].ScaledUv;
+ if (a.DistanceTo(b) <= edgeLengthTolerance)
+ continue;
+
+ edgeSet.Add(new Edge(a, b));
+ }
+
+ var triangles = BuildTrianglesFromEdges(edgeSet);
+ Assert.That(triangles.Count, Is.GreaterThan(0), "Could not reconstruct any triangles from API edges.");
+
+ AssertTrianglesSatisfyEmptyCircumcircle(triangles);
+ }
+
+ private static List BuildTrianglesFromEdges(HashSet edgeSet)
+ {
+ var points = edgeSet
+ .SelectMany(e => new[] { e.A, e.B })
+ .Distinct()
+ .ToList();
+
+ var pointIndex = points
+ .Select((point, index) => (point, index))
+ .ToDictionary(x => x.point, x => x.index);
+
+ var adjacency = points.Select(_ => new HashSet()).ToList();
+ foreach (var edge in edgeSet)
+ {
+ var ia = pointIndex[edge.A];
+ var ib = pointIndex[edge.B];
+ adjacency[ia].Add(ib);
+ adjacency[ib].Add(ia);
+ }
+
+ var triangles = new List();
+ for (var i = 0; i < points.Count; i++)
+ {
+ for (var j = i + 1; j < points.Count; j++)
+ {
+ if (!adjacency[i].Contains(j))
+ continue;
+
+ for (var k = j + 1; k < points.Count; k++)
+ {
+ if (!adjacency[i].Contains(k) || !adjacency[j].Contains(k))
+ continue;
+
+ triangles.Add(new Triangle(points[i], points[j], points[k]));
+ }
+ }
+ }
+
+ return triangles;
+ }
+
+ private static int FindClosestVertexIndex(
+ IReadOnlyList vertices,
+ WorldPoint candidate,
+ double tolerance)
+ {
+ var bestDistance = double.PositiveInfinity;
+ var bestIndex = -1;
+
+ for (var i = 0; i < vertices.Count; i++)
+ {
+ var distance = vertices[i].World.DistanceTo(candidate);
+ if (distance < bestDistance)
+ {
+ bestDistance = distance;
+ bestIndex = i;
+ }
+ }
+
+ if (bestIndex < 0 || bestDistance > tolerance)
+ {
+ Assert.Fail($"API edge endpoint could not be mapped to a UV input vertex. bestDistance={bestDistance}");
+ }
+
+ return bestIndex;
+ }
+
+ private static void AddUniqueSegment(List segments, WorldSegment candidate, double tol)
+ {
+ if (!segments.Any(existing => existing.Matches(candidate, tol)))
+ segments.Add(candidate);
+ }
+
+ private static void AddUniquePoint(List points, WorldPoint candidate, double tol)
+ {
+ if (!points.Any(existing => existing.DistanceTo(candidate) <= tol))
+ points.Add(candidate);
+ }
+
+ private readonly record struct Pt(double X, double Y)
+ {
+ public double DistanceTo(Pt other)
+ {
+ var dx = X - other.X;
+ var dy = Y - other.Y;
+ return Math.Sqrt(dx * dx + dy * dy);
+ }
+ }
+
+ private readonly record struct Edge(Pt A, Pt B)
+ {
+ public bool Equals(Edge other) => (A.Equals(other.A) && B.Equals(other.B)) || (A.Equals(other.B) && B.Equals(other.A));
+ public override int GetHashCode() => A.GetHashCode() ^ B.GetHashCode();
+ }
+
+ private readonly record struct WorldPoint(double X, double Y, double Z)
+ {
+ public double DistanceTo(WorldPoint other)
+ {
+ var dx = X - other.X;
+ var dy = Y - other.Y;
+ var dz = Z - other.Z;
+ return Math.Sqrt(dx * dx + dy * dy + dz * dz);
+ }
+ }
+
+ private readonly record struct WorldSegment(WorldPoint Start, WorldPoint End)
+ {
+ public bool Matches(WorldSegment other, double tol)
+ {
+ var sameDirection = Start.DistanceTo(other.Start) <= tol && End.DistanceTo(other.End) <= tol;
+ var reverseDirection = Start.DistanceTo(other.End) <= tol && End.DistanceTo(other.Start) <= tol;
+ return sameDirection || reverseDirection;
+ }
+ }
+
+ private readonly record struct UvVertex(Pt ScaledUv, WorldPoint World);
+
+ private sealed class Triangle
+ {
+ public Triangle(Pt a, Pt b, Pt c)
+ {
+ A = a;
+ B = b;
+ C = c;
+ Circumcircle = ComputeCircumcircle(a, b, c);
+ Edges = new[] { new Edge(a, b), new Edge(b, c), new Edge(c, a) };
+ Vertices = new[] { a, b, c };
+ }
+
+ public Pt A { get; }
+ public Pt B { get; }
+ public Pt C { get; }
+
+ public (Pt center, double radius) Circumcircle { get; }
+
+ public IReadOnlyList Edges { get; }
+ public IReadOnlyList Vertices { get; }
+
+ public bool ContainsVertex(Pt p) => A.Equals(p) || B.Equals(p) || C.Equals(p);
+
+ public bool IsPointInCircumcircle(Pt p, double eps)
+ {
+ var (center, radius) = Circumcircle;
+ var dist = center.DistanceTo(p);
+ return dist <= radius - eps;
+ }
+
+ private static (Pt center, double radius) ComputeCircumcircle(Pt a, Pt b, Pt c)
+ {
+ // Compute circumcenter for triangle (a,b,c).
+ var ax = a.X;
+ var ay = a.Y;
+ var bx = b.X;
+ var by = b.Y;
+ var cx = c.X;
+ var cy = c.Y;
+
+ var d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
+ if (Math.Abs(d) <= 1e-20)
+ {
+ // Degenerate triangle; return huge circle to avoid false failures.
+ var center = new Pt((ax + bx + cx) / 3.0, (ay + by + cy) / 3.0);
+ return (center, double.PositiveInfinity);
+ }
+
+ var ax2ay2 = ax * ax + ay * ay;
+ var bx2by2 = bx * bx + by * by;
+ var cx2cy2 = cx * cx + cy * cy;
+
+ var ux = (ax2ay2 * (by - cy) + bx2by2 * (cy - ay) + cx2cy2 * (ay - by)) / d;
+ var uy = (ax2ay2 * (cx - bx) + bx2by2 * (ax - cx) + cx2cy2 * (bx - ax)) / d;
+
+ var centerPt = new Pt(ux, uy);
+ var radius = centerPt.DistanceTo(a);
+ return (centerPt, radius);
+ }
+
+ public override string ToString() => $"({A})-({B})-({C})";
+ }
+ }
+}
diff --git a/test/Libraries/TessellationTests/TessellationTests.csproj b/test/Libraries/TessellationTests/TessellationTests.csproj
new file mode 100644
index 00000000000..2e31c7b5ac2
--- /dev/null
+++ b/test/Libraries/TessellationTests/TessellationTests.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+
+ {B5C64B59-6D12-4B8A-9A7B-1C0C5E7A1A2F}
+ Library
+ Properties
+ Dynamo.Tests.Tessellation
+ TessellationTests
+
+
+
+
+
+
+
+
+
+ {7858fa8c-475f-4b8e-b468-1f8200778cf8}
+ DynamoCore
+ False
+
+
+ {D6279651-D099-4F8D-A319-5BF12ED9F269}
+ Tessellation
+ False
+
+
+ {472084ed-1067-4b2c-8737-3839a6143eb2}
+ DynamoCoreTests
+ False
+
+
+ {6cd0f0cf-8199-49f9-b0ea-0b9598b44419}
+ TestServices
+ False
+
+
+
+
+
+