fix(irregular): preserve analytic line bounds after polygon cleanup

This commit is contained in:
aj committed 2026-10-01 20:45:22 -04:00
1 parent 25755a8850
commit bd5db59e42
3 files changed
+91 -6

No files matched your search

@@ -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
/// <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)
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;
@@ -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,
+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