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