fix(irregular): prevent overlaps from false NFP gaps

This commit is contained in:
aj
2026-10-01 01:05:07 -04:00
parent 56ab3a5f30
commit 62fd42346e
4 changed files with 298 additions and 7 deletions
@@ -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,
};
}
}
@@ -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;
/// <summary>Removes only NFP holes certified to lie wholly inside the filled Minkowski sum.</summary>
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<Path64> 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<Path64> 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;
}
}
}
@@ -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));
}
/// <summary>Filled Minkowski sum, with boundary sweeps and containment unioned together.</summary>
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);
}
/// <summary>Minkowski sum of two convex CCW polygons by merging edges in angle order.</summary>
private static PathD ConvexSum(PathD a, PathD b)
{
+5
View File
@@ -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