fix(cutoffs): preserve clearance at ambiguous vertex intersections

This commit is contained in:
aj
2026-09-28 20:37:49 -04:00
parent 1f637ca43c
commit 4dbdc2678d
2 changed files with 188 additions and 0 deletions
+9
View File
@@ -191,6 +191,15 @@ namespace OpenNest
if (coords.Count % 2 != 0)
return null;
// Intersects reports both incident edges at a shared vertex. Such hits may be
// crossings or tangencies, so neither pairing nor deduplicating them preserves
// inside/outside parity. Fall back to the clearance-expanded part bounds.
for (var i = 1; i < coords.Count; i++)
{
if (coords[i] - coords[i - 1] <= Math.Tolerance.Epsilon)
return null;
}
var padding = usedOffset ? 0 : clearance;
var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2)
@@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.CutOffs;
public class CutOffEdgeClearanceTests
{
public static IEnumerable<object[]> RectangleCases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var cutCoordinate in new[] { 70.0, 80.0, 90.0 })
foreach (var zeroClearance in new[] { false, true })
foreach (var materialize in new[] { false, true })
yield return new object[] { quadrant, axis, cutCoordinate, zeroClearance, materialize };
}
[Theory]
[MemberData(nameof(RectangleCases))]
public void Rectangle_EdgesAndInterior_KeepClearance(
int quadrant, CutOffAxis axis, double cutCoordinate, bool zeroClearance, bool materialize)
{
// Reported case: Plate(81,120), rectangle [70,90] x [30,50], vertical X=70.
// Transpose for horizontal cuts, and reflect both the plate and part for each quadrant.
var plate = axis == CutOffAxis.Vertical ? new Plate(81, 120) : new Plate(120, 81);
plate.Quadrant = quadrant;
var negativeX = quadrant is 2 or 3;
var negativeY = quadrant is 3 or 4;
var x0 = axis == CutOffAxis.Vertical ? 70.0 : 30.0;
var y0 = axis == CutOffAxis.Vertical ? 30.0 : 70.0;
var left = negativeX ? -x0 - 20 : x0;
var bottom = negativeY ? -y0 - 20 : y0;
var right = left + 20;
var top = bottom + 20;
var part = new Part(MakeRectangle(), new Vector(left, bottom));
plate.Parts.Add(part);
Assert.Equal(left, part.BoundingBox.Left);
Assert.Equal(right, part.BoundingBox.Right);
Assert.Equal(bottom, part.BoundingBox.Bottom);
Assert.Equal(top, part.BoundingBox.Top);
var cutPosition = axis == CutOffAxis.Vertical
? new Vector(negativeX ? -cutCoordinate : cutCoordinate, 0)
: new Vector(0, negativeY ? -cutCoordinate : cutCoordinate);
var cutoff = new CutOff(cutPosition, axis);
var settings = new CutOffSettings();
if (zeroClearance)
settings.PartClearance = 0;
if (materialize)
{
plate.CutOffs.Add(cutoff);
for (var iteration = 0; iteration < 2; iteration++)
{
plate.RegenerateCutOffs(settings);
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
Assert.Same(cutoff.Drawing, cutPart.BaseDrawing);
AssertRectangleClearance(cutPart.Program, cutPart.Location, axis,
left, right, bottom, top, settings.PartClearance);
}
}
else
{
var cache = Plate.BuildPerimeterCache(plate);
Assert.NotNull(cache[part]);
cutoff.Regenerate(plate, settings, cache);
AssertRectangleClearance(cutoff.Drawing.Program, Vector.Zero, axis,
left, right, bottom, top, settings.PartClearance);
}
}
[Theory]
[InlineData(CutOffAxis.Vertical)]
[InlineData(CutOffAxis.Horizontal)]
public void ZeroClearance_ThroughTwoCrossingVertices_DoesNotCutMaterial(CutOffAxis axis)
{
// Both diamond tips are transverse crossings, each reported by two incident edges.
var points = new[]
{
new Vector(70, 20), new Vector(90, 40),
new Vector(70, 60), new Vector(50, 40),
};
AssertOccupiedSlice(points, axis, 70, 20, 60);
}
[Theory]
[InlineData(CutOffAxis.Vertical)]
[InlineData(CutOffAxis.Horizontal)]
public void ZeroClearance_TwoTangentNotches_DoNotToggleCrossingParity(CutOffAxis axis)
{
// At X=70 the notch tips only touch the line: material remains between Y=20 and 80.
// Deduplicating their events would treat each tangency as a crossing and cut Y=40..60.
var points = new[]
{
new Vector(60, 20), new Vector(90, 20), new Vector(90, 35),
new Vector(70, 40), new Vector(90, 45), new Vector(90, 55),
new Vector(70, 60), new Vector(90, 65), new Vector(90, 80), new Vector(60, 80),
};
AssertOccupiedSlice(points, axis, 70, 20, 80);
}
private static void AssertOccupiedSlice(Vector[] points, CutOffAxis axis,
double cutCoordinate, double occupiedStart, double occupiedEnd)
{
Vector Orient(Vector point) => axis == CutOffAxis.Vertical
? point : new Vector(point.Y, point.X);
var program = new Program();
program.Codes.Add(new RapidMove(Orient(points[0])));
foreach (var point in points.Skip(1).Append(points[0]))
program.Codes.Add(new LinearMove(Orient(point)));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)))).IsClosed());
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("vertex-events", program)));
var cutoff = new CutOff(Orient(new Vector(cutCoordinate, 0)), axis);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings { PartClearance = 0 });
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var codes = cutPart.Program.Codes;
Assert.Equal(4, codes.Count);
var before = Assert.IsType<LinearMove>(codes[1]).EndPoint + cutPart.Location;
var after = Assert.IsType<RapidMove>(codes[2]).EndPoint + cutPart.Location;
Assert.Equal(occupiedStart, axis == CutOffAxis.Vertical ? before.Y : before.X, 9);
Assert.Equal(occupiedEnd, axis == CutOffAxis.Vertical ? after.Y : after.X, 9);
}
private static Drawing MakeRectangle()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(20, 0));
program.Codes.Add(new LinearMove(20, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)))).IsClosed());
var drawing = new Drawing("edge-clearance-rectangle", program);
Assert.Equal(400, drawing.Area);
return drawing;
}
private static void AssertRectangleClearance(Program program, Vector location, CutOffAxis axis,
double left, double right, double bottom, double top, double clearance)
{
// Independent analytic oracle: no production offsets, intersections or distance helpers.
// The distance between an axis-aligned segment and a filled rectangle is the norm of
// the gaps between their X/Y intervals. Zero clearance still forbids cutting along an edge.
Assert.NotEmpty(program.Codes);
Assert.Equal(0, program.Codes.Count % 2);
for (var i = 0; i < program.Codes.Count; i += 2)
{
var start = Assert.IsType<RapidMove>(program.Codes[i]).EndPoint + location;
var end = Assert.IsType<LinearMove>(program.Codes[i + 1]).EndPoint + location;
var minX = System.Math.Min(start.X, end.X);
var maxX = System.Math.Max(start.X, end.X);
var minY = System.Math.Min(start.Y, end.Y);
var maxY = System.Math.Max(start.Y, end.Y);
var dx = IntervalGap(minX, maxX, left, right);
var dy = IntervalGap(minY, maxY, bottom, top);
var distance = System.Math.Sqrt(dx * dx + dy * dy);
Assert.True(distance >= clearance - 1e-9,
$"Cut {start} -> {end} is only {distance:R} from the rectangle; requires {clearance:R}.");
var cutsMaterial = axis == CutOffAxis.Vertical
? minX >= left && minX <= right && minY < top && maxY > bottom
: minY >= bottom && minY <= top && minX < right && maxX > left;
Assert.False(cutsMaterial, $"Cut {start} -> {end} crosses or follows the rectangle.");
}
// Must retain the useful scrap cuts on both sides, not suppress the entire line.
Assert.Equal(4, program.Codes.Count);
}
private static double IntervalGap(double a0, double a1, double b0, double b1) =>
System.Math.Max(0, System.Math.Max(b0 - a1, a0 - b1));
}