From dc9e83ef17ee10e69d4d0a61fd878f5510bb4bc7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 14:29:43 -0400 Subject: [PATCH] perf(jobs): flatten validator contours once, at a 0.001 chord tolerance The candidate validator flattened every arc into 1000 segments and rebuilt both parts' polygons and edge lists for every pair it compared, so spacing checks on filleted parts cost millions of edge pairs each. Validation, not the fill pipeline, was nearly all of a solve's wall time. Placed contours are now flattened once with ToPolygonWithTolerance(0.001), the tolerance the benchmark NestValidator and Part.Intersects already use, and each part's shape is built once per candidate. Arcs stay inscribed, so a layout placed exactly at the spacing still passes. 12-nest PEP corpus, Default + StockLadder, --parallel 1: 2820 s -> 227 s. Every run that finished before gives the same validity, count, plates and cost. Three StockLadder runs that used to hit the 5-minute timeout now finish. Co-Authored-By: Claude Opus 5.5 --- .../Jobs/NestJobSpacingValidationTests.cs | 185 ++++++++++++++++++ .../Jobs/NestJobPlacementValidator.cs | 102 ++++++---- 2 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs new file mode 100644 index 0000000..523a09e --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs @@ -0,0 +1,185 @@ +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 Epsilon = 0.0000001; + 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(); + var random = new Random(name.GetHashCode(StringComparison.Ordinal) & 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) + )); + } + + 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 - Epsilon; + 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); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 4ef0c39..97426aa 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -12,6 +12,10 @@ namespace OpenNest.Engine.Jobs; internal static class NestJobPlacementValidator { private const double Epsilon = 0.0000001; + // Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's + // NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at + // the spacing passes; outward arcs may come up to this much closer than the spacing. + private const double PlacementChordTolerance = 0.001; internal static void ValidateCandidate( PlateCandidate candidate, @@ -24,6 +28,7 @@ internal static class NestJobPlacementValidator throw new InvalidOperationException("The plate nester returned a null candidate."); var counts = new Dictionary(StringComparer.Ordinal); var placed = new List(); + var sources = new Dictionary(StringComparer.Ordinal); foreach (var placement in candidate.Placements) { if ( @@ -48,7 +53,9 @@ internal static class NestJobPlacementValidator "Candidate rotation is not allowed for the requirement." ); - var shape = Transform(CreateShape(part.Geometry), placement); + if (!sources.TryGetValue(placement.PartId, out var source)) + sources[placement.PartId] = source = CreateShape(part.Geometry); + var shape = Transform(source, placement); if (!FitsWorkArea(shape, stock)) throw new InvalidOperationException( "Candidate placement falls outside the usable stock area." @@ -296,8 +303,8 @@ internal static class NestJobPlacementValidator private static bool Overlaps(ShapeTopology left, ShapeTopology right) { - var leftPoly = ToPolygon(left.Perimeter); - var rightPoly = ToPolygon(right.Perimeter); + var leftPoly = left.Contours[0].Polygon; + var rightPoly = right.Contours[0].Polygon; if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox)) return false; // True material overlap requires shared interior area, not boundary touching. @@ -308,8 +315,8 @@ internal static class NestJobPlacementValidator return Collision.HasOverlap( leftPoly, rightPoly, - ToPolygons(left.Cutouts), - ToPolygons(right.Cutouts) + left.CutoutPolygons, + right.CutoutPolygons ); } @@ -365,44 +372,22 @@ internal static class NestJobPlacementValidator private static double Distance(ShapeTopology left, ShapeTopology right) { var result = double.PositiveInfinity; - foreach (var leftContour in AllContours(left)) - foreach (var rightContour in AllContours(right)) - if (BoundsDistance(leftContour.BoundingBox, rightContour.BoundingBox) < result) + foreach (var leftContour in left.Contours) + foreach (var rightContour in right.Contours) + if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result) result = System.Math.Min( result, - BoundaryDistance(ToPolygon(leftContour), ToPolygon(rightContour)) + BoundaryDistance(leftContour.Lines, rightContour.Lines) ); return result; } - private static IEnumerable AllContours(ShapeTopology shape) - { - yield return shape.Perimeter; - foreach (var cutout in shape.Cutouts) - yield return cutout; - } - - private static List ToPolygons(List contours) - { - var polygons = new List(contours.Count); - foreach (var contour in contours) - polygons.Add(ToPolygon(contour)); - return polygons; - } - - private static Polygon ToPolygon(Shape contour) - { - var polygon = contour.ToPolygon(); - polygon.UpdateBounds(); - return polygon; - } - - private static double BoundaryDistance(Polygon left, Polygon right) + private static double BoundaryDistance(List left, List right) { var result = double.PositiveInfinity; - foreach (var leftLine in left.ToLines()) + foreach (var leftLine in left) { - foreach (var rightLine in right.ToLines()) + foreach (var rightLine in right) { if (leftLine.Intersects(rightLine)) return 0; @@ -429,7 +414,56 @@ internal static class NestJobPlacementValidator private sealed class ShapeTopology(Shape perimeter, List cutouts) { + private Contour[] contours; + private List cutoutPolygons; + internal Shape Perimeter { get; } = perimeter; internal List Cutouts { get; } = cutouts; + + /// The perimeter first, then the cutouts, each flattened once on first use. + internal Contour[] Contours + { + get + { + if (contours != null) + return contours; + var result = new Contour[Cutouts.Count + 1]; + result[0] = new Contour(Perimeter); + for (var i = 0; i < Cutouts.Count; i++) + result[i + 1] = new Contour(Cutouts[i]); + return contours = result; + } + } + + internal List CutoutPolygons + { + get + { + if (cutoutPolygons != null) + return cutoutPolygons; + var result = new List(Cutouts.Count); + for (var i = 1; i < Contours.Length; i++) + result.Add(Contours[i].Polygon); + return cutoutPolygons = result; + } + } + } + + /// + /// A contour flattened once, at , for the overlap and + /// spacing checks against every other placement. + /// + private sealed class Contour + { + internal Contour(Shape shape) + { + Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance); + Bounds = Polygon.BoundingBox; + Lines = Polygon.ToLines(); + } + + internal Box Bounds { get; } + internal Polygon Polygon { get; } + internal List Lines { get; } } }