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 <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,381 +1,30 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Math;
|
|
||||||
|
|
||||||
namespace OpenNest.Benchmark
|
namespace OpenNest.Benchmark;
|
||||||
|
|
||||||
|
/// <summary>Benchmark validation outcome.</summary>
|
||||||
|
public class ValidationResult
|
||||||
{
|
{
|
||||||
public class ValidationResult
|
|
||||||
{
|
|
||||||
public bool Valid => Violations.Count == 0;
|
public bool Valid => Violations.Count == 0;
|
||||||
public List<string> Violations { get; } = new();
|
public List<string> Violations { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
|
||||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
public static class NestValidator
|
||||||
/// 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
|
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class NestValidator
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static ValidationResult Validate(
|
public static ValidationResult Validate(
|
||||||
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
var result = new ValidationResult();
|
||||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
|
||||||
|
|
||||||
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
|
||||||
/// Checks what the materialized layout cannot show: every sheet must be
|
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
|
||||||
/// one of the job's own stock entries (an engine may not invent a sheet
|
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
|
||||||
/// size or loosen its spacing/edge settings, which the layout checks
|
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
|
||||||
/// would otherwise trust), finite stock may not be overdrawn, and every
|
|
||||||
/// placement's rotation must satisfy its part's RotationPolicy.
|
|
||||||
/// </summary>
|
|
||||||
public static void ValidateAgainstJob(
|
|
||||||
NestJob job,
|
|
||||||
NestJobResult jobResult,
|
|
||||||
IReadOnlyDictionary<string, string> displayNames,
|
|
||||||
ValidationResult result
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var stockById = job.Plates.ToDictionary(s => s.Id);
|
|
||||||
var partsById = job.Parts.ToDictionary(p => p.Id);
|
|
||||||
var sheetsUsed = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
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<Part> parts,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
|
||||||
ValidationResult result
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var placedCounts = parts
|
|
||||||
.GroupBy<Part, Drawing>(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<Part> parts,
|
|
||||||
Plate plate,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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}) 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Every pair of parts must be at least <paramref name="spacing"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void ValidateSpacing(
|
|
||||||
List<Part> parts,
|
|
||||||
double spacing,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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;
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
private static string DisplayName(
|
|
||||||
Part part,
|
|
||||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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<Polygon> Holes { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts a part's material as world-space polygons - the perimeter and
|
|
||||||
/// its cutouts - grown by <paramref name="inflateBy"/> (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.
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ArgumentOutOfRangeException>(() => NestTolerances.SafeClearanceMargin(-1));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => NestTolerances.SafeClearanceMargin(double.NaN));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ internal static class NestJobPlacementValidator
|
|||||||
// Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's
|
// 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
|
// 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.
|
// 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(
|
internal static void ValidateCandidate(
|
||||||
PlateCandidate candidate,
|
PlateCandidate candidate,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class NestLayoutCheck
|
||||||
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public static IReadOnlyList<string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<string> Validate(
|
||||||
|
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ValidateAgainstJob(
|
||||||
|
NestJob job,
|
||||||
|
NestJobResult jobResult,
|
||||||
|
IReadOnlyDictionary<string, string> displayNames,
|
||||||
|
List<string> result
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var stockById = job.Plates.ToDictionary(s => s.Id);
|
||||||
|
var partsById = job.Parts.ToDictionary(p => p.Id);
|
||||||
|
var sheetsUsed = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
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<Part> parts,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
||||||
|
List<string> result
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var placedCounts = parts
|
||||||
|
.GroupBy<Part, Drawing>(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<Part> parts,
|
||||||
|
Plate plate,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
||||||
|
List<string> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
List<string> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every pair of parts must be at least <paramref name="spacing"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateSpacing(
|
||||||
|
List<Part> parts,
|
||||||
|
double spacing,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
||||||
|
List<string> 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;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private static string DisplayName(
|
||||||
|
Part part,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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<Polygon> Holes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a part's material as world-space polygons - the perimeter and
|
||||||
|
/// its cutouts - grown by <paramref name="inflateBy"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
/// <summary>Shared numerical contract for layout validation.</summary>
|
||||||
|
public static class NestTolerances
|
||||||
|
{
|
||||||
|
/// <summary>Arc chord tolerance used by both validators. The layout check circumscribes
|
||||||
|
/// perimeter arcs and inscribes cutouts; the placement validator uses inscribed arcs.</summary>
|
||||||
|
public const double ValidationOutline = 0.001;
|
||||||
|
|
||||||
|
/// <summary>Clipper decimal precision (a 1e-4 coordinate grid).</summary>
|
||||||
|
public const int ClipperPrecision = ClipperBridge.Precision;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Tolerance is negative or non-finite.</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Benchmark" />
|
||||||
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
|
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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<string> Violations { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class LegacyNestValidator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static LegacyValidationResult Validate(
|
||||||
|
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static void ValidateAgainstJob(
|
||||||
|
NestJob job,
|
||||||
|
NestJobResult jobResult,
|
||||||
|
IReadOnlyDictionary<string, string> displayNames,
|
||||||
|
LegacyValidationResult result
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var stockById = job.Plates.ToDictionary(s => s.Id);
|
||||||
|
var partsById = job.Parts.ToDictionary(p => p.Id);
|
||||||
|
var sheetsUsed = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
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<Part> parts,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
||||||
|
LegacyValidationResult result
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var placedCounts = parts
|
||||||
|
.GroupBy<Part, Drawing>(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<Part> parts,
|
||||||
|
Plate plate,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every pair of parts must be at least <paramref name="spacing"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateSpacing(
|
||||||
|
List<Part> parts,
|
||||||
|
double spacing,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
private static string DisplayName(
|
||||||
|
Part part,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> 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<Polygon> Holes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a part's material as world-space polygons - the perimeter and
|
||||||
|
/// its cutouts - grown by <paramref name="inflateBy"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
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<PartFulfillment>(), Array.Empty<StockUsage>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user