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.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.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index 9187206..cdde347 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,16 +158,17 @@ 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 circles = 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) 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.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() { 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); } }