From 8003358f5f79424ac2b13b0c901a307b18b6293f Mon Sep 17 00:00:00 2001 From: V <61495413+kelo221@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:17:18 +0300 Subject: [PATCH] fix(polybool): split T-junction singularities Refs Henry00IS/ShapeEditor#17 --- .../PolyBoolCS/PolyboolExtensions.cs | 98 ++++++++++++++++++- Tests.meta | 8 ++ Tests/Editor.meta | 8 ++ Tests/Editor/PolyboolExtensionsTests.cs | 90 +++++++++++++++++ Tests/Editor/PolyboolExtensionsTests.cs.meta | 2 + ...ernumgames.shapeeditor.tests.editor.asmdef | 18 ++++ ...games.shapeeditor.tests.editor.asmdef.meta | 7 ++ 7 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 Tests.meta create mode 100644 Tests/Editor.meta create mode 100644 Tests/Editor/PolyboolExtensionsTests.cs create mode 100644 Tests/Editor/PolyboolExtensionsTests.cs.meta create mode 100644 Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef create mode 100644 Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef.meta diff --git a/Scripts/ShapeEditor/Decomposition/PolyBoolCS/PolyboolExtensions.cs b/Scripts/ShapeEditor/Decomposition/PolyBoolCS/PolyboolExtensions.cs index e61ea2b..13f54b5 100644 --- a/Scripts/ShapeEditor/Decomposition/PolyBoolCS/PolyboolExtensions.cs +++ b/Scripts/ShapeEditor/Decomposition/PolyBoolCS/PolyboolExtensions.cs @@ -44,13 +44,18 @@ public static List ToPolygons(this PolyboolPolygon poly, PolyBool polyB var roots = newNode(null); - // add all regions to the root + // add all regions to the root. PolyBool can return a weakly-simple + // region when two filled areas touch at a single point. Split those + // regions before building the hierarchy so convex decomposition only + // receives simple polygons. for (var i = 0; i < poly.regions.Count; i++) { var region = poly.regions[i]; if (region.Count < 3) // regions must have at least 3 points (sanity check) continue; - addChild(roots, region); + + foreach (var simpleRegion in splitSingularities(region)) + addChild(roots, simpleRegion); } // with our heirarchy, we can distinguish between exterior borders, and interior holes @@ -80,6 +85,93 @@ public static List ToPolygons(this PolyboolPolygon poly, PolyBool polyB return sePolys; } + private static List splitSingularities(Region region) + { + var regions = new List() { region }; + + for (var i = 0; i < regions.Count;) + { + if (trySplitSingularity(regions[i], out var first, out var second)) + { + regions[i] = first; + regions.Insert(i + 1, second); + continue; + } + + i++; + } + + return regions; + } + + private static bool trySplitSingularity(Region region, out Region first, out Region second) + { + first = null; + second = null; + + var count = region.Count; + for (var singularityIndex = 0; singularityIndex < count; singularityIndex++) + { + var singularity = region[singularityIndex]; + + for (var edgeStartIndex = 0; edgeStartIndex < count; edgeStartIndex++) + { + var edgeEndIndex = (edgeStartIndex + 1) % count; + if (edgeStartIndex == singularityIndex || edgeEndIndex == singularityIndex) + continue; + + var edgeStart = region[edgeStartIndex]; + var edgeEnd = region[edgeEndIndex]; + if (!Epsilon.pointsCollinear(edgeStart, singularity, edgeEnd) || + !Epsilon.pointBetween(singularity, edgeStart, edgeEnd)) + { + continue; + } + + var firstCount = 2 + CountPathVertices(singularityIndex, edgeStartIndex, count); + var secondCount = 2 + CountPathVertices(edgeEndIndex, singularityIndex, count); + if (firstCount < 3 || secondCount < 3) + continue; + + first = new Region(firstCount); + first.Add(singularity); + AddPathVertices(first, region, singularityIndex, edgeStartIndex); + + second = new Region(secondCount); + second.Add(singularity); + second.Add(edgeEnd); + AddPathVerticesBeforeEnd(second, region, edgeEndIndex, singularityIndex); + return true; + } + } + + return false; + } + + private static int CountPathVertices(int startIndex, int endIndex, int count) + { + var result = 0; + for (var index = (startIndex + 1) % count; index != endIndex; index = (index + 1) % count) + result++; + return result; + } + + private static void AddPathVertices(Region destination, Region source, int startIndex, int endIndex) + { + var count = source.Count; + for (var index = (startIndex + 1) % count; index != endIndex; index = (index + 1) % count) + destination.Add(source[index]); + + destination.Add(source[endIndex]); + } + + private static void AddPathVerticesBeforeEnd(Region destination, Region source, int startIndex, int endIndex) + { + var count = source.Count; + for (var index = (startIndex + 1) % count; index != endIndex; index = (index + 1) % count) + destination.Add(source[index]); + } + // test if r1 is inside r2 private static bool regionInsideRegion(Region r1, Region r2) { @@ -187,4 +279,4 @@ private static Region getInterior(MyNode node, List polygons) } } -#endif \ No newline at end of file +#endif diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..e63a97e --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ee090d9efe3464eb852e034c5e08bad +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..acac9d3 --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eba68ef4aa768504e9b89164fafb1fab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/PolyboolExtensionsTests.cs b/Tests/Editor/PolyboolExtensionsTests.cs new file mode 100644 index 0000000..3691876 --- /dev/null +++ b/Tests/Editor/PolyboolExtensionsTests.cs @@ -0,0 +1,90 @@ +#if UNITY_EDITOR + +using AeternumGames.ShapeEditor.PolyBoolCS; +using NUnit.Framework; +using System.Collections.Generic; +using UnityEngine.TestTools; +using PolyboolPolygon = AeternumGames.ShapeEditor.PolyBoolCS.Polygon; +using Region = AeternumGames.ShapeEditor.PolyBoolCS.PointList; + +namespace AeternumGames.ShapeEditor.Tests +{ + public class PolyboolExtensionsTests + { + [Test] + public void ToPolygons_VertexOnNonAdjacentEdge_SplitsIntoSimpleRegions() + { + var polyBool = new PolyBool(); + var singularPolygon = new PolyboolPolygon + { + regions = new List + { + new Region + { + new Point(-0.5, 0.5), + new Point(-0.5, -0.5), + new Point(0.0, -0.5), + new Point(-0.5, 0.0), + new Point(0.0, 0.5) + } + } + }; + + var polygons = singularPolygon.ToPolygons(polyBool); + + Assert.That(polygons, Has.Count.EqualTo(2)); + + var totalArea = 0.0f; + foreach (var polygon in polygons) + { + Assert.That(polygon, Has.Count.EqualTo(3)); + Assert.That(polygon.IsCounterClockWise2D(), Is.True); + totalArea += polygon.GetSignedArea2D(); + } + + Assert.That(totalArea, Is.EqualTo(0.25f).Within(0.000001f)); + } + + [Test] + public void GenerateConvexPolygons_PointTouchingShapes_DoesNotLogAndReturnsFourTriangles() + { + var project = new Project + { + shapes = new List + { + CreateTriangle(-0.5f, -0.5f, 0.0f, -0.5f, -0.5f, 0.0f), + CreateTriangle(0.0f, -0.5f, 0.5f, -0.5f, 0.5f, 0.0f), + CreateTriangle(0.5f, 0.0f, 0.5f, 0.5f, 0.0f, 0.5f), + CreateTriangle(-0.5f, 0.0f, 0.0f, 0.5f, -0.5f, 0.5f) + } + }; + project.Validate(); + + var polygons = project.GenerateConvexPolygons(); + + Assert.That(polygons, Has.Count.EqualTo(4)); + foreach (var polygon in polygons) + { + Assert.That(polygon, Has.Count.EqualTo(3)); + Assert.That(polygon.IsCounterClockWise2D(), Is.True); + } + + LogAssert.NoUnexpectedReceived(); + } + + private static Shape CreateTriangle( + float x1, float y1, + float x2, float y2, + float x3, float y3) + { + var shape = new Shape(); + shape.segments.Clear(); + shape.AddSegment(new Segment(shape, x1, y1)); + shape.AddSegment(new Segment(shape, x2, y2)); + shape.AddSegment(new Segment(shape, x3, y3)); + return shape; + } + } +} + +#endif diff --git a/Tests/Editor/PolyboolExtensionsTests.cs.meta b/Tests/Editor/PolyboolExtensionsTests.cs.meta new file mode 100644 index 0000000..112cbc9 --- /dev/null +++ b/Tests/Editor/PolyboolExtensionsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f02c2f8212d91514894f7cc4fafe3d2b diff --git a/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef b/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef new file mode 100644 index 0000000..493e0c1 --- /dev/null +++ b/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "com.aeternumgames.shapeeditor.tests.editor", + "references": [ + "com.aeternumgames.shapeeditor" + ], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [] +} diff --git a/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef.meta b/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef.meta new file mode 100644 index 0000000..cd86734 --- /dev/null +++ b/Tests/Editor/com.aeternumgames.shapeeditor.tests.editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1d2b1bc079f37694a80f6c482bec1691 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: