using System.Collections.Generic; using System.Linq; using OpenNest.Converters; using OpenNest.Engine.Jobs; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Benchmark { public class ValidationResult { 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 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 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). 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. /// 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 => e.Layer != SpecialLayers.Rapid) .ToList(); if (entities.Count == 0) return null; var profile = new ShapeProfile(entities); if (profile.Perimeter == null) return null; var perimeter = profile.Perimeter; if (inflateBy > Tolerance.Epsilon) perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter; var polygon = ToWorldPolygon(perimeter, part.Location); if (polygon == null) return null; var holes = new List(); foreach (var cutout in profile.Cutouts) { var hole = cutout; if (inflateBy > Tolerance.Epsilon) { hole = cutout.OffsetInward(inflateBy); // An offset that collapsed or flipped inside-out leaves no usable room. if ( hole == null || hole.Area() <= Tolerance.Epsilon || hole.Area() >= cutout.Area() ) continue; } var holePolygon = ToWorldPolygon(hole, part.Location); if (holePolygon != null) holes.Add(holePolygon); } return new PartOutline { Perimeter = polygon, Holes = holes }; } private static Polygon ToWorldPolygon(Shape shape, Vector location) { // 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 polygon = shape.ToPolygonWithTolerance(0.01, circumscribe: true); if (polygon == null) return null; polygon.Offset(location); polygon.UpdateBounds(); return polygon; } } }