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()
{