From a735884ee9c67f475980c48cfaea1940793b8ac8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 22 Mar 2026 22:34:14 -0400 Subject: [PATCH] feat: add Plate.BuildPerimeterCache with ShapeProfile and convex hull fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Plate.cs | 52 +++++++++++++++++++++++++++ OpenNest.Tests/CutOffGeometryTests.cs | 42 ++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 5e3a1eb..c07586a 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -125,6 +125,58 @@ namespace OpenNest } } + /// + /// Builds a dictionary mapping each non-cut-off part to its perimeter entity. + /// Closed shapes use ShapeProfile; open contours fall back to ConvexHull. + /// + public static Dictionary BuildPerimeterCache(Plate plate) + { + var cache = new Dictionary(); + + foreach (var part in plate.Parts) + { + if (part.BaseDrawing.IsCutOff) + continue; + + Geometry.Entity perimeter = null; + try + { + var entities = Converters.ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + if (entities.Count > 0) + { + var profile = new Geometry.ShapeProfile(entities); + + if (profile.Perimeter.IsClosed()) + { + perimeter = profile.Perimeter; + perimeter.Offset(part.Location); + } + else + { + var points = entities.CollectPoints(); + if (points.Count >= 3) + { + var hull = Geometry.ConvexHull.Compute(points); + hull.Offset(part.Location); + perimeter = hull; + } + } + } + } + catch + { + perimeter = null; + } + + cache[part] = perimeter; + } + + return cache; + } + /// /// The number of times to cut the plate. /// diff --git a/OpenNest.Tests/CutOffGeometryTests.cs b/OpenNest.Tests/CutOffGeometryTests.cs index 5e1315f..a16a1e9 100644 --- a/OpenNest.Tests/CutOffGeometryTests.cs +++ b/OpenNest.Tests/CutOffGeometryTests.cs @@ -336,6 +336,48 @@ public class CutOffGeometryTests Assert.Equal(8, points.Count); } + [Fact] + public void PlatePerimeterCache_ReturnsOneEntryPerPart() + { + var plate = new Plate(100, 100); + plate.Parts.Add(new Part(new Drawing("a", MakeSquare(10)))); + plate.Parts.Add(new Part(new Drawing("b", MakeCircle(5)))); + plate.Parts.Add(new Part(new Drawing("c", MakeDiamond(8)))); + + var cache = Plate.BuildPerimeterCache(plate); + Assert.Equal(3, cache.Count); + } + + [Fact] + public void PlatePerimeterCache_SkipsCutOffParts() + { + var plate = new Plate(100, 100); + plate.Parts.Add(new Part(new Drawing("real", MakeSquare(10)))); + plate.Parts.Add(new Part(new Drawing("cutoff", new Program()) { IsCutOff = true })); + + var cache = Plate.BuildPerimeterCache(plate); + Assert.Single(cache); + } + + [Fact] + public void PlatePerimeterCache_OpenContourUsesConvexHull() + { + 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))); + + var plate = new Plate(100, 100); + plate.Parts.Add(new Part(new Drawing("open", pgm))); + + var cache = Plate.BuildPerimeterCache(plate); + Assert.Single(cache); + + var part = plate.Parts[0]; + Assert.True(cache.ContainsKey(part)); + Assert.NotNull(cache[part]); + } + [Fact] public void ShapeProfile_SelectsLargestShapeAsPerimeter() {