diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index 50e88d34..db58a3be 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -144,8 +144,21 @@ namespace OpenNest if (part.BaseDrawing.IsCutOff) continue; - Entity perimeter = null; - cache?.TryGetValue(part, out perimeter); + Entity perimeter; + if (cache != null) + { + cache.TryGetValue(part, out perimeter); + } + else + { + // No pre-built cache: this is a direct/uncached caller (e.g. an + // external API consumer calling Regenerate without going through + // Plate.RegenerateCutOffs or the UI handlers, which always pass a + // cache). Build the same converted-material envelope on the fly so + // this path gets the same safety as the cached one, instead of + // silently trusting the part's bare BoundingBox. + perimeter = Plate.BuildPartPerimeter(part); + } var partExclusions = GetPartExclusions( part, perimeter, diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 31f92285..ef121bfe 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -291,52 +291,61 @@ namespace OpenNest if (part.BaseDrawing.IsCutOff) continue; - Geometry.Entity perimeter = null; - try - { - var entities = Converters - .ConvertProgram.ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) - .ToList(); - - if (entities.Count > 0) - { - // Leads are cutting paths in scrap, not boundary edges. Chaining them - // into the outline can make a closed part appear open and replace its - // recesses with a convex hull. Keep the full geometry for the existing - // conservative fallback when the material contour really is open. - var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && - e.Layer != SpecialLayers.Leadout).ToList(); - var profile = new Geometry.ShapeProfile(outline); - - 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; + cache[part] = BuildPartPerimeter(part); } return cache; } + /// + /// Converts one part's own program into its placed perimeter entity (closed + /// profile, or a convex hull fallback for a genuinely open contour). This is + /// the same conversion runs per part; callers + /// without a pre-built cache (e.g. a direct, uncached + /// call) use it to get the same conservative, converted-material envelope instead + /// of trusting the part's cached bounding box alone. + /// + public static Geometry.Entity BuildPartPerimeter(Part part) + { + try + { + var entities = Converters + .ConvertProgram.ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count == 0) + return null; + + // Leads are cutting paths in scrap, not boundary edges. Chaining them + // into the outline can make a closed part appear open and replace its + // recesses with a convex hull. Keep the full geometry for the existing + // conservative fallback when the material contour really is open. + var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && + e.Layer != SpecialLayers.Leadout).ToList(); + var profile = new Geometry.ShapeProfile(outline); + + if (profile.Perimeter.IsClosed()) + { + var perimeter = profile.Perimeter; + perimeter.Offset(part.Location); + return perimeter; + } + + var points = entities.CollectPoints(); + if (points.Count < 3) + return null; + + var hull = Geometry.ConvexHull.Compute(points); + hull.Offset(part.Location); + return hull; + } + catch + { + return null; + } + } + /// /// The number of times to cut the plate. /// diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs index 51612f1f..d0797475 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs @@ -860,6 +860,28 @@ public class AutomaticCutOffPlannerTests Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _)); } + [Fact] + public void LargeArcRefit_UncachedDirectRegenerate_DoesNotCrossConvertedPerimeter() + { + // Same large-refit scenario as above, but the caller never built a + // perimeter cache at all (OpenNest PM 13f7a685: a direct/uncached + // CutOff.Regenerate call used to trust the bare BoundingBox and could + // emit a segment crossing the arc's true converted extent). + var plate = MakeArcPlate(0.0001); + var part = Assert.Single(plate.Parts); + var cachedBox = part.BoundingBox; + var arc = Assert.Single(ConvertProgram.ToGeometry(part.Program).OfType()); + arc.Offset(part.Location); + var x = (cachedBox.Right + arc.BoundingBox.Right) / 2; + Assert.True(x > cachedBox.Right); // The unsafe line sits outside the cached bounds. + + var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical); + manual.Regenerate(plate, new CutOffSettings { PartClearance = 0 }); // No cache argument. + + foreach (var segment in Segments(manual.Drawing.Program)) + Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _)); + } + [Fact] public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts() {