From 0ab33af5d3d42abb5eb425d7eac775229f9947b8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 23 Apr 2026 10:40:43 -0400 Subject: [PATCH] feat: add WeldEndpoints to ShapeBuilder for gap repair on import Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Geometry/ShapeBuilder.cs | 93 ++++++++++++++++++- OpenNest.Tests/Geometry/WeldEndpointsTests.cs | 72 ++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Tests/Geometry/WeldEndpointsTests.cs diff --git a/OpenNest.Core/Geometry/ShapeBuilder.cs b/OpenNest.Core/Geometry/ShapeBuilder.cs index a4311f8..2a83b7b 100644 --- a/OpenNest.Core/Geometry/ShapeBuilder.cs +++ b/OpenNest.Core/Geometry/ShapeBuilder.cs @@ -1,12 +1,13 @@ using OpenNest.Math; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; namespace OpenNest.Geometry { public static class ShapeBuilder { - public static List GetShapes(IEnumerable entities) + public static List GetShapes(IEnumerable entities, double? weldTolerance = null) { var lines = new List(); var arcs = new List(); @@ -57,6 +58,9 @@ namespace OpenNest.Geometry entityList.AddRange(lines); entityList.AddRange(arcs); + if (weldTolerance.HasValue) + WeldEndpoints(entityList, weldTolerance.Value); + while (entityList.Count > 0) { var next = entityList[0]; @@ -107,6 +111,93 @@ namespace OpenNest.Geometry return shapes; } + public static void WeldEndpoints(List entities, double tolerance) + { + var endpointGroups = new List>(); + + foreach (var entity in entities) + { + var (start, end) = GetEndpoints(entity); + if (!start.IsValid() || !end.IsValid()) + continue; + + AddToGroup(endpointGroups, entity, true, start, tolerance); + AddToGroup(endpointGroups, entity, false, end, tolerance); + } + + foreach (var group in endpointGroups) + { + if (group.Count <= 1) + continue; + + var avgX = group.Average(g => g.point.X); + var avgY = group.Average(g => g.point.Y); + var weldedPoint = new Vector(avgX, avgY); + + foreach (var (entity, isStart, _) in group) + ApplyWeld(entity, isStart, weldedPoint); + } + } + + private static void AddToGroup( + List> groups, + Entity entity, bool isStart, Vector point, double tolerance) + { + foreach (var group in groups) + { + if (group[0].point.DistanceTo(point) <= tolerance) + { + group.Add((entity, isStart, point)); + return; + } + } + + groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) }); + } + + private static (Vector start, Vector end) GetEndpoints(Entity entity) + { + switch (entity.Type) + { + case EntityType.Arc: + var arc = (Arc)entity; + return (arc.StartPoint(), arc.EndPoint()); + + case EntityType.Line: + var line = (Line)entity; + return (line.StartPoint, line.EndPoint); + + default: + return (Vector.Invalid, Vector.Invalid); + } + } + + private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint) + { + switch (entity.Type) + { + case EntityType.Line: + var line = (Line)entity; + if (isStart) + line.StartPoint = weldedPoint; + else + line.EndPoint = weldedPoint; + break; + + case EntityType.Arc: + var arc = (Arc)entity; + var deltaX = weldedPoint.X - arc.Center.X; + var deltaY = weldedPoint.Y - arc.Center.Y; + var angle = System.Math.Atan2(deltaY, deltaX); + + if (isStart) + arc.StartAngle = angle; + else + arc.EndAngle = angle; + break; + } + } + internal static Entity GetConnected(Vector pt, IEnumerable geometry) { var tol = Tolerance.ChainTolerance; diff --git a/OpenNest.Tests/Geometry/WeldEndpointsTests.cs b/OpenNest.Tests/Geometry/WeldEndpointsTests.cs new file mode 100644 index 0000000..db1b235 --- /dev/null +++ b/OpenNest.Tests/Geometry/WeldEndpointsTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; +using Xunit; + +namespace OpenNest.Tests.Geometry; + +public class WeldEndpointsTests +{ + [Fact] + public void WeldEndpoints_SnapsNearbyLineEndpoints() + { + var line1 = new Line(0, 0, 10, 0); + var line2 = new Line(10.0000005, 0, 20, 0); + var entities = new List { line1, line2 }; + + ShapeBuilder.WeldEndpoints(entities, 0.000001); + + Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon); + } + + [Fact] + public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle() + { + var line = new Line(0, 0, 10, 0); + var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90)); + var entities = new List { line, arc }; + + ShapeBuilder.WeldEndpoints(entities, 0.01); + + var arcStart = arc.StartPoint(); + Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01); + } + + [Fact] + public void WeldEndpoints_DoesNotWeldDistantEndpoints() + { + var line1 = new Line(0, 0, 10, 0); + var line2 = new Line(10.1, 0, 20, 0); + var entities = new List { line1, line2 }; + + ShapeBuilder.WeldEndpoints(entities, 0.000001); + + Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01); + } + + [Fact] + public void GetShapes_WithWeldTolerance_WeldsBeforeChaining() + { + var line1 = new Line(0, 0, 10, 0); + var line2 = new Line(10.0000005, 0, 10.0000005, 10); + var entities = new List { line1, line2 }; + + var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001); + + Assert.Single(shapes); + Assert.Equal(2, shapes[0].Entities.Count); + } + + [Fact] + public void GetShapes_WithoutWeldTolerance_DefaultBehavior() + { + var line1 = new Line(0, 0, 10, 0); + var line2 = new Line(10, 0, 10, 10); + var entities = new List { line1, line2 }; + + var shapes = ShapeBuilder.GetShapes(entities); + + Assert.Single(shapes); + Assert.Equal(2, shapes[0].Entities.Count); + } +}