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));
+ }
+}