feat: add CutOff and CutOffSettings domain classes with segment generation
CutOff computes cut segments along a vertical or horizontal axis, excluding zones around existing parts with configurable clearance. CutOffSettings controls part clearance, overtravel, minimum segment length, and cut direction (toward/away from origin). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
OpenNest.Core/CutOff.cs
Normal file
168
OpenNest.Core/CutOff.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
OpenNest.Core/CutOffSettings.cs
Normal file
16
OpenNest.Core/CutOffSettings.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Linq;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Tests;
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
@@ -61,4 +63,156 @@ public class CutOffTests
|
|||||||
var hasOverlap = plate.HasOverlappingParts(out var pts);
|
var hasOverlap = plate.HasOverlappingParts(out var pts);
|
||||||
Assert.False(hasOverlap);
|
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<RapidMove>().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<RapidMove>().ToList();
|
||||||
|
Assert.Single(rapidMoves);
|
||||||
|
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().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<RapidMove>().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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user