diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs new file mode 100644 index 0000000..f900711 --- /dev/null +++ b/OpenNest.Core/CutOff.cs @@ -0,0 +1,168 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest +{ + public enum CutOffAxis + { + Horizontal, + Vertical + } + + public class CutOff + { + public Vector Position { get; set; } + public CutOffAxis Axis { get; set; } + public double? StartLimit { get; set; } + public double? EndLimit { get; set; } + public Drawing Drawing { get; private set; } + + public CutOff(Vector position, CutOffAxis axis) + { + Position = position; + Axis = axis; + Drawing = new Drawing(GetName()) { IsCutOff = true }; + } + + public void Regenerate(Plate plate, CutOffSettings settings) + { + var segments = ComputeSegments(plate, settings); + var program = BuildProgram(segments, settings); + Drawing.Program = program; + } + + private string GetName() + { + var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H"; + var coord = Axis == CutOffAxis.Vertical ? Position.X : Position.Y; + return $"CutOff-{axisChar}-{coord:F2}"; + } + + private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings) + { + var bounds = plate.BoundingBox(includeParts: false); + + double lineStart, lineEnd, cutPosition; + + if (Axis == CutOffAxis.Vertical) + { + cutPosition = Position.X; + lineStart = StartLimit ?? bounds.Y; + lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel); + } + else + { + cutPosition = Position.Y; + lineStart = StartLimit ?? bounds.X; + lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel); + } + + var exclusions = new List<(double Start, double End)>(); + + foreach (var part in plate.Parts) + { + if (part.BaseDrawing.IsCutOff) + continue; + + var bb = part.BoundingBox; + double partStart, partEnd, partMin, partMax; + + if (Axis == CutOffAxis.Vertical) + { + partMin = bb.X - settings.PartClearance; + partMax = bb.X + bb.Width + settings.PartClearance; + partStart = bb.Y - settings.PartClearance; + partEnd = bb.Y + bb.Length + settings.PartClearance; + } + else + { + partMin = bb.Y - settings.PartClearance; + partMax = bb.Y + bb.Length + settings.PartClearance; + partStart = bb.X - settings.PartClearance; + partEnd = bb.X + bb.Width + settings.PartClearance; + } + + if (cutPosition >= partMin && cutPosition <= partMax) + exclusions.Add((partStart, partEnd)); + } + + exclusions.Sort((a, b) => a.Start.CompareTo(b.Start)); + var merged = new List<(double Start, double End)>(); + foreach (var ex in exclusions) + { + if (merged.Count > 0 && ex.Start <= merged[^1].End) + merged[^1] = (merged[^1].Start, System.Math.Max(merged[^1].End, ex.End)); + else + merged.Add(ex); + } + + var segments = new List<(double Start, double End)>(); + var current = lineStart; + + foreach (var ex in merged) + { + var clampedStart = System.Math.Max(ex.Start, lineStart); + var clampedEnd = System.Math.Min(ex.End, lineEnd); + + if (clampedStart > current) + segments.Add((current, clampedStart)); + + current = System.Math.Max(current, clampedEnd); + } + + if (current < lineEnd) + segments.Add((current, lineEnd)); + + segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList(); + + return segments; + } + + private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings) + { + var program = new Program(); + + if (segments.Count == 0) + return program; + + if (settings.CutDirection == CutDirection.TowardOrigin) + segments = segments.OrderByDescending(s => s.Start).ToList(); + else + segments = segments.OrderBy(s => s.Start).ToList(); + + foreach (var seg in segments) + { + double startVal, endVal; + if (settings.CutDirection == CutDirection.TowardOrigin) + { + startVal = seg.End; + endVal = seg.Start; + } + else + { + startVal = seg.Start; + endVal = seg.End; + } + + Vector startPt, endPt; + if (Axis == CutOffAxis.Vertical) + { + startPt = new Vector(Position.X, startVal); + endPt = new Vector(Position.X, endVal); + } + else + { + startPt = new Vector(startVal, Position.Y); + endPt = new Vector(endVal, Position.Y); + } + + program.Codes.Add(new RapidMove(startPt)); + program.Codes.Add(new LinearMove(endPt)); + } + + return program; + } + } +} diff --git a/OpenNest.Core/CutOffSettings.cs b/OpenNest.Core/CutOffSettings.cs new file mode 100644 index 0000000..1df520c --- /dev/null +++ b/OpenNest.Core/CutOffSettings.cs @@ -0,0 +1,16 @@ +namespace OpenNest +{ + public enum CutDirection + { + TowardOrigin, + AwayFromOrigin + } + + public class CutOffSettings + { + public double PartClearance { get; set; } = 0.125; + public double Overtravel { get; set; } + public double MinSegmentLength { get; set; } = 0.05; + public CutDirection CutDirection { get; set; } = CutDirection.TowardOrigin; + } +} diff --git a/OpenNest.Tests/CutOffTests.cs b/OpenNest.Tests/CutOffTests.cs index c94acb9..e3cd936 100644 --- a/OpenNest.Tests/CutOffTests.cs +++ b/OpenNest.Tests/CutOffTests.cs @@ -1,4 +1,6 @@ +using System.Linq; using OpenNest.CNC; +using OpenNest.Geometry; namespace OpenNest.Tests; @@ -61,4 +63,156 @@ public class CutOffTests var hasOverlap = plate.HasOverlappingParts(out var pts); Assert.False(hasOverlap); } + + [Fact] + public void CutOff_VerticalCut_GeneratesFullLineOnEmptyPlate() + { + var plate = new Plate(100, 50); + var settings = new CutOffSettings(); + var cutoff = new CutOff(new Vector(25, 20), CutOffAxis.Vertical); + + cutoff.Regenerate(plate, settings); + + Assert.NotNull(cutoff.Drawing); + Assert.True(cutoff.Drawing.IsCutOff); + Assert.True(cutoff.Drawing.Program.Codes.Count > 0); + } + + [Fact] + public void CutOff_HorizontalCut_GeneratesFullLineOnEmptyPlate() + { + var plate = new Plate(100, 50); + var settings = new CutOffSettings(); + var cutoff = new CutOff(new Vector(25, 20), CutOffAxis.Horizontal); + + cutoff.Regenerate(plate, settings); + + var codes = cutoff.Drawing.Program.Codes; + Assert.Equal(2, codes.Count); + } + + [Fact] + public void CutOff_VerticalCut_TrimsAroundPart() + { + // Create a 10x10 part at the origin, then move it to (20,20) + // so the bounding box is Box(20,20,10,10) and doesn't span the origin. + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + var drawing = new Drawing("sq", pgm); + + var plate = new Plate(50, 50); + var part = Part.CreateAtOrigin(drawing); + part.Location = new Vector(20, 20); + plate.Parts.Add(part); + + // Vertical cut at X=25 runs along Y from 0 to 50. + // Part BB at (20,20,10,10) with clearance 1 → exclusion X=[19,31], Y=[19,31]. + // X=25 is within [19,31] so exclusion applies: skip Y=[19,31]. + // Segments: (0, 19) and (31, 50) → 2 segments → 4 codes. + var settings = new CutOffSettings { PartClearance = 1.0 }; + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical); + cutoff.Regenerate(plate, settings); + + var codes = cutoff.Drawing.Program.Codes; + Assert.Equal(4, codes.Count); + } + + [Fact] + public void CutOff_ShortSegment_FilteredByMinLength() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(20, 0.02))); + pgm.Codes.Add(new LinearMove(new Vector(30, 0.02))); + pgm.Codes.Add(new LinearMove(new Vector(30, 10))); + pgm.Codes.Add(new LinearMove(new Vector(20, 10))); + pgm.Codes.Add(new LinearMove(new Vector(20, 0.02))); + var drawing = new Drawing("sq", pgm); + + var plate = new Plate(50, 50); + plate.Parts.Add(new Part(drawing)); + + var settings = new CutOffSettings { PartClearance = 0.0, MinSegmentLength = 0.05 }; + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical); + cutoff.Regenerate(plate, settings); + + var rapidCount = cutoff.Drawing.Program.Codes.Count(c => c is RapidMove); + var lineCount = cutoff.Drawing.Program.Codes.Count(c => c is LinearMove); + Assert.Equal(rapidCount, lineCount); + } + + [Fact] + public void CutOff_Overtravel_ExtendsFarEnd() + { + var plate = new Plate(100, 50); + var settings = new CutOffSettings { Overtravel = 2.0 }; + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical); + cutoff.Regenerate(plate, settings); + + // Plate(100, 50) = Width=100, Length=50. Vertical cut runs along Y (Width axis). + // BoundingBox Y extent = Size.Width = 100. With 2" overtravel = 102. + // Default TowardOrigin: RapidMove to far end (102), LinearMove to near end (0). + var rapidMoves = cutoff.Drawing.Program.Codes.OfType().ToList(); + Assert.Single(rapidMoves); + Assert.Equal(102.0, rapidMoves[0].EndPoint.Y, 5); + } + + [Fact] + public void CutOff_StartLimit_TruncatesNearEnd() + { + var plate = new Plate(100, 50); + var settings = new CutOffSettings(); + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical) + { + StartLimit = 20.0 + }; + cutoff.Regenerate(plate, settings); + + var rapidMoves = cutoff.Drawing.Program.Codes.OfType().ToList(); + Assert.Single(rapidMoves); + var linearMoves = cutoff.Drawing.Program.Codes.OfType().ToList(); + Assert.Single(linearMoves); + Assert.Equal(20.0, linearMoves[0].EndPoint.Y, 5); + } + + [Fact] + public void CutOff_EndLimit_TruncatesFarEnd() + { + var plate = new Plate(100, 50); + var settings = new CutOffSettings(); + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical) + { + EndLimit = 80.0 + }; + cutoff.Regenerate(plate, settings); + + var rapidMoves = cutoff.Drawing.Program.Codes.OfType().ToList(); + Assert.Single(rapidMoves); + Assert.Equal(80.0, rapidMoves[0].EndPoint.Y, 5); + } + + [Fact] + public void CutOff_BothLimits_LShapedCornerCut() + { + var plate = new Plate(60, 120); + var settings = new CutOffSettings { PartClearance = 0 }; + + var hCut = new CutOff(new Vector(85, 30), CutOffAxis.Horizontal) + { + EndLimit = 85.0 + }; + hCut.Regenerate(plate, settings); + + var vCut = new CutOff(new Vector(85, 30), CutOffAxis.Vertical) + { + StartLimit = 30.0 + }; + vCut.Regenerate(plate, settings); + + Assert.True(hCut.Drawing.Program.Codes.Count > 0); + Assert.True(vCut.Drawing.Program.Codes.Count > 0); + } }