From a8d90be2ea7501b215ad94ce9721cbda18b0e92b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 23 Apr 2026 11:07:04 -0400 Subject: [PATCH 1/4] feat: add layer filter overloads to Dxf.GetGeometry() Add optional Func layerFilter parameter to ConvertEntities and two new GetGeometry overloads (path and stream) that accept a layer filter. This lets callers control which layers to exclude instead of being limited to the hardcoded IsNonCutLayer check. Existing overloads without the filter continue to use the default IsNonCutLayer behavior. Co-Authored-By: Claude Opus 4.6 --- OpenNest.IO/Dxf.cs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index 5c2548c..4c20f89 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -65,6 +65,36 @@ namespace OpenNest.IO } } + public static List GetGeometry(string path, Func layerFilter) + { + try + { + using var reader = new DxfReader(path); + var doc = reader.Read(); + return ConvertEntities(doc, layerFilter); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + return new List(); + } + } + + public static List GetGeometry(Stream stream, Func layerFilter) + { + try + { + using var reader = new DxfReader(stream); + var doc = reader.Read(); + return ConvertEntities(doc, layerFilter); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + return new List(); + } + } + #endregion #region Export @@ -128,15 +158,16 @@ namespace OpenNest.IO } } - private static List ConvertEntities(CadDocument doc) + private static List ConvertEntities(CadDocument doc, Func layerFilter = null) { var entities = new List(); var lines = new List(); var arcs = new List(); + var filter = layerFilter ?? IsNonCutLayer; foreach (var entity in doc.Entities) { - if (IsNonCutLayer(entity.Layer?.Name)) + if (filter(entity.Layer?.Name)) continue; switch (entity) From 53988acefcd7ded17784fd2844ff3e2e1aa11482 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 8 May 2026 13:09:31 -0400 Subject: [PATCH 2/4] fix(io): deduplicate circles and full-circle arcs during DXF import Duplicate circle entities at the same location inflated pierce counts and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8). Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Geometry/Arc.cs | 3 ++ OpenNest.Core/Geometry/GeometryOptimizer.cs | 32 +++++++++++++++++++++ OpenNest.IO/Dxf.cs | 6 +++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index 8a677ec..e556ffe 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -93,6 +93,9 @@ namespace OpenNest.Geometry } } + public bool IsFullCircle() => + SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon; + /// /// Angle in radians between start and end angles. /// diff --git a/OpenNest.Core/Geometry/GeometryOptimizer.cs b/OpenNest.Core/Geometry/GeometryOptimizer.cs index 0243e51..97595f9 100644 --- a/OpenNest.Core/Geometry/GeometryOptimizer.cs +++ b/OpenNest.Core/Geometry/GeometryOptimizer.cs @@ -17,6 +17,38 @@ namespace OpenNest.Geometry (list, item, i) => list.GetCollinearLines(item, i), (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)); + public static void Deduplicate(IList circles) + { + for (var i = circles.Count - 1; i >= 1; i--) + { + for (var j = i - 1; j >= 0; j--) + { + if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon + && circles[i].Radius.IsEqualTo(circles[j].Radius)) + { + circles.RemoveAt(i); + break; + } + } + } + } + + public static void Deduplicate(IList circles, IList arcs) + { + for (var i = circles.Count - 1; i >= 0; i--) + { + for (var j = arcs.Count - 1; j >= 0; j--) + { + if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon + && arcs[j].Radius.IsEqualTo(circles[i].Radius) + && arcs[j].IsFullCircle()) + { + arcs.RemoveAt(j); + } + } + } + } + private delegate bool TryJoin(T a, T b, out T joined); private static void MergePass(IList items, diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index 4c20f89..cdde347 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -163,6 +163,7 @@ namespace OpenNest.IO var entities = new List(); var lines = new List(); var arcs = new List(); + var circles = new List(); var filter = layerFilter ?? IsNonCutLayer; foreach (var entity in doc.Entities) @@ -181,7 +182,7 @@ namespace OpenNest.IO break; case ACadSharp.Entities.Circle circle: - entities.Add(circle.ToOpenNest()); + circles.Add(circle.ToOpenNest()); break; case ACadSharp.Entities.Spline spline: @@ -212,7 +213,10 @@ namespace OpenNest.IO GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(arcs); + GeometryOptimizer.Deduplicate(circles); + GeometryOptimizer.Deduplicate(circles, arcs); + entities.AddRange(circles); entities.AddRange(lines); entities.AddRange(arcs); From 27f06850589c6353d5fd22036c5b8dea5362179d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 17 May 2026 19:03:33 -0400 Subject: [PATCH 3/4] fix(engine): skip intersecting parts as obstacles during compactor push Parts that already overlap the moving group are now excluded from the obstacle list so they don't block the push direction. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/Fill/Compactor.cs | 39 +++++++++- OpenNest.Tests/Fill/CompactorTests.cs | 107 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 26cef71..7ce2489 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -1,6 +1,7 @@ using OpenNest.Geometry; using System.Collections.Generic; using System.Linq; +using OpenNest.Math; namespace OpenNest.Engine.Fill { @@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill public static double Push(List movingParts, Plate plate, PushDirection direction) { var obstacleParts = plate.Parts - .Where(p => !movingParts.Contains(p)) + .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) .ToList(); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); @@ -26,7 +27,7 @@ namespace OpenNest.Engine.Fill public static double Push(List movingParts, Plate plate, double angle) { var obstacleParts = plate.Parts - .Where(p => !movingParts.Contains(p)) + .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) .ToList(); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); @@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill : PartGeometry.GetPerimeterEntities(obstacleParts[i]); var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction); + if (d <= Tolerance.Epsilon + && partSpacing <= Tolerance.Epsilon + && CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)) + { + continue; + } + if (d < distance) distance = d; } @@ -115,6 +123,31 @@ namespace OpenNest.Engine.Fill return 0; } + private static bool IntersectsAny(Part candidate, List parts) + { + for (var i = 0; i < parts.Count; i++) + { + if (candidate.Intersects(parts[i], out _)) + return true; + } + return false; + } + + private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction) + { + var nudge = direction * (Tolerance.Epsilon * 10); + + moving.Offset(nudge); + try + { + return !moving.Intersects(obstacle, out _); + } + finally + { + moving.Offset(-nudge); + } + } + public static double Push(List movingParts, List obstacleParts, Box workArea, double partSpacing, PushDirection direction) { @@ -130,7 +163,7 @@ namespace OpenNest.Engine.Fill public static double PushBoundingBox(List movingParts, Plate plate, PushDirection direction) { var obstacleParts = plate.Parts - .Where(p => !movingParts.Contains(p)) + .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) .ToList(); return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index 4c27df8..e85a84a 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -97,6 +97,33 @@ namespace OpenNest.Tests.Fill return part; } + private static Drawing MakeTriangleDrawing(params Vector[] points) + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0])); + + for (var i = 1; i < points.Length; i++) + pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i])); + + pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0])); + return new Drawing("triangle", pgm); + } + + private static Part MakeTrianglePart(params Vector[] points) + { + var part = new Part(MakeTriangleDrawing(points)); + part.UpdateBounds(); + return part; + } + + private static Part MakeTrianglePart(double x, double y, params Vector[] points) + { + var part = MakeTrianglePart(points); + part.Location = new Vector(x, y); + part.UpdateBounds(); + return part; + } + [Fact] public void Push_Left_MovesPartTowardEdge() { @@ -171,6 +198,86 @@ namespace OpenNest.Tests.Fill Assert.NotEqual(distNoSpacing, distWithSpacing); } + [Fact] + public void Push_Up_AllowsSharedDiagonalEdgeToSeparate() + { + var workArea = new Box(0, 0, 20, 20); + var obstacle = MakeTrianglePart( + new Vector(0, 0), + new Vector(10, 0), + new Vector(0, 10)); + var movingPart = MakeTrianglePart( + new Vector(0, 10), + new Vector(10, 0), + new Vector(10, 10)); + + var distance = Compactor.Push( + new List { movingPart }, + new List { obstacle }, + workArea, + 0, + PushDirection.Up); + + Assert.True(distance > 0); + Assert.True(movingPart.BoundingBox.Top > 19.9); + Assert.False(movingPart.Intersects(obstacle, out _)); + } + + [Fact] + public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge() + { + var workArea = new Box(0, 0, 24, 24); + var leftTriangle = MakeTrianglePart( + 2, 2, + new Vector(0, 0), + new Vector(8, 0), + new Vector(4, 10)); + var rightTriangle = MakeTrianglePart( + 14, 4, + new Vector(0, 10), + new Vector(8, 10), + new Vector(4, 0)); + + var moving = new List { rightTriangle }; + var obstacles = new List { leftTriangle }; + + var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left); + var yBeforePushUp = rightTriangle.Location.Y; + var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom; + + var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up); + + Assert.True(leftDistance > 0); + Assert.True(upDistance > 0); + Assert.True(rightTriangle.Location.Y > yBeforePushUp); + Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp); + Assert.False(rightTriangle.Intersects(leftTriangle, out _)); + } + + [Fact] + public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap() + { + var workArea = new Box(0, 0, 20, 20); + var obstacle = MakeTrianglePart( + new Vector(0, 0), + new Vector(10, 0), + new Vector(0, 10)); + var movingPart = MakeTrianglePart( + new Vector(0, 10), + new Vector(10, 0), + new Vector(10, 10)); + + var distance = Compactor.Push( + new List { movingPart }, + new List { obstacle }, + workArea, + 0, + PushDirection.Left); + + Assert.Equal(0, distance); + Assert.Equal(0, movingPart.BoundingBox.Left); + } + [Fact] public void Push_AngleLeft_MovesPartTowardEdge() { From da77cc9270d0c98bc8e63330b1d1e9d772f32eaa Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 18 May 2026 22:17:47 -0400 Subject: [PATCH 4/4] Fix best-fit viewer bounds for angled pairs --- OpenNest.Engine/BestFit/BestFitResult.cs | 65 +++++++++++++++++ .../BestFit/BestFitResultFrameTests.cs | 72 +++++++++++++++++++ OpenNest/Forms/BestFitViewerForm.cs | 6 +- OpenNest/Forms/MainForm.cs | 3 +- 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 OpenNest.Tests/BestFit/BestFitResultFrameTests.cs diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 60fc093..f819edb 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -1,6 +1,9 @@ +using OpenNest.Engine; +using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.Math; using System.Collections.Generic; +using System.Linq; namespace OpenNest.Engine.BestFit { @@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit return new List { part1, part2 }; } + + public List BuildCanonicalParts() + { + return NormalizeToCutOrigin(BuildParts(Candidate.Drawing)); + } + + public List BuildSourceParts(Drawing drawing) + { + var parts = BuildCanonicalParts(); + var sourceAngle = drawing?.Source?.Angle ?? 0.0; + + for (var i = 0; i < parts.Count; i++) + { + var p = parts[i]; + var rebound = Part.CreateAtOrigin(drawing, p.Rotation); + var delta = p.BoundingBox.Location - rebound.BoundingBox.Location; + rebound.Offset(delta); + rebound.UpdateBounds(); + parts[i] = rebound; + } + + return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle)); + } + + public Box GetCutBounds(List parts) + { + return GetCutBoundingBox(parts); + } + + private static List NormalizeToCutOrigin(List parts) + { + if (parts == null || parts.Count == 0) + return parts; + + var bounds = GetCutBoundingBox(parts); + var offset = new Vector(-bounds.Left, -bounds.Bottom); + + foreach (var part in parts) + part.Offset(offset); + + return parts; + } + + private static Box GetCutBoundingBox(List parts) + { + var entities = new List(); + + foreach (var part in parts) + { + var partEntities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + foreach (var entity in partEntities) + { + entity.Offset(part.Location); + entities.Add(entity); + } + } + + return entities.GetBoundingBox(); + } } public enum BestFitSortField diff --git a/OpenNest.Tests/BestFit/BestFitResultFrameTests.cs b/OpenNest.Tests/BestFit/BestFitResultFrameTests.cs new file mode 100644 index 0000000..3162ac3 --- /dev/null +++ b/OpenNest.Tests/BestFit/BestFitResultFrameTests.cs @@ -0,0 +1,72 @@ +using OpenNest.Engine; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; +using OpenNest.Shapes; + +namespace OpenNest.Tests.BestFit; + +public class BestFitResultFrameTests +{ + [Fact] + public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds() + { + var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing(); + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + + var result = EvaluateOffsetPair(canonical, new Vector(40, 30)); + + Assert.True(IsNonAxisAligned(result.OptimalRotation), + $"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees."); + + var parts = result.BuildCanonicalParts(); + var bounds = result.GetCutBounds(parts); + + Assert.Equal(0, bounds.Left, 3); + Assert.Equal(0, bounds.Bottom, 3); + Assert.Equal(result.BoundingWidth, bounds.Length, 2); + Assert.Equal(result.BoundingHeight, bounds.Width, 2); + } + + [Fact] + public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing() + { + var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing(); + drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center); + drawing.RecomputeCanonicalAngle(); + + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + var result = EvaluateOffsetPair(canonical, new Vector(40, 30)); + + var parts = result.BuildSourceParts(drawing); + var bounds = result.GetCutBounds(parts); + + Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing)); + Assert.Equal(0, bounds.Left, 3); + Assert.Equal(0, bounds.Bottom, 3); + Assert.False(parts[0].Intersects(parts[1], out _)); + } + + private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset) + { + var candidate = new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0, + Part2Rotation = System.Math.PI, + Part2Offset = offset, + Spacing = 0.25 + }; + + return new PairEvaluator().Evaluate(candidate); + } + + private static bool IsNonAxisAligned(double angle) + { + var normalized = Angle.NormalizeRad(angle); + var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI); + var delta = System.Math.Abs(normalized - nearestQuadrant); + delta = System.Math.Min(delta, Angle.HalfPI - delta); + return delta > Angle.ToRadians(1); + } +} diff --git a/OpenNest/Forms/BestFitViewerForm.cs b/OpenNest/Forms/BestFitViewerForm.cs index 3599b98..1851f23 100644 --- a/OpenNest/Forms/BestFitViewerForm.cs +++ b/OpenNest/Forms/BestFitViewerForm.cs @@ -45,6 +45,7 @@ namespace OpenNest.Forms public BestFitResult SelectedResult { get; private set; } public Drawing SelectedDrawing => activeDrawing; + public List SelectedParts { get; private set; } public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches) { @@ -318,12 +319,12 @@ namespace OpenNest.Forms var cell = new BestFitCell(colorScheme); cell.PartColor = partColor; cell.Dock = DockStyle.Fill; + + var parts = result.BuildCanonicalParts(); cell.Plate.Size = new Geometry.Size( result.BoundingHeight, result.BoundingWidth); - var parts = result.BuildParts(drawing); - foreach (var part in parts) cell.Plate.Parts.Add(part); @@ -332,6 +333,7 @@ namespace OpenNest.Forms cell.DoubleClick += (sender, e) => { SelectedResult = result; + SelectedParts = result.BuildSourceParts(drawing); DialogResult = DialogResult.OK; Close(); }; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 09f8e51..99f2e0d 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -686,7 +686,8 @@ namespace OpenNest.Forms { if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null) { - var parts = form.SelectedResult.BuildParts(form.SelectedDrawing); + var parts = form.SelectedParts + ?? form.SelectedResult.BuildSourceParts(form.SelectedDrawing); activeForm.PlateView.SetAction(typeof(ActionClone), parts); } }