From 7f63c725e6e7831960c6ed8ecf45add1a7d0a6cf Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 08:54:54 -0400 Subject: [PATCH] refactor(engine): expose the layout validation contract to engines Engines had to reverse-engineer the benchmark validator: Opus55 assumed a 0.01 arc tolerance (the validator uses 0.001), Gpt6Astra added hand-tuned paddings and copied the validator's check order, Qwen picked its chord tolerance to stay under a constant it could not reference. NestTolerances publishes the validator's arc tolerance, the Clipper grid and SafeClearanceMargin (with its derivation). NestLayoutCheck moves the benchmark NestValidator's checks into OpenNest.Engine as a public API (Clears for a part pair, Violations for a whole result); NestValidator is now a thin wrapper. Verdicts are unchanged: tests compare ordered violation lists against a frozen copy of the old validator, and a tangent-disc stress test covers 432 pairs at the safe margin. Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 --- OpenNest.Benchmark/NestValidator.cs | 395 +--------------- .../Jobs/NestLayoutCheckTests.cs | 50 ++ .../Jobs/NestJobPlacementValidator.cs | 2 +- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 426 ++++++++++++++++++ OpenNest.Engine/Jobs/NestTolerances.cs | 29 ++ OpenNest.Engine/OpenNest.Engine.csproj | 1 + .../Benchmark/LegacyNestValidator.cs | 383 ++++++++++++++++ .../NestLayoutCheckEquivalenceTests.cs | 85 ++++ 8 files changed, 997 insertions(+), 374 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/NestLayoutCheckTests.cs create mode 100644 OpenNest.Engine/Jobs/NestLayoutCheck.cs create mode 100644 OpenNest.Engine/Jobs/NestTolerances.cs create mode 100644 OpenNest.Tests/Benchmark/LegacyNestValidator.cs create mode 100644 OpenNest.Tests/Benchmark/NestLayoutCheckEquivalenceTests.cs diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index 5d81996..13506df 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -1,381 +1,30 @@ using System.Collections.Generic; -using System.Linq; -using OpenNest.Converters; using OpenNest.Engine.Jobs; -using OpenNest.Geometry; -using OpenNest.Math; -namespace OpenNest.Benchmark +namespace OpenNest.Benchmark; + +/// Benchmark validation outcome. +public class ValidationResult { - public class ValidationResult + public bool Valid => Violations.Count == 0; + public List Violations { get; } = new(); +} + +/// Compatibility wrapper over the shared layout validation contract. +public static class NestValidator +{ + /// Checks materialized plates using drawing-reference requirement identity. + public static ValidationResult Validate( + List<(Plate Plate, List Parts)> plateRuns, + IReadOnlyDictionary requirements) { - public bool Valid => Violations.Count == 0; - public List Violations { get; } = new(); + var result = new ValidationResult(); + result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements)); + return result; } - /// - /// Validates a (possibly multi-plate) placed layout against the benchmark - /// rules: on every plate, every part must lie within that plate's work - /// area and every pair of parts must be at least PartSpacing apart; across - /// all plates combined, no drawing may have more parts placed than - /// requested (the quantity limit is a property of the whole order, not of - /// any one plate). Geometry checks work on arbitrary (concave, holed) - /// polygons by reusing the same world-space extraction Part.Intersects - /// uses internally, so no engine gets an advantage or penalty from shape - /// complexity. - /// - public static class NestValidator - { - /// - /// requirements maps each materialized part's BaseDrawing (by reference - materialized - /// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so - /// identity must never be inferred from Name, which is only incidentally seeded from the - /// originating NestJobPart id) to its original quantity limit and display name. - /// - public static ValidationResult Validate( - List<(Plate Plate, List Parts)> plateRuns, - IReadOnlyDictionary requirements - ) - { - var result = new ValidationResult(); - var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList(); - - if (allParts.Count == 0) - return result; - - ValidateQuantities(allParts, requirements, result); - - foreach (var (plate, parts) in plateRuns) - { - if (parts.Count == 0) - continue; - - ValidateBounds(parts, plate, requirements, result); - ValidateAreaBudget(parts, plate, result); - ValidateSpacing(parts, plate.PartSpacing, requirements, result); - } - - return result; - } - - /// - /// Checks what the materialized layout cannot show: every sheet must be - /// one of the job's own stock entries (an engine may not invent a sheet - /// size or loosen its spacing/edge settings, which the layout checks - /// would otherwise trust), finite stock may not be overdrawn, and every - /// placement's rotation must satisfy its part's RotationPolicy. - /// - public static void ValidateAgainstJob( - NestJob job, - NestJobResult jobResult, - IReadOnlyDictionary displayNames, - ValidationResult result - ) - { - var stockById = job.Plates.ToDictionary(s => s.Id); - var partsById = job.Parts.ToDictionary(p => p.Id); - var sheetsUsed = new Dictionary(); - - foreach (var sheet in jobResult.Plates) - { - if ( - !stockById.TryGetValue(sheet.Stock.Id, out var stock) - || !SameSettings(stock, sheet.Stock) - ) - { - result.Violations.Add( - $"Plate {sheet.PlateIndex} uses stock '{sheet.Stock.Id}' ({sheet.Stock.Size}) that does not match any stock offered by the job" - ); - continue; - } - - sheetsUsed[stock.Id] = sheetsUsed.GetValueOrDefault(stock.Id) + 1; - } - - foreach (var (stockId, used) in sheetsUsed) - { - var available = stockById[stockId].Quantity; - - if (available.HasValue && used > available.Value) - { - result.Violations.Add( - $"Used {used} sheet(s) of stock '{stockId}' but only {available.Value} are available" - ); - } - } - - foreach (var sheet in jobResult.Plates) - { - foreach (var placement in sheet.Placements) - { - if (!partsById.TryGetValue(placement.PartId, out var part)) - continue; // reported by ValidateQuantities - - if (!part.Rotation.Allows(placement.Rotation)) - { - var name = displayNames.TryGetValue(part.Id, out var n) ? n : part.Id; - result.Violations.Add( - $"'{name}' placed at {Angle.ToDegrees(placement.Rotation):F3}° on plate {sheet.PlateIndex}, " - + $"outside its rotation constraint ({Describe(part.Rotation)})" - ); - } - } - } - } - - private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => - ReferenceEquals(expected, actual) - || ( - expected.Size.Equals(actual.Size) - && expected.PartSpacing.IsEqualTo(actual.PartSpacing) - && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) - && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) - && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) - && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) - && expected.Quadrant == actual.Quadrant - ); - - private static string Describe(RotationPolicy policy) => - policy.Kind switch - { - RotationPolicyKind.Fixed => $"fixed at {Angle.ToDegrees(policy.Start):F3}°", - RotationPolicyKind.BoundedSweep => - $"{Angle.ToDegrees(policy.Start):F3}° to {Angle.ToDegrees(policy.End):F3}° in {Angle.ToDegrees(policy.Step):F3}° steps", - _ => "any", - }; - - private static void ValidateQuantities( - List parts, - IReadOnlyDictionary requirements, - ValidationResult result - ) - { - var placedCounts = parts - .GroupBy(p => p.BaseDrawing, ReferenceEqualityComparer.Instance) - .ToDictionary(g => g.Key, g => g.Count()); - - foreach (var (drawing, placed) in placedCounts) - { - if (!requirements.TryGetValue(drawing, out var requirement)) - { - result.Violations.Add( - $"Placed drawing '{drawing.Name}' which was not requested for this job" - ); - continue; - } - - if (placed > requirement.Quantity) - { - result.Violations.Add( - $"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested" - ); - } - } - } - - private static void ValidateBounds( - List parts, - Plate plate, - IReadOnlyDictionary requirements, - ValidationResult result - ) - { - var workArea = plate.WorkArea(); - - foreach (var part in parts) - { - var bb = part.BoundingBox; - - var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; - var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; - var outRight = bb.Right > workArea.Right + Tolerance.Epsilon; - var outTop = bb.Top > workArea.Top + Tolerance.Epsilon; - - if (outLeft || outBottom || outRight || outTop) - { - result.Violations.Add( - $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " - + $"of a {plate.Size} plate" - ); - } - } - } - - /// - /// Hard mathematical backstop: non-overlapping parts confined to the - /// work area can never have a combined area greater than the work - /// area itself. This catches overlap that the polygon-based - /// ValidateSpacing check can miss - Collision.HasOverlap (and - /// Part.Intersects, which uses the same algorithm) has been observed - /// to return false negatives on real, complex production geometry, so - /// this check does not depend on it. - /// - private static void ValidateAreaBudget( - List parts, - Plate plate, - ValidationResult result - ) - { - var workArea = plate.WorkArea(); - var budget = workArea.Width * workArea.Length; - var placedArea = parts.Sum(p => p.BaseDrawing.Area); - - if (placedArea > budget + Tolerance.Epsilon) - { - result.Violations.Add( - $"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " - + "parts must overlap even though the polygon overlap check did not flag a pair" - ); - } - } - - /// - /// Every pair of parts must be at least apart. - /// Each part's material is inflated by the spacing (perimeter offset - /// outward, holes shrunk inward) and tested against the other part's raw - /// material, with holes subtracted on both sides - so a small part - /// nested inside another part's cutout (part-in-part) is legal as long as - /// it clears the cutout's edge by the spacing. Pairs are pruned with an - /// X-sorted sweep over bounding boxes so only neighbours reach the - /// polygon clipper. - /// - private static void ValidateSpacing( - List parts, - double spacing, - IReadOnlyDictionary requirements, - ValidationResult result - ) - { - var raw = new PartOutline[parts.Count]; - var inflated = new PartOutline[parts.Count]; - - for (var i = 0; i < parts.Count; i++) - { - raw[i] = Outline(parts[i], 0); - inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; - } - - var order = Enumerable - .Range(0, parts.Count) - .Where(i => raw[i] != null && inflated[i] != null) - .OrderBy(i => raw[i].Perimeter.BoundingBox.Left) - .ToList(); - - for (var a = 0; a < order.Count; a++) - { - var i = order[a]; - var reach = inflated[i].Perimeter.BoundingBox; - - for (var b = a + 1; b < order.Count; b++) - { - var j = order[b]; - var other = raw[j].Perimeter.BoundingBox; - - // Sorted by Left, so nothing further along can reach part i either. - if (other.Left > reach.Right + Tolerance.Epsilon) - break; - - if (!BoxesTouch(reach, other)) - continue; - - // Inflating one side by the full spacing covers both cases: part j - // inside part i's (shrunk) cutout, or part i's inflated outline - // inside part j's raw cutout. - if ( - Collision.HasOverlap( - inflated[i].Perimeter, - raw[j].Perimeter, - inflated[i].Holes, - raw[j].Holes - ) - ) - { - result.Violations.Add( - $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})" - ); - } - } - } - } - - private static bool BoxesTouch(Box a, Box b) => - a.Left <= b.Right + Tolerance.Epsilon - && b.Left <= a.Right + Tolerance.Epsilon - && a.Bottom <= b.Top + Tolerance.Epsilon - && b.Bottom <= a.Top + Tolerance.Epsilon; - - /// Friendly name for a violation message, falling back to the materialized - /// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all - - /// that mismatch is already reported by ValidateQuantities, so this is display-only. - private static string DisplayName( - Part part, - IReadOnlyDictionary requirements - ) => - requirements.TryGetValue(part.BaseDrawing, out var requirement) - ? requirement.Name - : part.BaseDrawing.Name; - - private const double OutlineTolerance = 0.001; - - private sealed class PartOutline - { - public Polygon Perimeter { get; init; } - public List Holes { get; init; } - } - - /// - /// Extracts a part's material as world-space polygons - the perimeter and - /// its cutouts - grown by (perimeter offset - /// outward, cutouts offset inward, in one Clipper region offset). A cutout - /// that closes up under the offset is dropped, which treats it as solid: - /// conservative, since it has no room for another part at the required - /// spacing anyway. Arcs are flattened conservatively (perimeter arcs - /// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout - /// exactly at the spacing passes; the only leniency is the round-join chord - /// error at convex corners (OutlineTolerance / 10). - /// part.Program is already rotated; only a Location offset is needed. - /// - private static PartOutline Outline(Part part, double inflateBy) - { - var entities = ConvertProgram - .ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) - .ToList(); - - if (entities.Count == 0) - return null; - - var profile = new ShapeProfile(entities); - - if (profile.Perimeter == null) - return null; - - // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 - // segments per arc) - arc-heavy real parts otherwise produce thousands - // of vertices, which is needlessly slow for a spacing check. - var region = ClipperBridge.OffsetForValidation( - profile, - inflateBy > Tolerance.Epsilon ? inflateBy : 0, - OutlineTolerance - ); - - var perimeter = region.LargestOuter(); - - if (perimeter == null) - return null; - - ToWorld(perimeter, part.Location); - - foreach (var hole in region.Holes) - ToWorld(hole, part.Location); - - return new PartOutline { Perimeter = perimeter, Holes = region.Holes }; - } - - private static void ToWorld(Polygon polygon, Vector location) - { - polygon.Offset(location); - polygon.UpdateBounds(); - } - } + /// Appends offered-stock, finite-stock and rotation-policy violations. + public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult, + IReadOnlyDictionary displayNames, ValidationResult result) => + NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations); } diff --git a/OpenNest.Engine.Tests/Jobs/NestLayoutCheckTests.cs b/OpenNest.Engine.Tests/Jobs/NestLayoutCheckTests.cs new file mode 100644 index 0000000..18c61d8 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestLayoutCheckTests.cs @@ -0,0 +1,50 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestLayoutCheckTests +{ + [Fact] + public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances() + { + var count = 0; + foreach (var radius in new[] { 0.1, 1.0, 10.0 }) + foreach (var tolerance in new[] { 0.0, 0.0005, 0.01 }) + foreach (var spacing in new[] { 0.0, 0.25 }) + { + var program = new Program(); + program.MoveTo(radius, 0); + program.Codes.Add(new ArcMove(radius, 0, 0, 0, RotationType.CW)); + var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program)); + // Two inscribed engine outlines can underestimate true extent by t each. + var distance = 2 * radius + spacing + + NestTolerances.SafeClearanceMargin(tolerance) - 2 * tolerance; + for (var degrees = 0; degrees < 360; degrees += 15) + { + var angle = degrees * System.Math.PI / 180; + var a = new NestJobPlacement("disc", 0, 0.12345, -0.54321, angle / 3); + var b = new NestJobPlacement("disc", 1, a.X + distance * System.Math.Cos(angle), + a.Y + distance * System.Math.Sin(angle), -angle / 7); + Assert.True(NestLayoutCheck.Clears(geometry, a, geometry, b, spacing)); + Assert.True(NestLayoutCheck.Clears(geometry, b, geometry, a, spacing)); + count++; + } + } + Assert.Equal(432, count); + } + + [Fact] + public void PairCheckDetectsOverlapAndLeavesGeometryUnchanged() + { + var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3))); + var bounds = geometry.Bounds; + Assert.False(NestLayoutCheck.Clears(geometry, new("p", 0, 0, 0, 0), + geometry, new("p", 1, 2, 0, 0), 0.25)); + Assert.Equal(bounds, geometry.Bounds); + Assert.Equal(0.0032, NestTolerances.SafeClearanceMargin(0.0005), 12); + Assert.Throws(() => NestTolerances.SafeClearanceMargin(-1)); + Assert.Throws(() => NestTolerances.SafeClearanceMargin(double.NaN)); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 4d8fd78..31b671e 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -12,7 +12,7 @@ internal static class NestJobPlacementValidator // 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; + private const double PlacementChordTolerance = NestTolerances.ValidationOutline; internal static void ValidateCandidate( PlateCandidate candidate, diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs new file mode 100644 index 0000000..2d1e271 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -0,0 +1,426 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Jobs; + +/// +/// Validates a (possibly multi-plate) placed layout against the benchmark +/// rules: on every plate, every part must lie within that plate's work +/// area and every pair of parts must be at least PartSpacing apart; across +/// all plates combined, no drawing may have more parts placed than +/// requested (the quantity limit is a property of the whole order, not of +/// any one plate). Geometry checks work on arbitrary (concave, holed) +/// polygons by reusing the same world-space extraction Part.Intersects +/// uses internally, so no engine gets an advantage or penalty from shape +/// complexity. +/// +public static class NestLayoutCheck +{ + /// Checks bounds, spacing, quantities, offered stock and rotation policies. + /// Requirement IDs are used in messages. Instance indices and fulfillment metadata are + /// not checked, matching the benchmark contract. + public static IReadOnlyList Violations(NestJob job, NestJobResult result) + { + var materialized = NestResultMaterializer.Materialize(job, result); + var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], + p => (p.Id, p.Quantity)); + var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(); + var violations = Validate(runs, requirements); + ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), violations); + return violations; + } + + /// Tests material clearance using the benchmark's conservative outlines. + /// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain + /// argument order. Geometry is cloned before transformation. + public static bool Clears(JobPartGeometry a, NestJobPlacement pa, + JobPartGeometry b, NestJobPlacement pb, double spacing) + { + var ap = Transform(a, pa.Rotation); + var bp = Transform(b, pb.Rotation); + var al = new Vector(pa.X, pa.Y); + var bl = new Vector(pb.X, pb.Y); + var ar = Outline(ap, al, 0); + var br = Outline(bp, bl, 0); + if (ar.Perimeter.BoundingBox.Left > br.Perimeter.BoundingBox.Left) + { + (ap, bp) = (bp, ap); + (al, bl) = (bl, al); + (ar, br) = (br, ar); + } + var inflated = spacing > Tolerance.Epsilon ? Outline(ap, al, spacing) : ar; + return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox) + || !Collision.HasOverlap(inflated.Perimeter, br.Perimeter, inflated.Holes, br.Holes); + } + + private static ShapeProfile Transform(JobPartGeometry geometry, double rotation) + { + var shapes = new[] { geometry.Perimeter }.Concat(geometry.Cutouts); + var entities = shapes.SelectMany(s => s.Entities).Select(e => e.Clone()).ToList(); + foreach (var entity in entities) + entity.Rotate(rotation); + return new ShapeProfile(entities); + } + + + /// + /// requirements maps each materialized part's BaseDrawing (by reference - materialized + /// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so + /// identity must never be inferred from Name, which is only incidentally seeded from the + /// originating NestJobPart id) to its original quantity limit and display name. + /// + internal static List Validate( + List<(Plate Plate, List Parts)> plateRuns, + IReadOnlyDictionary requirements + ) + { + var result = new List(); + var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList(); + + if (allParts.Count == 0) + return result; + + ValidateQuantities(allParts, requirements, result); + + foreach (var (plate, parts) in plateRuns) + { + if (parts.Count == 0) + continue; + + ValidateBounds(parts, plate, requirements, result); + ValidateAreaBudget(parts, plate, result); + ValidateSpacing(parts, plate.PartSpacing, requirements, result); + } + + return result; + } + + /// + /// Checks what the materialized layout cannot show: every sheet must be + /// one of the job's own stock entries (an engine may not invent a sheet + /// size or loosen its spacing/edge settings, which the layout checks + /// would otherwise trust), finite stock may not be overdrawn, and every + /// placement's rotation must satisfy its part's RotationPolicy. + /// + internal static void ValidateAgainstJob( + NestJob job, + NestJobResult jobResult, + IReadOnlyDictionary displayNames, + List result + ) + { + var stockById = job.Plates.ToDictionary(s => s.Id); + var partsById = job.Parts.ToDictionary(p => p.Id); + var sheetsUsed = new Dictionary(); + + foreach (var sheet in jobResult.Plates) + { + if ( + !stockById.TryGetValue(sheet.Stock.Id, out var stock) + || !SameSettings(stock, sheet.Stock) + ) + { + result.Add( + $"Plate {sheet.PlateIndex} uses stock '{sheet.Stock.Id}' ({sheet.Stock.Size}) that does not match any stock offered by the job" + ); + continue; + } + + sheetsUsed[stock.Id] = sheetsUsed.GetValueOrDefault(stock.Id) + 1; + } + + foreach (var (stockId, used) in sheetsUsed) + { + var available = stockById[stockId].Quantity; + + if (available.HasValue && used > available.Value) + { + result.Add( + $"Used {used} sheet(s) of stock '{stockId}' but only {available.Value} are available" + ); + } + } + + foreach (var sheet in jobResult.Plates) + { + foreach (var placement in sheet.Placements) + { + if (!partsById.TryGetValue(placement.PartId, out var part)) + continue; // reported by ValidateQuantities + + if (!part.Rotation.Allows(placement.Rotation)) + { + var name = displayNames.TryGetValue(part.Id, out var n) ? n : part.Id; + result.Add( + $"'{name}' placed at {Angle.ToDegrees(placement.Rotation):F3}° on plate {sheet.PlateIndex}, " + + $"outside its rotation constraint ({Describe(part.Rotation)})" + ); + } + } + } + } + + private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => + ReferenceEquals(expected, actual) + || ( + expected.Size.Equals(actual.Size) + && expected.PartSpacing.IsEqualTo(actual.PartSpacing) + && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) + && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) + && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) + && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) + && expected.Quadrant == actual.Quadrant + ); + + private static string Describe(RotationPolicy policy) => + policy.Kind switch + { + RotationPolicyKind.Fixed => $"fixed at {Angle.ToDegrees(policy.Start):F3}°", + RotationPolicyKind.BoundedSweep => + $"{Angle.ToDegrees(policy.Start):F3}° to {Angle.ToDegrees(policy.End):F3}° in {Angle.ToDegrees(policy.Step):F3}° steps", + _ => "any", + }; + + private static void ValidateQuantities( + List parts, + IReadOnlyDictionary requirements, + List result + ) + { + var placedCounts = parts + .GroupBy(p => p.BaseDrawing, ReferenceEqualityComparer.Instance) + .ToDictionary(g => g.Key, g => g.Count()); + + foreach (var (drawing, placed) in placedCounts) + { + if (!requirements.TryGetValue(drawing, out var requirement)) + { + result.Add( + $"Placed drawing '{drawing.Name}' which was not requested for this job" + ); + continue; + } + + if (placed > requirement.Quantity) + { + result.Add( + $"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested" + ); + } + } + } + + private static void ValidateBounds( + List parts, + Plate plate, + IReadOnlyDictionary requirements, + List result + ) + { + var workArea = plate.WorkArea(); + + foreach (var part in parts) + { + var bb = part.BoundingBox; + + var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; + var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; + var outRight = bb.Right > workArea.Right + Tolerance.Epsilon; + var outTop = bb.Top > workArea.Top + Tolerance.Epsilon; + + if (outLeft || outBottom || outRight || outTop) + { + result.Add( + $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " + + $"of a {plate.Size} plate" + ); + } + } + } + + /// + /// Hard mathematical backstop: non-overlapping parts confined to the + /// work area can never have a combined area greater than the work + /// area itself. This catches overlap that the polygon-based + /// ValidateSpacing check can miss - Collision.HasOverlap (and + /// Part.Intersects, which uses the same algorithm) has been observed + /// to return false negatives on real, complex production geometry, so + /// this check does not depend on it. + /// + private static void ValidateAreaBudget( + List parts, + Plate plate, + List result + ) + { + var workArea = plate.WorkArea(); + var budget = workArea.Width * workArea.Length; + var placedArea = parts.Sum(p => p.BaseDrawing.Area); + + if (placedArea > budget + Tolerance.Epsilon) + { + result.Add( + $"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " + + "parts must overlap even though the polygon overlap check did not flag a pair" + ); + } + } + + /// + /// Every pair of parts must be at least apart. + /// Each part's material is inflated by the spacing (perimeter offset + /// outward, holes shrunk inward) and tested against the other part's raw + /// material, with holes subtracted on both sides - so a small part + /// nested inside another part's cutout (part-in-part) is legal as long as + /// it clears the cutout's edge by the spacing. Pairs are pruned with an + /// X-sorted sweep over bounding boxes so only neighbours reach the + /// polygon clipper. + /// + private static void ValidateSpacing( + List parts, + double spacing, + IReadOnlyDictionary requirements, + List result + ) + { + var raw = new PartOutline[parts.Count]; + var inflated = new PartOutline[parts.Count]; + + for (var i = 0; i < parts.Count; i++) + { + raw[i] = Outline(parts[i], 0); + inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; + } + + var order = Enumerable + .Range(0, parts.Count) + .Where(i => raw[i] != null && inflated[i] != null) + .OrderBy(i => raw[i].Perimeter.BoundingBox.Left) + .ToList(); + + for (var a = 0; a < order.Count; a++) + { + var i = order[a]; + var reach = inflated[i].Perimeter.BoundingBox; + + for (var b = a + 1; b < order.Count; b++) + { + var j = order[b]; + var other = raw[j].Perimeter.BoundingBox; + + // Sorted by Left, so nothing further along can reach part i either. + if (other.Left > reach.Right + Tolerance.Epsilon) + break; + + if (!BoxesTouch(reach, other)) + continue; + + // Inflating one side by the full spacing covers both cases: part j + // inside part i's (shrunk) cutout, or part i's inflated outline + // inside part j's raw cutout. + if ( + Collision.HasOverlap( + inflated[i].Perimeter, + raw[j].Perimeter, + inflated[i].Holes, + raw[j].Holes + ) + ) + { + result.Add( + $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})" + ); + } + } + } + } + + private static bool BoxesTouch(Box a, Box b) => + a.Left <= b.Right + Tolerance.Epsilon + && b.Left <= a.Right + Tolerance.Epsilon + && a.Bottom <= b.Top + Tolerance.Epsilon + && b.Bottom <= a.Top + Tolerance.Epsilon; + + /// Friendly name for a violation message, falling back to the materialized + /// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all - + /// that mismatch is already reported by ValidateQuantities, so this is display-only. + private static string DisplayName( + Part part, + IReadOnlyDictionary requirements + ) => + requirements.TryGetValue(part.BaseDrawing, out var requirement) + ? requirement.Name + : part.BaseDrawing.Name; + + private const double OutlineTolerance = NestTolerances.ValidationOutline; + + private sealed class PartOutline + { + public Polygon Perimeter { get; init; } + public List Holes { get; init; } + } + + /// + /// Extracts a part's material as world-space polygons - the perimeter and + /// its cutouts - grown by (perimeter offset + /// outward, cutouts offset inward, in one Clipper region offset). A cutout + /// that closes up under the offset is dropped, which treats it as solid: + /// conservative, since it has no room for another part at the required + /// spacing anyway. Arcs are flattened conservatively (perimeter arcs + /// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout + /// exactly at the spacing passes; the only leniency is the round-join chord + /// error at convex corners (OutlineTolerance / 10). + /// part.Program is already rotated; only a Location offset is needed. + /// + private static PartOutline Outline(Part part, double inflateBy) + { + var entities = ConvertProgram + .ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + + if (profile.Perimeter == null) + return null; + + return Outline(profile, part.Location, inflateBy); + } + + private static PartOutline Outline(ShapeProfile profile, Vector location, double inflateBy) + { + // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 + // segments per arc) - arc-heavy real parts otherwise produce thousands + // of vertices, which is needlessly slow for a spacing check. + var region = ClipperBridge.OffsetForValidation( + profile, + inflateBy > Tolerance.Epsilon ? inflateBy : 0, + OutlineTolerance + ); + + var perimeter = region.LargestOuter(); + + if (perimeter == null) + return null; + + ToWorld(perimeter, location); + + foreach (var hole in region.Holes) + ToWorld(hole, location); + + return new PartOutline { Perimeter = perimeter, Holes = region.Holes }; + } + + private static void ToWorld(Polygon polygon, Vector location) + { + polygon.Offset(location); + polygon.UpdateBounds(); + } +} diff --git a/OpenNest.Engine/Jobs/NestTolerances.cs b/OpenNest.Engine/Jobs/NestTolerances.cs new file mode 100644 index 0000000..932c0e3 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestTolerances.cs @@ -0,0 +1,29 @@ +using System; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +/// Shared numerical contract for layout validation. +public static class NestTolerances +{ + /// Arc chord tolerance used by both validators. The layout check circumscribes + /// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs. + public const double ValidationOutline = 0.001; + + /// Clipper decimal precision (a 1e-4 coordinate grid). + public const int ClipperPrecision = ClipperBridge.Precision; + + /// Extra pair clearance for an engine whose outline error is bounded by t. + /// Each of two boundaries contributes ValidationOutline from validator flattening, + /// one Clipper grid unit from rounding, and t from engine flattening: therefore + /// 2 * ValidationOutline + 2 * 10^(-ClipperPrecision) + 2 * t. + /// This budget assumes valid material geometry and bounded chord error on both sides. + /// Tolerance is negative or non-finite. + public static double SafeClearanceMargin(double engineChordTolerance) + { + if (!double.IsFinite(engineChordTolerance) || engineChordTolerance < 0) + throw new ArgumentOutOfRangeException(nameof(engineChordTolerance)); + return 2 * ValidationOutline + 2 * System.Math.Pow(10, -ClipperPrecision) + + 2 * engineChordTolerance; + } +} diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj index 82981bb..b2a9561 100644 --- a/OpenNest.Engine/OpenNest.Engine.csproj +++ b/OpenNest.Engine/OpenNest.Engine.csproj @@ -6,6 +6,7 @@ + diff --git a/OpenNest.Tests/Benchmark/LegacyNestValidator.cs b/OpenNest.Tests/Benchmark/LegacyNestValidator.cs new file mode 100644 index 0000000..b5b17a2 --- /dev/null +++ b/OpenNest.Tests/Benchmark/LegacyNestValidator.cs @@ -0,0 +1,383 @@ +#nullable disable +// Frozen pre-PR4 benchmark validator: exact message/order equivalence reference. +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Benchmark +{ + public class LegacyValidationResult + { + public bool Valid => Violations.Count == 0; + public List Violations { get; } = new(); + } + + /// + /// Validates a (possibly multi-plate) placed layout against the benchmark + /// rules: on every plate, every part must lie within that plate's work + /// area and every pair of parts must be at least PartSpacing apart; across + /// all plates combined, no drawing may have more parts placed than + /// requested (the quantity limit is a property of the whole order, not of + /// any one plate). Geometry checks work on arbitrary (concave, holed) + /// polygons by reusing the same world-space extraction Part.Intersects + /// uses internally, so no engine gets an advantage or penalty from shape + /// complexity. + /// + public static class LegacyNestValidator + { + /// + /// requirements maps each materialized part's BaseDrawing (by reference - materialized + /// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so + /// identity must never be inferred from Name, which is only incidentally seeded from the + /// originating NestJobPart id) to its original quantity limit and display name. + /// + public static LegacyValidationResult Validate( + List<(Plate Plate, List Parts)> plateRuns, + IReadOnlyDictionary requirements + ) + { + var result = new LegacyValidationResult(); + var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList(); + + if (allParts.Count == 0) + return result; + + ValidateQuantities(allParts, requirements, result); + + foreach (var (plate, parts) in plateRuns) + { + if (parts.Count == 0) + continue; + + ValidateBounds(parts, plate, requirements, result); + ValidateAreaBudget(parts, plate, result); + ValidateSpacing(parts, plate.PartSpacing, requirements, result); + } + + return result; + } + + /// + /// Checks what the materialized layout cannot show: every sheet must be + /// one of the job's own stock entries (an engine may not invent a sheet + /// size or loosen its spacing/edge settings, which the layout checks + /// would otherwise trust), finite stock may not be overdrawn, and every + /// placement's rotation must satisfy its part's RotationPolicy. + /// + public static void ValidateAgainstJob( + NestJob job, + NestJobResult jobResult, + IReadOnlyDictionary displayNames, + LegacyValidationResult result + ) + { + var stockById = job.Plates.ToDictionary(s => s.Id); + var partsById = job.Parts.ToDictionary(p => p.Id); + var sheetsUsed = new Dictionary(); + + foreach (var sheet in jobResult.Plates) + { + if ( + !stockById.TryGetValue(sheet.Stock.Id, out var stock) + || !SameSettings(stock, sheet.Stock) + ) + { + result.Violations.Add( + $"Plate {sheet.PlateIndex} uses stock '{sheet.Stock.Id}' ({sheet.Stock.Size}) that does not match any stock offered by the job" + ); + continue; + } + + sheetsUsed[stock.Id] = sheetsUsed.GetValueOrDefault(stock.Id) + 1; + } + + foreach (var (stockId, used) in sheetsUsed) + { + var available = stockById[stockId].Quantity; + + if (available.HasValue && used > available.Value) + { + result.Violations.Add( + $"Used {used} sheet(s) of stock '{stockId}' but only {available.Value} are available" + ); + } + } + + foreach (var sheet in jobResult.Plates) + { + foreach (var placement in sheet.Placements) + { + if (!partsById.TryGetValue(placement.PartId, out var part)) + continue; // reported by ValidateQuantities + + if (!part.Rotation.Allows(placement.Rotation)) + { + var name = displayNames.TryGetValue(part.Id, out var n) ? n : part.Id; + result.Violations.Add( + $"'{name}' placed at {Angle.ToDegrees(placement.Rotation):F3}° on plate {sheet.PlateIndex}, " + + $"outside its rotation constraint ({Describe(part.Rotation)})" + ); + } + } + } + } + + private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => + ReferenceEquals(expected, actual) + || ( + expected.Size.Equals(actual.Size) + && expected.PartSpacing.IsEqualTo(actual.PartSpacing) + && expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left) + && expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right) + && expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top) + && expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom) + && expected.Quadrant == actual.Quadrant + ); + + private static string Describe(RotationPolicy policy) => + policy.Kind switch + { + RotationPolicyKind.Fixed => $"fixed at {Angle.ToDegrees(policy.Start):F3}°", + RotationPolicyKind.BoundedSweep => + $"{Angle.ToDegrees(policy.Start):F3}° to {Angle.ToDegrees(policy.End):F3}° in {Angle.ToDegrees(policy.Step):F3}° steps", + _ => "any", + }; + + private static void ValidateQuantities( + List parts, + IReadOnlyDictionary requirements, + LegacyValidationResult result + ) + { + var placedCounts = parts + .GroupBy(p => p.BaseDrawing, ReferenceEqualityComparer.Instance) + .ToDictionary(g => g.Key, g => g.Count()); + + foreach (var (drawing, placed) in placedCounts) + { + if (!requirements.TryGetValue(drawing, out var requirement)) + { + result.Violations.Add( + $"Placed drawing '{drawing.Name}' which was not requested for this job" + ); + continue; + } + + if (placed > requirement.Quantity) + { + result.Violations.Add( + $"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested" + ); + } + } + } + + private static void ValidateBounds( + List parts, + Plate plate, + IReadOnlyDictionary requirements, + LegacyValidationResult result + ) + { + var workArea = plate.WorkArea(); + + foreach (var part in parts) + { + var bb = part.BoundingBox; + + var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; + var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; + var outRight = bb.Right > workArea.Right + Tolerance.Epsilon; + var outTop = bb.Top > workArea.Top + Tolerance.Epsilon; + + if (outLeft || outBottom || outRight || outTop) + { + result.Violations.Add( + $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " + + $"of a {plate.Size} plate" + ); + } + } + } + + /// + /// Hard mathematical backstop: non-overlapping parts confined to the + /// work area can never have a combined area greater than the work + /// area itself. This catches overlap that the polygon-based + /// ValidateSpacing check can miss - Collision.HasOverlap (and + /// Part.Intersects, which uses the same algorithm) has been observed + /// to return false negatives on real, complex production geometry, so + /// this check does not depend on it. + /// + private static void ValidateAreaBudget( + List parts, + Plate plate, + LegacyValidationResult result + ) + { + var workArea = plate.WorkArea(); + var budget = workArea.Width * workArea.Length; + var placedArea = parts.Sum(p => p.BaseDrawing.Area); + + if (placedArea > budget + Tolerance.Epsilon) + { + result.Violations.Add( + $"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " + + "parts must overlap even though the polygon overlap check did not flag a pair" + ); + } + } + + /// + /// Every pair of parts must be at least apart. + /// Each part's material is inflated by the spacing (perimeter offset + /// outward, holes shrunk inward) and tested against the other part's raw + /// material, with holes subtracted on both sides - so a small part + /// nested inside another part's cutout (part-in-part) is legal as long as + /// it clears the cutout's edge by the spacing. Pairs are pruned with an + /// X-sorted sweep over bounding boxes so only neighbours reach the + /// polygon clipper. + /// + private static void ValidateSpacing( + List parts, + double spacing, + IReadOnlyDictionary requirements, + LegacyValidationResult result + ) + { + var raw = new PartOutline[parts.Count]; + var inflated = new PartOutline[parts.Count]; + + for (var i = 0; i < parts.Count; i++) + { + raw[i] = Outline(parts[i], 0); + inflated[i] = spacing > Tolerance.Epsilon ? Outline(parts[i], spacing) : raw[i]; + } + + var order = Enumerable + .Range(0, parts.Count) + .Where(i => raw[i] != null && inflated[i] != null) + .OrderBy(i => raw[i].Perimeter.BoundingBox.Left) + .ToList(); + + for (var a = 0; a < order.Count; a++) + { + var i = order[a]; + var reach = inflated[i].Perimeter.BoundingBox; + + for (var b = a + 1; b < order.Count; b++) + { + var j = order[b]; + var other = raw[j].Perimeter.BoundingBox; + + // Sorted by Left, so nothing further along can reach part i either. + if (other.Left > reach.Right + Tolerance.Epsilon) + break; + + if (!BoxesTouch(reach, other)) + continue; + + // Inflating one side by the full spacing covers both cases: part j + // inside part i's (shrunk) cutout, or part i's inflated outline + // inside part j's raw cutout. + if ( + Collision.HasOverlap( + inflated[i].Perimeter, + raw[j].Perimeter, + inflated[i].Holes, + raw[j].Holes + ) + ) + { + result.Violations.Add( + $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})" + ); + } + } + } + } + + private static bool BoxesTouch(Box a, Box b) => + a.Left <= b.Right + Tolerance.Epsilon + && b.Left <= a.Right + Tolerance.Epsilon + && a.Bottom <= b.Top + Tolerance.Epsilon + && b.Bottom <= a.Top + Tolerance.Epsilon; + + /// Friendly name for a violation message, falling back to the materialized + /// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all - + /// that mismatch is already reported by ValidateQuantities, so this is display-only. + private static string DisplayName( + Part part, + IReadOnlyDictionary requirements + ) => + requirements.TryGetValue(part.BaseDrawing, out var requirement) + ? requirement.Name + : part.BaseDrawing.Name; + + private const double OutlineTolerance = 0.001; + + private sealed class PartOutline + { + public Polygon Perimeter { get; init; } + public List Holes { get; init; } + } + + /// + /// Extracts a part's material as world-space polygons - the perimeter and + /// its cutouts - grown by (perimeter offset + /// outward, cutouts offset inward, in one Clipper region offset). A cutout + /// that closes up under the offset is dropped, which treats it as solid: + /// conservative, since it has no room for another part at the required + /// spacing anyway. Arcs are flattened conservatively (perimeter arcs + /// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout + /// exactly at the spacing passes; the only leniency is the round-join chord + /// error at convex corners (OutlineTolerance / 10). + /// part.Program is already rotated; only a Location offset is needed. + /// + private static PartOutline Outline(Part part, double inflateBy) + { + var entities = ConvertProgram + .ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + + if (entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + + if (profile.Perimeter == null) + return null; + + // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 + // segments per arc) - arc-heavy real parts otherwise produce thousands + // of vertices, which is needlessly slow for a spacing check. + var region = ClipperBridge.OffsetForValidation( + profile, + inflateBy > Tolerance.Epsilon ? inflateBy : 0, + OutlineTolerance + ); + + var perimeter = region.LargestOuter(); + + if (perimeter == null) + return null; + + ToWorld(perimeter, part.Location); + + foreach (var hole in region.Holes) + ToWorld(hole, part.Location); + + return new PartOutline { Perimeter = perimeter, Holes = region.Holes }; + } + + private static void ToWorld(Polygon polygon, Vector location) + { + polygon.Offset(location); + polygon.UpdateBounds(); + } + } +} diff --git a/OpenNest.Tests/Benchmark/NestLayoutCheckEquivalenceTests.cs b/OpenNest.Tests/Benchmark/NestLayoutCheckEquivalenceTests.cs new file mode 100644 index 0000000..c0bad3d --- /dev/null +++ b/OpenNest.Tests/Benchmark/NestLayoutCheckEquivalenceTests.cs @@ -0,0 +1,85 @@ +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Benchmark; + +public class NestLayoutCheckEquivalenceTests +{ + [Theory] + [InlineData("StockLadder")] + [InlineData("Default")] + [InlineData("Strip")] + [InlineData("Vertical Remnant")] + [InlineData("Horizontal Remnant")] + public void ExistingBenchmarkDxfFixtureMatchesFrozenValidator(string engine) + { + var directory = Path.Combine(AppContext.BaseDirectory, "validator-fixtures"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "bracket.manifest.json"); + var dxf = Path.GetFullPath(Path.Combine("Bending", "TestData", "4526 A14 PT11.dxf")); + File.WriteAllText(path, System.Text.Json.JsonSerializer.Serialize(new + { + sheetSizes = new[] { "48x96" }, + parts = new[] { new { dxf, quantity = 2 } }, + })); + var job = Assert.Single(JobLoader.Load(path)).BuildNestJob(10); + AssertEquivalent(job, NestingEngineRegistry.Create(engine).Solve(job)); + // The fixture can be too large for its offered sheet. Also force real geometry + // through the arbiter so equivalence cannot pass solely on empty solver results. + var id = job.Parts[0].Id; + var sheet = new NestJobPlateResult(0, job.Plates[0], new[] + { + new NestJobPlacement(id, 0, 0, 0, 0), + new NestJobPlacement(id, 1, 1, 1, 0.3), + }); + var forced = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { sheet }, Array.Empty(), Array.Empty()); + Assert.NotEmpty(NestLayoutCheck.Violations(job, forced)); + AssertEquivalent(job, forced); + } + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(1, 0, 0)] + [InlineData(9, 0, 0)] + [InlineData(2, 0.25, 0)] + [InlineData(2, 0, 0.3)] + public void BenchmarkSyntheticRectangleCasesMatchIncludingViolationOrder(double x, double spacing, double angle) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(2, 0); + program.LineTo(2, 2); + program.LineTo(0, 2); + program.LineTo(0, 0); + var part = new NestJobPart("rectangle", PartGeometrySnapshot.FromProgram(program), 1, + rotation: RotationPolicy.Fixed(0)); + var stock = new NestPlateStock("sheet", new Size(10, 10), quantity: 1, partSpacing: spacing); + var job = new NestJob(new[] { part }, new[] { stock }); + var sheets = new[] + { + new NestJobPlateResult(0, stock, new[] { new NestJobPlacement(part.Id, 0, 0, 0, 0), + new NestJobPlacement(part.Id, 1, x, 0, angle) }), + new NestJobPlateResult(1, stock, new[] { new NestJobPlacement(part.Id, 2, 0, 0, 0) }), + }; + AssertEquivalent(job, new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + sheets, Array.Empty(), Array.Empty())); + } + + private static void AssertEquivalent(NestJob job, NestJobResult result) + { + var materialized = NestResultMaterializer.Materialize(job, result); + var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)); + var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(); + var names = job.Parts.ToDictionary(p => p.Id, p => p.Id); + var legacy = LegacyNestValidator.Validate(runs, requirements); + LegacyNestValidator.ValidateAgainstJob(job, result, names, legacy); + var wrapper = NestValidator.Validate(runs, requirements); + NestValidator.ValidateAgainstJob(job, result, names, wrapper); + Assert.Equal(legacy.Violations, wrapper.Violations); + Assert.Equal(legacy.Violations, NestLayoutCheck.Violations(job, result)); + } +}