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;
///
/// 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.
///
public class NestJobSpacingValidationTests
{
private const double Spacing = 0.25;
private const double Origin = 50;
public static IEnumerable 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;
}
/// Perimeter polygon first, then cutouts, placed the way the validator places them.
private static List 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 placements) : IPlateNester
{
public PlateCandidate Place(
PlatePlacementRequest request,
IProgress? progress = null,
CancellationToken token = default
) => new(placements);
}
}