Files
OpenNest/OpenNest.Benchmark/NestValidator.cs
T
aj 20da5477b6 Fix NestValidator: add area-budget backstop, cheaper polygon conversion
Collision.HasOverlap (and Part.Intersects, which shares the same
underlying algorithm) was observed to return false negatives on real,
complex production geometry: a layout with a combined placed area over
7x the plate's work area passed the polygon-based spacing/overlap
check with zero flagged pairs. This is a pre-existing gap in
OpenNest.Core's Collision detection, not something introduced here,
but it let an obviously-invalid layout score as "valid".

ValidateAreaBudget adds a hard mathematical backstop that does not
depend on Collision at all: non-overlapping parts confined to the work
area can never have a combined area greater than the work area itself.

Also switch WorldPolygon from Shape.ToPolygon() (default up to 1000
segments per arc) to ToPolygonWithTolerance(0.01), matching the
convention already used elsewhere in the codebase (e.g.
BestFit.PolygonHelper) - arc-heavy real parts were producing
thousands-of-vertex polygons for a simple spacing check.
2026-09-15 20:54:12 -04:00

171 lines
6.7 KiB
C#

using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Benchmark
{
public class ValidationResult
{
public bool Valid => Violations.Count == 0;
public List<string> Violations { get; } = new();
}
/// <summary>
/// Validates a placed layout against the benchmark rules: every part must lie
/// within the plate's work area, every pair of parts must be at least
/// PartSpacing apart, and no drawing may have more parts placed than requested.
/// 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.
/// </summary>
public static class NestValidator
{
public static ValidationResult Validate(List<Part> parts, Plate plate, BenchmarkJob job)
{
var result = new ValidationResult();
if (parts == null || parts.Count == 0)
return result;
ValidateQuantities(parts, job, result);
ValidateBounds(parts, plate, result);
ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, result);
return result;
}
/// <summary>
/// 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.
/// </summary>
private static void ValidateAreaBudget(List<Part> 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}) exceeds the work area ({budget:F2}) - " +
"parts must overlap even though the polygon overlap check did not flag a pair");
}
}
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
{
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
var placedCounts = parts
.GroupBy(p => p.BaseDrawing.Id)
.ToDictionary(g => g.Key, g => g.Count());
foreach (var (drawingId, placed) in placedCounts)
{
if (!allowed.TryGetValue(drawingId, out var max))
{
result.Violations.Add($"Placed drawing id={drawingId} which was not requested for this job");
continue;
}
if (placed > max)
{
var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name;
result.Violations.Add($"'{name}': placed {placed} but only {max} were requested");
}
}
}
private static void ValidateBounds(List<Part> parts, Plate plate, 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(
$"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area");
}
}
}
private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result)
{
var worldPolygons = new Polygon[parts.Count];
var inflatedPolygons = new Polygon[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
worldPolygons[i] = WorldPolygon(parts[i], 0);
inflatedPolygons[i] = spacing > Tolerance.Epsilon ? WorldPolygon(parts[i], spacing) : worldPolygons[i];
}
for (var i = 0; i < parts.Count; i++)
{
if (worldPolygons[i] == null || inflatedPolygons[i] == null)
continue;
for (var j = i + 1; j < parts.Count; j++)
{
if (worldPolygons[j] == null)
continue;
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
{
result.Violations.Add(
$"'{parts[i].BaseDrawing.Name}' and '{parts[j].BaseDrawing.Name}' are closer than the required spacing ({spacing:F3})");
}
}
}
}
/// <summary>
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
/// outward by the given spacing, mirroring Part.Intersects' own geometry
/// extraction (part.Program is already rotated; only a Location offset is needed).
/// </summary>
private static Polygon WorldPolygon(Part part, double inflateBy)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var perimeter = new ShapeProfile(entities).Perimeter;
if (perimeter == null)
return null;
if (inflateBy > Tolerance.Epsilon)
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
// 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 = perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon == null)
return null;
polygon.Offset(part.Location);
return polygon;
}
}
}