Layouts placed exactly at the part spacing can land ~1e-4 short once rotated, rounded (e.g. PEP's 4-decimal exports) and snapped to the Clipper grid, so both validators rejected layouts that were correct in practice. NestTolerances.SpacingSlack (0.0005, far below anything a cutting machine resolves) is now subtracted from the spacing by NestLayoutCheck's inflation and NestJobPlacementValidator's edge-distance check. The frozen LegacyNestValidator takes the same rule so the equivalence tests keep comparing like with like. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.0 KiB
C#
207 lines
8.0 KiB
C#
using OpenNest.Converters;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
using OpenNest.Engine.Jobs.Placement;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Shapes;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs;
|
|
|
|
/// <summary>
|
|
/// The candidate validator flattens each placed contour once and reuses it across pairs. These
|
|
/// tests compare its accept/reject decision with a reference that rebuilds every contour and
|
|
/// checks every edge pair, on arc-heavy parts placed right around the spacing.
|
|
/// </summary>
|
|
public class NestJobSpacingValidationTests
|
|
{
|
|
private const double Spacing = 0.25;
|
|
private const double Origin = 50;
|
|
|
|
public static IEnumerable<object[]> Shapes() =>
|
|
new[]
|
|
{
|
|
new object[] { "rounded", new RoundedRectangleShape { Length = 12, Width = 6, Radius = 1.5 }.GetDrawing().Program },
|
|
new object[] { "ring", new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Shapes))]
|
|
public void SpacingDecisionMatchesBruteForceNearTheLimit(string name, OpenNest.CNC.Program program)
|
|
{
|
|
var geometry = PartGeometrySnapshot.FromProgram(program);
|
|
var part = new NestJobPart("part", geometry, 2);
|
|
var job = new NestJob(
|
|
new[] { part },
|
|
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
|
|
);
|
|
|
|
var bounds = program.BoundingBox();
|
|
// string.GetHashCode is randomized per process; a stable seed keeps the sampling reproducible.
|
|
var random = new Random(name.Aggregate(17, (hash, c) => unchecked(hash * 31 + c)) & 0x7fff);
|
|
var accepted = 0;
|
|
var rejected = 0;
|
|
|
|
for (var sample = 0; sample < 60; sample++)
|
|
{
|
|
// Second part beside or above the first with a gap near the spacing, shifted
|
|
// sideways so corners and arcs meet at varied angles, and sometimes turned.
|
|
var gap = Spacing + (random.NextDouble() - 0.5) * 0.06;
|
|
var rotation = sample % 3 == 0 ? System.Math.PI : sample % 3 == 1 ? 0.0 : 0.05;
|
|
var shift = (random.NextDouble() - 0.5) * 0.5 * bounds.Width;
|
|
var beside = sample % 2 == 0;
|
|
var second = beside
|
|
? new NestJobPlacement("part", 1, Origin + bounds.Length + gap, Origin + shift, rotation)
|
|
: new NestJobPlacement("part", 1, Origin + shift, Origin + bounds.Width + gap, rotation);
|
|
var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
|
|
|
|
var expectedValid = !ReferenceViolates(geometry, first, second);
|
|
var actualValid = IsValid(job, first, second);
|
|
|
|
Assert.True(
|
|
expectedValid == actualValid,
|
|
$"{name} sample {sample}: gap {gap:F5}, shift {shift:F4}, rotation {rotation}: "
|
|
+ $"brute force {(expectedValid ? "accepts" : "rejects")}, validator {(actualValid ? "accepts" : "rejects")}"
|
|
);
|
|
|
|
if (actualValid)
|
|
accepted++;
|
|
else
|
|
rejected++;
|
|
}
|
|
|
|
// The sampling has to exercise both outcomes to mean anything.
|
|
Assert.True(accepted > 5, $"only {accepted} accepted");
|
|
Assert.True(rejected > 5, $"only {rejected} rejected");
|
|
}
|
|
|
|
[Fact]
|
|
public void RingsPlacedExactlyAtSpacingPass()
|
|
{
|
|
var program = new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program;
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
|
|
var job = new NestJob(
|
|
new[] { part },
|
|
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
|
|
);
|
|
|
|
// Inscribed chords never bring arcs closer, so an exact-spacing layout stays valid.
|
|
Assert.True(IsValid(
|
|
job,
|
|
new NestJobPlacement("part", 0, Origin, Origin, 0),
|
|
new NestJobPlacement("part", 1, Origin + 8 + Spacing, Origin, 0)
|
|
));
|
|
Assert.False(IsValid(
|
|
job,
|
|
new NestJobPlacement("part", 0, Origin, Origin, 0),
|
|
new NestJobPlacement("part", 1, Origin + 8 + Spacing - 0.01, Origin, 0)
|
|
));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.0001, true)]
|
|
[InlineData(0.0004, true)]
|
|
[InlineData(0.0010, false)]
|
|
public void GapsJustUnderSpacingPassWithinTheSlack(double shortfall, bool expected)
|
|
{
|
|
// A layout placed at the spacing can land ~1e-4 short once rotated and rounded.
|
|
var program = TestDrawingFactory.Rectangle(4, 3);
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
|
|
var job = new NestJob(
|
|
new[] { part },
|
|
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
|
|
);
|
|
var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
|
|
var second = new NestJobPlacement("part", 1, Origin + 4 + Spacing - shortfall, Origin, 0);
|
|
|
|
Assert.Equal(expected, IsValid(job, first, second));
|
|
var geometry = JobPartGeometry.Read(part.Geometry);
|
|
Assert.Equal(expected, NestLayoutCheck.Clears(geometry, first, geometry, second, Spacing));
|
|
}
|
|
|
|
private static bool IsValid(NestJob job, params NestJobPlacement[] placements)
|
|
{
|
|
try
|
|
{
|
|
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
|
|
return true;
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool ReferenceViolates(
|
|
PartGeometrySnapshot geometry,
|
|
NestJobPlacement first,
|
|
NestJobPlacement second
|
|
)
|
|
{
|
|
var a = Contours(geometry, first);
|
|
var b = Contours(geometry, second);
|
|
|
|
if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList()))
|
|
return true;
|
|
|
|
var limit = Spacing - NestTolerances.SpacingSlack;
|
|
foreach (var left in a)
|
|
{
|
|
var leftLines = left.ToLines();
|
|
foreach (var right in b)
|
|
{
|
|
var rightLines = right.ToLines();
|
|
foreach (var l in leftLines)
|
|
foreach (var r in rightLines)
|
|
{
|
|
if (l.Intersects(r))
|
|
return true;
|
|
var d = System.Math.Min(
|
|
System.Math.Min(
|
|
l.ClosestPointTo(r.StartPoint).DistanceTo(r.StartPoint),
|
|
l.ClosestPointTo(r.EndPoint).DistanceTo(r.EndPoint)
|
|
),
|
|
System.Math.Min(
|
|
r.ClosestPointTo(l.StartPoint).DistanceTo(l.StartPoint),
|
|
r.ClosestPointTo(l.EndPoint).DistanceTo(l.EndPoint)
|
|
)
|
|
);
|
|
if (d < limit)
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Perimeter polygon first, then cutouts, placed the way the validator places them.</summary>
|
|
private static List<Polygon> Contours(PartGeometrySnapshot geometry, NestJobPlacement placement)
|
|
{
|
|
var entities = ConvertProgram
|
|
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
|
|
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
|
|
.ToList();
|
|
var profile = new ShapeProfile(entities);
|
|
profile.NormalizeWinding();
|
|
|
|
return new[] { profile.Perimeter }
|
|
.Concat(profile.Cutouts)
|
|
.Select(shape =>
|
|
{
|
|
var contour = (Shape)shape.Clone();
|
|
contour.Rotate(placement.Rotation);
|
|
contour.Offset(placement.X, placement.Y);
|
|
return contour.ToPolygonWithTolerance(0.001);
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
|
|
{
|
|
public PlateCandidate Place(
|
|
PlatePlacementRequest request,
|
|
IProgress<NestJobProgress>? progress = null,
|
|
CancellationToken token = default
|
|
) => new(placements);
|
|
}
|
|
}
|