diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularContainmentTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularContainmentTests.cs
new file mode 100644
index 0000000..cefc663
--- /dev/null
+++ b/OpenNest.Engine.Tests/NestingEngines/IrregularContainmentTests.cs
@@ -0,0 +1,137 @@
+using Clipper2Lib;
+using OpenNest.Engine.NestingEngines.Irregular;
+
+namespace OpenNest.Engine.Tests.NestingEngines;
+
+public class IrregularContainmentTests
+{
+ [Fact]
+ public void ConcaveContainmentDoesNotCreateSpuriousFreeRegions()
+ {
+ // Synthetic radial profiles, both star-shaped about the origin. Their filled
+ // Minkowski sum is star-shaped too, so it cannot contain an enclosed free region.
+ // Rounding the boundary sweep before adding containment used to leave a thin hole.
+ var large = Orientation(0,
+ (11.1566, 0), (2.9107, 5.0415), (-5.8475, 10.1282),
+ (-5.5736, 0), (-5.3618, -9.287), (2.1573, -3.7365));
+ var small = Orientation(1,
+ (3.53487, 0), (0.95703, 0.95703), (0, 3.50634),
+ (-0.86934, 0.86934), (-3.05031, 0), (-1.06221, -1.06221),
+ (0, -3.56937), (0.97029, -0.97029));
+ var cache = new NoFitCache(0.25);
+
+ var nfp = cache.Get(large, small);
+
+ Assert.Single(nfp.Region);
+ Assert.True(Clipper.IsPositive(nfp.Region[0]));
+ Assert.Equal(PointInPolygonResult.IsInside,
+ Clipper.PointInPolygon(new PointD(0, 0), nfp.Region[0], NoFitCache.Precision));
+ }
+
+ [Fact]
+ public void NarrowEntranceStillPreservesAUsableConcavePocket()
+ {
+ // The opening is narrower than the moving square, but the chamber is larger.
+ // A legal static placement inside it is an enclosed hole in configuration space,
+ // not a material cutout. Removing every negative NFP ring would lose this fit.
+ var chamber = Orientation(0,
+ (0, 0), (10, 0), (10, 4), (8, 4), (8, 2), (2, 2),
+ (2, 8), (8, 8), (8, 6), (10, 6), (10, 10), (0, 10));
+ var square = Orientation(1, (0, 0), (2.5, 0), (2.5, 2.5), (0, 2.5));
+ var cache = new NoFitCache(0.25);
+
+ var nfp = cache.Get(chamber, square);
+
+ Assert.Contains(nfp.Region, path => !Clipper.IsPositive(path));
+ Assert.False(Forbidden(nfp, new PointD(4, 4)));
+ Assert.True(Forbidden(nfp, new PointD(1, 4)));
+ Assert.False(Forbidden(nfp, new PointD(11, 4)));
+ }
+
+ [Theory]
+ [InlineData(1.0)]
+ [InlineData(100000000.0)]
+ public void RemovesOnlyRingsWhollyInsideOneForbiddenCell(double scale)
+ {
+ var a = new PathD { new(0, 0), new(10 * scale, 0), new(0, 10 * scale) };
+ var b = new PathD { new(0, 0), new(scale, 0), new(0, scale) };
+ var outer = new PathD { new(-scale, -scale), new(12 * scale, -scale), new(12 * scale, 12 * scale), new(-scale, 12 * scale) };
+ var covered = new PathD { new(scale, scale), new(scale, 2 * scale), new(2 * scale, scale) };
+ // One vertex and the centroid are forbidden, but this ring extends beyond A+B.
+ var partlyCovered = new PathD { new(2 * scale, 2 * scale), new(2 * scale, 8 * scale), new(12 * scale, 2 * scale) };
+ var region = new PathsD { outer, covered, partlyCovered };
+
+ NfpHoleFilter.RemoveCoveredHoles(region, a, b);
+
+ Assert.Equal(2, region.Count);
+ Assert.Same(outer, region[0]);
+ Assert.Same(partlyCovered, region[1]);
+ }
+
+ [Fact]
+ public void SeparateForbiddenCellsDoNotCertifyTheSpaceBetweenThem()
+ {
+ var a = new PathD { new(0, 0), new(10, 0), new(10, 2), new(2, 2), new(2, 10), new(0, 10) };
+ var b = new PathD { new(0, 0), new(0.1, 0), new(0, 0.1) };
+ // Both ends overlap an arm, but the middle crosses usable space in the notch.
+ var ring = new PathD { new(1, 8), new(8, 1), new(7.9, 1) };
+ if (Clipper.IsPositive(ring)) ring.Reverse();
+ var region = new PathsD { ring };
+
+ NfpHoleFilter.RemoveCoveredHoles(region, a, b);
+
+ Assert.Same(ring, Assert.Single(region));
+ }
+
+ [Fact]
+ public void RoundedEarDecisionCannotCertifySpaceOutsideTheSource()
+ {
+ // ABP is a positive turn on the 1e-4 lattice, but its double cross product
+ // is negative. Floating-point ear clipping can emit C,A,B across the notch.
+ var a = new PathD
+ {
+ new(-269144028945 / 10000.0, 180906060632 / 10000.0),
+ new(463355031050 / 10000.0, 832229610235 / 10000.0),
+ new(-702076855 / 10000.0, 419599288578 / 10000.0),
+ new(-269144028945 / 10000.0, 1483553159838 / 10000.0),
+ };
+ var b = new PathD { new(0, 0), new(0.0001, 0), new(0, 0.0001) };
+ var x = (a[1].x + a[2].x + a[3].x) / 3;
+ var y = (a[1].y + a[2].y + a[3].y) / 3;
+ var ring = new PathD { new(x - 10, y - 10), new(x - 10, y + 10), new(x + 10, y - 10) };
+ var region = new PathsD { ring };
+ Assert.Equal(PointInPolygonResult.IsOutside,
+ Clipper.PointInPolygon(new PointD(x, y), a, NoFitCache.Precision));
+
+ NfpHoleFilter.RemoveCoveredHoles(region, a, b);
+
+ Assert.Same(ring, Assert.Single(region));
+ }
+
+ private static bool Forbidden(Nfp nfp, PointD point)
+ {
+ var winding = 0;
+ foreach (var path in nfp.Region)
+ if (Clipper.PointInPolygon(point, path, NoFitCache.Precision) == PointInPolygonResult.IsInside)
+ winding += Clipper.IsPositive(path) ? 1 : -1;
+ return winding != 0;
+ }
+
+ private static Orientation Orientation(int type, params (double X, double Y)[] points)
+ {
+ var outline = new PathD(points.Select(p => new PointD(p.X, p.Y)));
+ var bounds = Clipper.GetBounds(outline);
+ return new Orientation
+ {
+ TypeIndex = type,
+ Index = 0,
+ Rotation = 0,
+ Outline = outline,
+ Tolerance = 0.002,
+ MinX = bounds.left - 0.002,
+ MinY = bounds.top - 0.002,
+ MaxX = bounds.right + 0.002,
+ MaxY = bounds.bottom + 0.002,
+ };
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/Irregular/NfpHoleFilter.cs b/OpenNest.Engine/NestingEngines/Irregular/NfpHoleFilter.cs
new file mode 100644
index 0000000..bfe26d2
--- /dev/null
+++ b/OpenNest.Engine/NestingEngines/Irregular/NfpHoleFilter.cs
@@ -0,0 +1,120 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Clipper2Lib;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.NestingEngines.Irregular;
+
+/// Removes only NFP holes certified to lie wholly inside the filled Minkowski sum.
+internal static class NfpHoleFilter
+{
+ internal static void RemoveCoveredHoles(PathsD region, PathD a, PathD b)
+ {
+ // Rounded Boolean operations can leave hairline holes inside forbidden material.
+ // Size alone cannot distinguish those from a real pocket. A triangle T inside A,
+ // translated by any vertex of B, is wholly inside A+B. If that same convex cell
+ // strictly contains every vertex of a hole, it contains the entire hole as well.
+ if (!region.Any(path => !Clipper.IsPositive(path)))
+ return;
+ RemoveCoveredBy(region, a, b);
+ if (region.Any(path => !Clipper.IsPositive(path)))
+ RemoveCoveredBy(region, b, a);
+ }
+
+ private static void RemoveCoveredBy(PathsD region, PathD source, PathD offsets)
+ {
+ var scale = System.Math.Pow(10, NoFitCache.Precision);
+ var triangles = ConvexDecomposition.Triangulate(ClipperBridge.ToPolygon(source))
+ .Select(triangle => Clipper.ScalePath64(new PathD(triangle.Vertices.Take(3)
+ .Select(point => new PointD(point.X, point.Y))), scale))
+ .ToList();
+ if (!CertifiesSource(Clipper.ScalePath64(source, scale), triangles))
+ return;
+ var translations = Clipper.ScalePath64(offsets, scale);
+ for (var index = region.Count - 1; index >= 0; index--)
+ {
+ var path = region[index];
+ if (Clipper.IsPositive(path))
+ continue;
+ var ring = Clipper.ScalePath64(path, scale);
+ if (IsCovered(ring, triangles, translations))
+ region.RemoveAt(index);
+ }
+ }
+
+ private static bool CertifiesSource(Path64 source, List triangles)
+ {
+ // The shared triangulator uses doubles, so its ears are only candidates.
+ // Positive lattice triangles whose oriented edges cancel to the source boundary
+ // have the source's winding everywhere. For our simple CCW footprint that proves
+ // every triangle lies inside it, without trusting floating-point ear decisions.
+ try
+ {
+ var edges = new Dictionary<(long, long, long, long), int>();
+ void AddBoundary(Path64 path, int direction)
+ {
+ for (var index = 0; index < path.Count; index++)
+ {
+ var a = path[index];
+ var b = path[(index + 1) % path.Count];
+ if (a.X == b.X && a.Y == b.Y)
+ continue;
+ var forward = a.X < b.X || (a.X == b.X && a.Y < b.Y);
+ var key = forward ? (a.X, a.Y, b.X, b.Y) : (b.X, b.Y, a.X, a.Y);
+ edges.TryGetValue(key, out var balance);
+ edges[key] = checked(balance + (forward ? direction : -direction));
+ }
+ }
+ AddBoundary(source, -1);
+ foreach (var triangle in triangles)
+ {
+ if (triangle.Count != 3 || Cross(triangle[0], triangle[1], triangle[2], default) <= 0)
+ return false;
+ AddBoundary(triangle, 1);
+ }
+ return triangles.Count > 0 && edges.Values.All(balance => balance == 0);
+ }
+ catch (OverflowException)
+ {
+ return false;
+ }
+ }
+
+ private static Int128 Cross(Point64 a, Point64 b, Point64 point, Point64 offset) =>
+ checked(((Int128)b.X - a.X) * ((Int128)point.Y - offset.Y - a.Y)
+ - ((Int128)b.Y - a.Y) * ((Int128)point.X - offset.X - a.X));
+
+ private static bool IsCovered(Path64 ring, List triangles, Path64 translations)
+ {
+ foreach (var triangle in triangles)
+ foreach (var offset in translations)
+ if (StrictlyContains(triangle, offset, ring))
+ return true;
+ return false;
+ }
+
+ private static bool StrictlyContains(Path64 triangle, Point64 offset, Path64 ring)
+ {
+ try
+ {
+ foreach (var point in ring)
+ for (var edge = 0; edge < 3; edge++)
+ {
+ var a = triangle[edge];
+ var b = triangle[(edge + 1) % 3];
+ // Work on the existing Clipper lattice. Promote before subtracting;
+ // double cross products can erase a thin ring's containment verdict.
+ if (Cross(a, b, point, offset) <= 0)
+ return false;
+ }
+ return ring.Count >= 3;
+ }
+ catch (OverflowException)
+ {
+ // An uncertifiable ring stays unchanged; uncertainty never fills a real pocket.
+ return false;
+ }
+ }
+}
diff --git a/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs b/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs
index 66f71da..6b52b61 100644
--- a/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs
+++ b/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs
@@ -80,17 +80,46 @@ internal sealed class NoFitCache
}
else
{
- // A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
- // copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
- // (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
- var sweep = Minkowski.Sum(negB, a, true, Precision);
- sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
- sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
- region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
+ region = ConcaveSum(a, negB);
+ NfpHoleFilter.RemoveCoveredHoles(region, a, negB);
}
return new Nfp(region, Clipper.GetBounds(region));
}
+ /// Filled Minkowski sum, with boundary sweeps and containment unioned together.
+ private static PathsD ConcaveSum(PathD a, PathD b)
+ {
+ // Minkowski.Sum rounds the boundary sweep before returning it. Unioning containment
+ // covers afterward can leave hairline holes along those rounded edges. Submit the
+ // raw edge-pair parallelograms and both covers together instead.
+ var pieces = new PathsD
+ {
+ Clipper.TranslatePath(a, b[0].x, b[0].y), // Moving part inside the fixed part.
+ Clipper.TranslatePath(b, a[0].x, a[0].y), // Moving part contains the fixed part.
+ };
+ var previousA = a[^1];
+ foreach (var currentA in a)
+ {
+ var previousB = b[^1];
+ foreach (var currentB in b)
+ {
+ var quad = new PathD
+ {
+ new(previousA.x + previousB.x, previousA.y + previousB.y),
+ new(currentA.x + previousB.x, currentA.y + previousB.y),
+ new(currentA.x + currentB.x, currentA.y + currentB.y),
+ new(previousA.x + currentB.x, previousA.y + currentB.y),
+ };
+ if (!Clipper.IsPositive(quad))
+ quad.Reverse();
+ pieces.Add(quad);
+ previousB = currentB;
+ }
+ previousA = currentA;
+ }
+ return Clipper.Union(pieces, new PathsD(), FillRule.NonZero, Precision);
+ }
+
/// Minkowski sum of two convex CCW polygons by merging edges in angle order.
private static PathD ConvexSum(PathD a, PathD b)
{
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index e8c77a9..41f0a68 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -20,6 +20,11 @@ Rectangles places irregular parts validly, but only as their bounding boxes; it
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
valid at box contact.
+Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
+enclosed cutouts. Concave no-fit polygons are prepared with a single boundary/containment union.
+Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden
+space, preserving genuine enclosed placement pockets without changing spacing tolerances.
+
## Renamed engines
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so