diff --git a/OpenNest.Core/Geometry/Clearance.cs b/OpenNest.Core/Geometry/Clearance.cs
index ca90a4f3..d37eab9a 100644
--- a/OpenNest.Core/Geometry/Clearance.cs
+++ b/OpenNest.Core/Geometry/Clearance.cs
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
return Separation(linesA, linesB);
}
+ /// Distance from a closed boundary's segments to every segment
+ /// of an intentional open cutting path. Unlike polygonizing that path,
+ /// this retains segment interiors and disjoint subpaths.
+ internal static ClearanceResult BoundaryDistance(List closed, List open)
+ {
+ if (closed.Count == 0 || open.Count == 0)
+ return new ClearanceResult(0, new Vector(1, 0));
+ return Separation(closed, open);
+ }
+
///
/// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates away from
diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs
new file mode 100644
index 00000000..2d669327
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffDeltaTests.cs
@@ -0,0 +1,121 @@
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+public class ExpanderCutoffDeltaTests
+{
+ private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) };
+
+ private static Program Rectangle(double width = 2, double height = 2)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(width, 0);
+ program.LineTo(width, height);
+ program.LineTo(0, height);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ private static Part Add(Plate plate, Program program, double x, double y)
+ {
+ var part = new Part(new Drawing("neutral", program), new Vector(x, y));
+ plate.Parts.Add(part);
+ return part;
+ }
+
+ private static Program Disk(double radius) =>
+ ConvertGeometry.ToProgram(new List { new Circle(0, 0, radius) });
+
+ [Theory]
+ [InlineData(CutOffAxis.Horizontal)]
+ [InlineData(CutOffAxis.Vertical)]
+ public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis)
+ {
+ var plate = Sheet();
+ var anchor = Add(plate, Rectangle(), 1, 1);
+ var mover = Add(plate, Rectangle(), 4, 4);
+ plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
+ var settings = new CutOffSettings();
+ plate.RegenerateCutOffs(settings);
+ var before = axis == CutOffAxis.Horizontal
+ ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
+
+ var result = Expander.Expand(new List { anchor, mover }, plate,
+ new Expander.Options { MaxSpacing = 3 });
+ var after = axis == CutOffAxis.Horizontal
+ ? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
+ Assert.True(after >= before - 1.1e-6,
+ $"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
+ plate.RegenerateCutOffs(settings);
+ Assert.Single(plate.CutOffs);
+ }
+
+ [Fact]
+ public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff()
+ {
+ var plate = Sheet();
+ var anchor = Add(plate, Rectangle(), 1, 4);
+ var mover = Add(plate, Rectangle(), 4, 4);
+ var program = new Program();
+ program.MoveTo(0, 10);
+ program.LineTo(30, 10);
+ program.LineTo(30, 0);
+ program.LineTo(0, 0);
+ plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero));
+
+ var result = Expander.Expand(new List { anchor, mover }, plate,
+ new Expander.Options { MaxSpacing = 3 });
+ // The three real segments are at least four units from the selected
+ // square at entry; an invented closing chord is not a real obstacle.
+ var gap = System.Math.Min(mover.BoundingBox.Bottom,
+ System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right));
+ Assert.True(gap >= 4 - 1.1e-6,
+ $"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus)
+ {
+ var plate = Sheet();
+ var entities = new List { new Circle(0, 0, 2) };
+ if (annulus)
+ entities.Add(new Circle(0, 0, 1));
+ Add(plate, ConvertGeometry.ToProgram(entities), 10, 10);
+ const double angle = 0.017;
+ const double depth = 0.0005;
+ var mover = Add(plate, Rectangle(0.01, 0.01),
+ 10 + (2 - depth) * System.Math.Cos(angle),
+ 10 + (2 - depth) * System.Math.Sin(angle));
+
+ var result = Expander.Separate(new List { mover }, plate, 0);
+ var nearest = result.Positions[0].DistanceTo(new Vector(10, 10));
+ Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0),
+ $"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}");
+ }
+
+ [Theory]
+ [InlineData(CutOffAxis.Horizontal)]
+ [InlineData(CutOffAxis.Vertical)]
+ public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis)
+ {
+ var plate = Sheet();
+ var horizontal = axis == CutOffAxis.Horizontal;
+ var anchor = Add(plate, Rectangle(), 1, 1);
+ var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1);
+ plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
+ plate.RegenerateCutOffs(new CutOffSettings());
+
+ var result = Expander.Separate(new List { anchor, mover }, plate, 12);
+ var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2;
+ Assert.True(trailingEdge <= 10 + 1e-6,
+ $"Crossed native cutoff, trailing edge={trailingEdge:R}");
+ Assert.False(result.Converged);
+ }
+}
diff --git a/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs
new file mode 100644
index 00000000..51cefcba
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/ExpanderCutoffQuadrantTests.cs
@@ -0,0 +1,94 @@
+using OpenNest.CNC;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using Xunit;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+public class ExpanderCutoffQuadrantTests
+{
+ private static Program Square()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(2, 0);
+ program.LineTo(2, 2);
+ program.LineTo(0, 2);
+ program.LineTo(0, 0);
+ return program;
+ }
+
+ public static IEnumerable