diff --git a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs index 4aedddf..1a5c82c 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs @@ -59,13 +59,18 @@ public class NestJobCostTests } [Theory] - [InlineData(0)] - [InlineData(0.5)] - public void RotatedMarksAndMultipleSheetsKeepExactLegacyCost(double rate) + [InlineData(0, false)] + [InlineData(0.5, false)] + [InlineData(0, true)] + [InlineData(0.5, true)] + public void RotatedPartsAndMultipleSheetsUseMaterialCost(double rate, bool marked) { var program = TestDrawingFactory.Rectangle(4, 3); - program.MoveTo(2, 2); - program.Codes.Add(new LinearMove(9, 2) { Layer = LayerType.Scribe }); + if (marked) + { + program.MoveTo(2, 2); + program.Codes.Add(new LinearMove(9, 2) { Layer = LayerType.Scribe }); + } var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 5); var stock = new NestPlateStock("sheet", new Size(30, 40)); var other = new NestPlateStock("large", new Size(50, 50)); @@ -75,15 +80,18 @@ public class NestJobCostTests builder.AddSheet(stock, new[] { ("part", 10.123, 8.456, 0.37), ("part", 25.789, 17.321, 1.12) }); builder.AddSheet(other, new[] { ("part", 12.345, 19.876, 2.13) }); var result = builder.Build(NestJobStopReason.NoPlacementFound); + var cleanPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram( + TestDrawingFactory.Rectangle(4, 3)), 5); + var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options); foreach (var sheet in result.Plates) - Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet)); Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); - Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(job, sheet)) + 5000, + Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000, NestJobCost.Evaluate(job, result)); } [Fact] - public void ScoringKeepsEtchBoundsEvenThoughMaterialGeometryExcludesThem() + public void EtchBeyondMaterialDoesNotShrinkSalvageOffcut() { var program = TestDrawingFactory.Rectangle(4, 3); program.MoveTo(2, 2); @@ -95,7 +103,7 @@ public class NestJobCostTests var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }); Assert.Equal(130, LegacyNestJobCost.EstimateNetArea(job, sheet)); - Assert.Equal(130, NestJobCost.NetSheetArea(job, sheet)); + Assert.Equal(120, NestJobCost.NetSheetArea(job, sheet)); Assert.Equal(120, NestJobCost.NetSheetArea(job.Options, stock, JobPartGeometry.Read(part.Geometry).Bounds)); } diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs index 4d883d2..c18fa33 100644 --- a/OpenNest.Engine/Jobs/NestJobCost.cs +++ b/OpenNest.Engine/Jobs/NestJobCost.cs @@ -10,7 +10,7 @@ public static class NestJobCost /// /// Sheet area less SalvageRate times the largest usable full-width/full-length edge offcut. /// Both offcut dimensions must meet MinimumSalvageDimension, which must be positive. - /// Bounds intentionally use Part.BoundingBox (including marks) to preserve benchmark scores. + /// Bounds use rotated material contours only; scribe/etch marks never shrink salvage offcuts. /// public static double NetSheetArea(NestJob job, NestJobPlateResult sheet) { @@ -26,7 +26,7 @@ public static class NestJobCost part.Rotate(p.Rotation); part.Location = new OpenNest.Geometry.Vector(p.X, p.Y); part.UpdateBounds(); - return part.BoundingBox; + return NestLayoutCheck.MaterialBounds(part); }) .ToList(); return NetSheetArea(job.Options, sheet.Stock, boxes.Min(b => b.Left), @@ -35,7 +35,7 @@ public static class NestJobCost /// /// Computes net area from an existing placed-parts envelope without rebuilding geometry. - /// For benchmark parity the envelope must include the same marks as Part.BoundingBox. + /// The envelope must contain material only; exclude scribe/etch marks. /// Empty sheets should use the sheet overload, which returns their full area. /// public static double NetSheetArea(NestJobOptions options, NestPlateStock stock, Box partsEnvelope) => diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index 2d1e271..c3e2ddd 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -225,7 +225,7 @@ public static class NestLayoutCheck foreach (var part in parts) { - var bb = part.BoundingBox; + var bb = MaterialBounds(part); var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; @@ -339,6 +339,13 @@ public static class NestLayoutCheck } } + /// Analytic world-space material bounds; surface marks never bound material. + internal static Box MaterialBounds(Part part) => + ConvertProgram.ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .GetBoundingBox() + .Translate(part.Location); + private static bool BoxesTouch(Box a, Box b) => a.Left <= b.Right + Tolerance.Epsilon && b.Left <= a.Right + Tolerance.Epsilon diff --git a/OpenNest.Engine/Jobs/NestTolerances.cs b/OpenNest.Engine/Jobs/NestTolerances.cs index 932c0e3..a40de18 100644 --- a/OpenNest.Engine/Jobs/NestTolerances.cs +++ b/OpenNest.Engine/Jobs/NestTolerances.cs @@ -3,7 +3,9 @@ using OpenNest.Geometry; namespace OpenNest.Engine.Jobs; -/// Shared numerical contract for layout validation. +/// Shared numerical contract for layout validation. +/// Only material contours (SpecialLayers.IsMaterial) count for bounds, clearance and scoring. +/// Scribe/etch marks never affect these checks or salvage envelopes, even outside the sheet. public static class NestTolerances { /// Arc chord tolerance used by both validators. The layout check circumscribes diff --git a/OpenNest.Tests/Benchmark/NestLayoutCheckMarksTests.cs b/OpenNest.Tests/Benchmark/NestLayoutCheckMarksTests.cs new file mode 100644 index 0000000..44fe9d6 --- /dev/null +++ b/OpenNest.Tests/Benchmark/NestLayoutCheckMarksTests.cs @@ -0,0 +1,48 @@ +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Benchmark; + +public class NestLayoutCheckMarksTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void EtchTickPastFlushMaterialEdgePassesBoundsAndLeavesSalvageUnchanged(int quadrant) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(4, 0); + program.LineTo(4, 3); + program.LineTo(0, 3); + program.LineTo(0, 0); + var clean = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1); + program.MoveTo(3.75, 2); + program.Codes.Add(new LinearMove(4.25, 2) { Layer = LayerType.Scribe }); + var marked = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1); + var stock = new NestPlateStock("sheet", new Size(10, 20), quadrant: quadrant); + var job = new NestJob(new[] { marked }, new[] { stock }, + new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(stock, new[] { (marked.Id, stock.WorkArea.Right - 4, stock.WorkArea.Bottom, 0.0) }); + var result = builder.Build(NestJobStopReason.Completed); + Assert.Empty(NestLayoutCheck.Violations(job, result)); + var cleanJob = new NestJob(new[] { clean }, job.Plates, job.Options); + Assert.Equal(NestJobCost.Evaluate(cleanJob, result), NestJobCost.Evaluate(job, result)); + + var materialized = NestResultMaterializer.Materialize(job, result); + var requirements = new Dictionary + { + [materialized.DrawingsByPartId[marked.Id]] = (marked.Id, 1), + }; + var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(); + Assert.Contains(LegacyNestValidator.Validate(runs, requirements).Violations, + message => message.Contains("outside the work area")); + Assert.True(NestValidator.Validate(runs, requirements).Valid); + } +}