merge: integrate exact-contact rotated rectangle repair

Brings in the independently reviewed exact-contact repair (c7b4ba4,
25755a8, bd5db59): line-only part outlines keep exact analytic bounds,
so an 8x3 part fits rotated on 8.5x3.5 stock at 0.25 spacing, while
curved outlines keep their conservative padding and no validator,
spacing or fit tolerance changes. The held hole-profile and pocket
commits built on bd5db59 stay unmerged.
This commit is contained in:
aj committed 2026-10-02 20:50:19 -04:00
commit c49c387af5
3 files changed
+307 -8

No files matched your search

@@ -0,0 +1,276 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// A part whose material exactly fills the work area in one allowed rotation. The engine must
/// place it: preparation padding for arc flattening may not shrink a line-only part's room.
/// </summary>
public class IrregularExactContactTests
{
private const double PartLength = 8;
private const double PartWidth = 3;
private const double Edge = 0.25;
private const double Spacing = 0.25;
/// <summary>Gap shortfall the independent oracle tolerates (float noise only).</summary>
private const double OracleNoise = 1e-9;
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ExactContactRotatedRectangleIsPlacedInEveryQuadrant(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var result = new IrregularNestingEngine().Solve(job);
AssertPlacedSafely(job, result);
}
[Theory]
[InlineData(8.501, 3.501)]
[InlineData(8.5, 3.501)]
[InlineData(8.501, 3.5)]
[InlineData(8.52, 3.52)] // Generator smoke stock: a looser control, not exact-contact acceptance.
public void LargerNeighbouringStockStillPlacesThePart(double width, double length)
{
var job = RectangleJob(width, length, 1, RotationPolicy.Automatic);
AssertPlacedSafely(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(8.499, 3.5, 1)]
[InlineData(8.5, 3.499, 1)]
[InlineData(8.499, 3.499, 3)]
public void StockOneThousandthTooSmallLeavesThePartUnplaced(double width, double length, int quadrant)
{
var job = RectangleJob(width, length, quadrant, RotationPolicy.Automatic);
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void ZeroRotationOnlyCannotFitAndStaysUnplaced(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Fixed(0));
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void HandPoseAtExactContactPassesAndOneThousandthOverEdgeFails(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var stock = job.Plates[0];
var work = stock.WorkArea;
// +90 degrees maps the source rectangle to X in [-3, 0], Y in [0, 8].
var exact = new NestJobPlacement("rect", 0, work.Left + PartWidth, work.Bottom, System.Math.PI / 2);
Assert.True(OracleGaps(stock, exact).Min() >= Edge - OracleNoise);
Assert.Empty(Violations(job, exact));
Validate(job, exact);
foreach (var (dx, dy) in new[] { (0.001, 0.0), (-0.001, 0.0), (0.0, 0.001), (0.0, -0.001) })
{
var over = exact with { X = exact.X + dx, Y = exact.Y + dy };
Assert.True(OracleGaps(stock, over).Min() < Edge - 0.0009);
Assert.NotEmpty(Violations(job, over));
Assert.Throws<InvalidOperationException>(() => Validate(job, over));
}
}
[Fact]
public void LineOnlyBoundsAreExactWhileFlatteningToleranceIsKept()
{
var job = RectangleJob(8.5, 3.5, 1, RotationPolicy.Automatic);
var type = PartCatalog.Build(job).Single();
var rotated = type.Orientations.Single(o => System.Math.Abs(o.Rotation - System.Math.PI / 2) < 1e-9);
// Chord tolerance still drives tessellation and NFP footprints.
Assert.Equal(PartCatalog.ChordTolerance, rotated.Tolerance);
Assert.Equal(-PartWidth, rotated.MinX, 12);
Assert.Equal(0, rotated.MinY, 12);
Assert.Equal(0, rotated.MaxX, 12);
Assert.Equal(PartLength, rotated.MaxY, 12);
Assert.True(job.Plates[0].Fits(rotated.Width, rotated.Height));
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(1.2)]
public void ArcBoundsStillContainTheTruePerimeterAndTheFlattenedOutline(double angle)
{
var job = Job(new[] { Part("obround", Shapes.Obround(9, 4), 1, RotationPolicy.Fixed(angle)) },
new[] { Stock("sheet", 40, 40, spacing: Spacing) });
var type = PartCatalog.Build(job).Single();
var o = type.Orientations.Single();
Assert.True(o.Tolerance >= PartCatalog.ChordTolerance);
// Independent oracle: sample the stadium analytically (straight sides plus semicircles).
const double length = 9, width = 4, r = width / 2;
var points = new List<(double X, double Y)>();
for (var i = 0; i <= 2000; i++)
{
var t = System.Math.PI * i / 2000;
points.Add((1 + length - r + r * System.Math.Sin(t), 1 + r - r * System.Math.Cos(t)));
points.Add((1 + r - r * System.Math.Sin(t), 1 + r + r * System.Math.Cos(t)));
}
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
foreach (var (x, y) in points)
{
var rx = x * c - y * s;
var ry = x * s + y * c;
Assert.InRange(rx, o.MinX - 1e-9, o.MaxX + 1e-9);
Assert.InRange(ry, o.MinY - 1e-9, o.MaxY + 1e-9);
}
foreach (var p in o.Outline)
{
Assert.InRange(p.x, o.MinX, o.MaxX);
Assert.InRange(p.y, o.MinY, o.MaxY);
}
}
[Theory]
[InlineData(0.0, false)]
[InlineData(System.Math.PI / 2, false)]
[InlineData(System.Math.PI, false)]
[InlineData(3 * System.Math.PI / 2, false)]
[InlineData(0.0, true)]
[InlineData(System.Math.PI / 2, true)]
[InlineData(System.Math.PI, true)]
[InlineData(3 * System.Math.PI / 2, true)]
public void ShortEdgeChainBoundsKeepEveryAnalyticEndpoint(double angle, bool extraOrientation)
{
var job = Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1,
RotationPolicy.Fixed(extraOrientation ? 0 : angle)) }, new[] { Stock("sheet", 20, 20) });
var type = PartCatalog.Build(job).Single();
var orientation = extraOrientation
? PartCatalog.CreateOrientation(type, 100, angle)!
: type.Orientations.Single();
Assert.NotNull(orientation);
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
var endpoints = ShortEdgeChainVertices()
.Select(p => (X: p.X * c - p.Y * s, Y: p.X * s + p.Y * c)).ToArray();
Assert.Equal(endpoints.Min(p => p.X), orientation.MinX, 12);
Assert.Equal(endpoints.Min(p => p.Y), orientation.MinY, 12);
Assert.Equal(endpoints.Max(p => p.X), orientation.MaxX, 12);
Assert.Equal(endpoints.Max(p => p.Y), orientation.MaxY, 12);
Assert.Equal(PartCatalog.ChordTolerance, orientation.Tolerance);
}
[Fact]
public void ShortEdgeChainCannotEscapeAnExactWorkArea()
{
var job = ShortEdgeChainJob(8.5);
var result = new IrregularNestingEngine().Solve(job);
Assert.Empty(NestLayoutCheck.Violations(job, result));
AssertUnplaced(job, result);
}
[Fact]
public void ShortEdgeChainFitsWhenStockContainsItsTrueExtent()
{
var job = ShortEdgeChainJob(8.500024);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal((1, 1, 0), (result.Fulfillment.Single().Requested,
result.Fulfillment.Single().Placed, result.Fulfillment.Single().Unplaced));
var sheet = Assert.Single(result.Plates);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose, ShortEdgeChainVertices()).Min() >= Edge - OracleNoise);
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
// Polygon cleanup can collapse these individually short edges, but their accumulated
// outward extent is real material and exceeds the unchanged work-area slack.
private static (double X, double Y)[] ShortEdgeChainVertices() =>
[(0, 0), (0, 3), (8, 3), (8.000008, 2.999992),
(8.000016, 2.999984), (8.000024, 2.999976), (8, 0)];
private static NestJob ShortEdgeChainJob(double length) =>
Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 3.5, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge)) });
private static NestJob RectangleJob(double width, double length, int quadrant, RotationPolicy rotation) =>
Job(new[] { Rectangle("rect", PartLength, PartWidth, 1, rotation) },
new[] { Stock("sheet", width, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge), quadrant: quadrant) });
private static void AssertPlacedSafely(NestJob job, NestJobResult result)
{
Assert.Equal(NestJobStatus.Complete, result.Status);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 1, 0), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
var sheet = Assert.Single(result.Plates);
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose).Min() >= Edge - OracleNoise,
$"pose ({pose.X:R}, {pose.Y:R}, {pose.Rotation:R}) gaps {string.Join(", ", OracleGaps(sheet.Stock, pose))}");
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
private static void AssertUnplaced(NestJob job, NestJobResult result)
{
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.Empty(result.Plates);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 0, 1), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
LayoutAssert.Valid(job, result);
}
/// <summary>Clearance from the rotated nominal rectangle to each physical sheet edge
/// (left, bottom, right, top), computed from corners and the quadrant's sheet origin.</summary>
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) =>
OracleGaps(stock, pose, [(0, 0), (PartLength, 0), (PartLength, PartWidth), (0, PartWidth)]);
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose,
(double X, double Y)[] vertices)
{
var (c, s) = (System.Math.Cos(pose.Rotation), System.Math.Sin(pose.Rotation));
var corners = vertices
.Select(p => (X: p.X * c - p.Y * s + pose.X, Y: p.X * s + p.Y * c + pose.Y))
.ToArray();
var sheetLeft = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var sheetBottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
return new[]
{
corners.Min(p => p.X) - sheetLeft,
corners.Min(p => p.Y) - sheetBottom,
sheetLeft + stock.Size.Length - corners.Max(p => p.X),
sheetBottom + stock.Size.Width - corners.Max(p => p.Y),
};
}
private static IReadOnlyList<string> Violations(NestJob job, NestJobPlacement pose)
{
var stock = job.Plates[0];
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { pose }) },
new[] { new PartFulfillment("rect", 1, 1, 0) }, new[] { new StockUsage(stock.Id, 1, null) });
return NestLayoutCheck.Violations(job, result);
}
private static void Validate(NestJob job, NestJobPlacement pose) =>
NestJobValidator.ValidateCandidate(new PlateCandidate(new[] { pose }), job.Plates[0],
new Dictionary<string, int> { ["rect"] = 1 }, job.Parts.ToDictionary(p => p.Id));
}
@@ -25,7 +25,11 @@ internal sealed class Orientation
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
public required double Tolerance { get; init; }
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
/// <summary>
/// Outline bounds grown by the outline's actual flattening error, so they contain the true
/// perimeter: by <see cref="Tolerance"/> when arcs were flattened, not at all for line-only
/// outlines, whose vertices are exact. Footprints and NFPs still use <see cref="Tolerance"/>.
/// </summary>
public required double MinX { get; init; }
public required double MinY { get; init; }
public required double MaxX { get; init; }
@@ -96,7 +100,7 @@ internal static class PartCatalog
var outline = Polygonize(perimeter, angle, tolerance);
if (outline.Count < 3)
continue;
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance, perimeter));
}
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
@@ -123,7 +127,7 @@ internal static class PartCatalog
return null;
var tolerance = type.Orientations[0].Tolerance;
var outline = Polygonize(type.Perimeter, angle, tolerance);
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance);
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance, type.Perimeter);
}
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
@@ -163,9 +167,21 @@ internal static class PartCatalog
return path;
}
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline,
double tolerance, Shape perimeter)
{
var bounds = Clipper.GetBounds(outline);
// Curves retain flattening-error padding. For lines, use analytic endpoint bounds:
// polygon cleanup can discard short-edge chains and shrink the material's true extent.
var padding = tolerance;
if (perimeter.Entities.All(e => e.Type == EntityType.Line))
{
var nominal = (Shape)perimeter.Clone();
nominal.Rotate(angle);
var box = nominal.BoundingBox;
bounds = new RectD(box.Left, box.Bottom, box.Right, box.Top);
padding = 0;
}
return new Orientation
{
TypeIndex = typeIndex,
@@ -173,10 +189,10 @@ internal static class PartCatalog
Rotation = angle,
Outline = outline,
Tolerance = tolerance,
MinX = bounds.left - tolerance,
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + tolerance,
MaxY = bounds.bottom + tolerance,
MinX = bounds.left - padding,
MinY = bounds.top - padding, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + padding,
MaxY = bounds.bottom + padding,
};
}
+7
View File
@@ -25,6 +25,13 @@ A free box may absorb a slightly oversized side only at its right/top edge when
coincides with the plate work-area boundary. Internal leftover edges keep the strict packing
tolerance, and actual part dimensions still determine spacing away from the plate boundary.
Irregular keeps nominal orientation bounds for line-only outlines, so an allowed rotation can
fit exactly between the configured plate-edge gaps. These bounds use original rotated line
endpoints, retaining material extents even when polygon cleanup discards short-edge chains.
Curved outlines retain conservative flattening-error padding. This does not relax part-spacing
footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted
work-area bounds remains invalid.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair