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 + + + + + +