mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-01 16:38:49 -04:00
fix(irregular): prevent overlaps from false NFP gaps
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user