From fb92b75c06f129cbce5efbe78322252242913d61 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 15 Oct 2026 23:26:23 -0400 Subject: [PATCH] fix(cutoff): give uncached CutOff.Regenerate the converted-material envelope A direct CutOff.Regenerate(plate, settings) call with no perimeter cache argument trusted Part.BoundingBox plus a sub-epsilon roundoff allowance. A legitimately larger arc refit (ConvertProgram fitting an imported arc's center to its chord) can protrude past that cached box by more than the roundoff, so an uncached manual cutoff could emit a segment crossing the part's true converted material. Every production call site (Plate.RegenerateCutOffs, AutomaticCutOffPlanner, the desktop cutoff tools) already builds and passes a Plate.BuildPerimeterCache dictionary, so this only affects a direct/uncached caller. Extract the per-part conversion out of BuildPerimeterCache into Plate.BuildPartPerimeter and use it as a lazy, on-the-fly fallback in CutOff.ComputeSegments whenever no cache dictionary is supplied at all, so the uncached path gets the same conservative envelope as the cached one instead of silently trusting the bare bounding box. Cached behavior (including a cache's own explicit null entries from a failed conversion) is unchanged. OpenNest PM 13f7a685-0564-4cbf-8f46-a7805a0288a0. --- OpenNest.Core/CutOff.cs | 17 +++- OpenNest.Core/Plate.cs | 91 ++++++++++--------- .../CutOffs/AutomaticCutOffPlannerTests.cs | 22 +++++ 3 files changed, 87 insertions(+), 43 deletions(-) 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() {