fix(core): narrow the perimeter-build catch to geometry failures

BuildPartPerimeter swallowed every exception. It now guards a missing
program and an outline that forms no shape, and catches only geometry
failures; a null perimeter still makes a cut-off exclude the part's whole
bounding-box span.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.2 committed 2026-11-12 19:10:26 -05:00
1 parent 32edc18ede
commit a451cf5ef3
2 files changed
+77 -2

No files matched your search

+10 -2
View File
@@ -664,6 +664,11 @@ namespace OpenNest
/// </summary>
public static Geometry.Entity BuildPartPerimeter(Part part)
{
// Null is the conservative answer: the cut-off then excludes the part's whole
// bounding-box span. Only malformed geometry earns it; anything else propagates.
if (part?.Program == null)
return null;
try
{
var entities = Converters
@@ -683,7 +688,10 @@ namespace OpenNest
e.Layer != SpecialLayers.Leadout).ToList();
if (outline.Count == 0)
return null;
var profile = new Geometry.ShapeProfile(outline);
var shapes = Geometry.ShapeBuilder.GetShapes(outline);
if (shapes.Count == 0)
return null;
var profile = Geometry.ShapeProfile.FromShapes(shapes);
if (profile.Perimeter.IsClosed())
{
@@ -700,7 +708,7 @@ namespace OpenNest
hull.Offset(part.Location);
return hull;
}
catch
catch (Exception e) when (Diagnostics.PlateOverlapAnalyzer.IsGeometryFailure(e))
{
return null;
}
@@ -382,4 +382,71 @@ public class CutOffGeometryTests
Assert.Equal(20.0, bb.Width, 1);
Assert.Equal(20.0, bb.Length, 1);
}
private static Program MakeSingleLine(double size)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
return pgm;
}
[Fact]
public void NoFormableOutline_YieldsNullCacheEntry()
{
// One open cut has too few points for a closed profile or a hull.
var plate = new Plate(100, 100);
var part = new Part(new Drawing("line", MakeSingleLine(10)), new Vector(20, 20));
plate.Parts.Add(part);
var cache = Plate.BuildPerimeterCache(plate);
Assert.True(cache.ContainsKey(part));
Assert.Null(cache[part]);
Assert.Null(Plate.BuildPartPerimeter(part));
}
[Fact]
public void NoMaterial_YieldsNullCacheEntry()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(10, 10)));
var plate = new Plate(100, 100);
var part = new Part(new Drawing("rapid only", pgm), new Vector(20, 20));
plate.Parts.Add(part);
Assert.Null(Plate.BuildPerimeterCache(plate)[part]);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void NoFormableOutline_CutOffStillExcludesTheBoundingBoxSpan(bool cached)
{
// Part bounding box is (20,20)-(30,30). A vertical cut at X=22 crosses the
// diagonal only at Y=22, but with no perimeter to intersect the cut-off must
// stay out of the whole Y=[20,30] span.
var plate = new Plate(100, 100);
var part = new Part(new Drawing("line", MakeSingleLine(10)), new Vector(20, 20));
plate.Parts.Add(part);
var box = part.BoundingBox;
Assert.Equal(20.0, box.Bottom, 6);
Assert.Equal(30.0, box.Top, 6);
var cutoff = new CutOff(new Vector(22, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, ZeroClearance, cached ? Plate.BuildPerimeterCache(plate) : null);
var codes = cutoff.Drawing.Program.Codes;
for (var i = 0; i < codes.Count - 1; i += 2)
{
var rapid = Assert.IsType<RapidMove>(codes[i]);
var linear = Assert.IsType<LinearMove>(codes[i + 1]);
var low = System.Math.Min(rapid.EndPoint.Y, linear.EndPoint.Y);
var high = System.Math.Max(rapid.EndPoint.Y, linear.EndPoint.Y);
Assert.True(high <= 20.0 + 1e-6 || low >= 30.0 - 1e-6,
$"Cut segment Y=[{low:F4},{high:F4}] enters the part's bounding-box span");
}
Assert.Equal(90.0, TotalCutLength(cutoff.Drawing.Program), 3);
}
}