From c7b4ba445768ec232293fa998346d13c77910c0b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 18:44:51 -0400 Subject: [PATCH 1/3] test(irregular): cover exact-contact rotated rectangle placement --- .../IrregularExactContactTests.cs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs new file mode 100644 index 0000000..15e0cb4 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs @@ -0,0 +1,206 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Geometry; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// +/// 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. +/// +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; + + /// Gap shortfall the independent oracle tolerates (float noise only). + 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(() => 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); + } + } + + 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); + } + + /// Clearance from the rotated nominal rectangle to each physical sheet edge + /// (left, bottom, right, top), computed from corners and the quadrant's sheet origin. + private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) + { + var (c, s) = (System.Math.Cos(pose.Rotation), System.Math.Sin(pose.Rotation)); + var corners = new[] { (0.0, 0.0), (PartLength, 0.0), (PartLength, PartWidth), (0.0, PartWidth) } + .Select(p => (X: p.Item1 * c - p.Item2 * s + pose.X, Y: p.Item1 * s + p.Item2 * 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 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 { ["rect"] = 1 }, job.Parts.ToDictionary(p => p.Id)); +} From 25755a88507b504342cec5499014c7ec2574c9f0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 18:44:51 -0400 Subject: [PATCH 2/3] fix(irregular): keep exact bounds for line-only part outlines Orientation bounds were padded by the arc chord tolerance even when the outline has no arcs, so a part that exactly fills the work area after rotation was rejected before packing. Pad only outlines with flattened arcs; footprints and NFPs still use the chord tolerance. --- .../NestingEngines/Irregular/PartCatalog.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs index 0e42ceb..445150e 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs @@ -25,7 +25,11 @@ internal sealed class Orientation /// Chord deviation used for arcs; footprints are grown by it to stay conservative. public required double Tolerance { get; init; } - /// Outline bounds grown by the tolerance, so they contain the true perimeter. + /// + /// Outline bounds grown by the outline's actual flattening error, so they contain the true + /// perimeter: by when arcs were flattened, not at all for line-only + /// outlines, whose vertices are exact. Footprints and NFPs still use . + /// 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,13 @@ 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); + // Only flattened arcs deviate from the true perimeter; line vertices are exact, so a + // line-only part keeps its nominal material bounds and can fill its work area exactly. + var padding = perimeter.Entities.All(e => e.Type == EntityType.Line) ? 0 : tolerance; return new Orientation { TypeIndex = typeIndex, @@ -173,10 +181,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, }; } From bd5db59e42872f2f769410454aecbec6b33da2b7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 20:45:22 -0400 Subject: [PATCH 3/3] fix(irregular): preserve analytic line bounds after polygon cleanup --- .../IrregularExactContactTests.cs | 76 ++++++++++++++++++- .../NestingEngines/Irregular/PartCatalog.cs | 14 +++- docs/nesting-engines.md | 7 ++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs index 15e0cb4..3b74b49 100644 --- a/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularExactContactTests.cs @@ -143,6 +143,72 @@ public class IrregularExactContactTests } } + [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) }); @@ -174,11 +240,15 @@ public class IrregularExactContactTests /// Clearance from the rotated nominal rectangle to each physical sheet edge /// (left, bottom, right, top), computed from corners and the quadrant's sheet origin. - private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) + 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 = new[] { (0.0, 0.0), (PartLength, 0.0), (PartLength, PartWidth), (0.0, PartWidth) } - .Select(p => (X: p.Item1 * c - p.Item2 * s + pose.X, Y: p.Item1 * s + p.Item2 * c + pose.Y)) + 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; diff --git a/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs index 445150e..f19bfa7 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs @@ -171,9 +171,17 @@ internal static class PartCatalog double tolerance, Shape perimeter) { var bounds = Clipper.GetBounds(outline); - // Only flattened arcs deviate from the true perimeter; line vertices are exact, so a - // line-only part keeps its nominal material bounds and can fill its work area exactly. - var padding = perimeter.Entities.All(e => e.Type == EntityType.Line) ? 0 : tolerance; + // 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, diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 7d3c9e1..5680d2c 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -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