diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index ec4a4c7..8a19366 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -362,6 +362,9 @@ namespace OpenNest.Engine.Fill { var perpAxis = PerpendicularAxis(direction); + // Set only when Step 1's check executed, passed, and no fallback replaced the row. + var rowIsVerified = false; + // Step 1: Tile along primary axis var row = new List(pattern.Parts); row.AddRange(TilePattern(pattern, direction, cache)); @@ -372,6 +375,10 @@ namespace OpenNest.Engine.Fill row = new List(pattern.Parts); row.AddRange(TilePatternBbox(pattern, direction)); } + else if (pattern.Parts.Count > 1) + { + rowIsVerified = true; + } // If primary tiling didn't produce copies, just tile along perpendicular if (row.Count <= pattern.Parts.Count) @@ -396,7 +403,11 @@ namespace OpenNest.Engine.Fill var gridResult = new List(rowPattern.Parts); gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache)); - if (HasOverlappingParts(gridResult, out var a3, out var b3)) + // Only the unchanged row is covered by Step 1's clean verdict: skip Step 2 + // only when the perpendicular tiling appended zero parts, so gridResult + // still contains the same Part objects, in the same order and poses. + if ((!rowIsVerified || gridResult.Count != row.Count) + && HasOverlappingParts(gridResult, out var a3, out var b3)) { LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3); gridResult = new List(rowPattern.Parts); diff --git a/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs b/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs index b33deb4..25d676d 100644 --- a/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs +++ b/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs @@ -165,6 +165,112 @@ public class FillLinearValidationReuseTests output.WriteLine($"{direction}: rejected={rejected:R} accepted={accepted:R}"); } +#if DEBUG + // Work-count assertions rely on Debug-only PerfCounters increments; the Release + // properties would read zero, so the whole methods compile only in Debug. + [Theory] + [InlineData("horizontal-stripe", 8, 4, 2, 8)] + [InlineData("vertical-stripe", 19, 18, 2, 19)] + public void UnchangedRow_ReducesStep2OverlapWork(string mode, int count, int exact, int preparations, int triangulations) + { + // One production Fill call between counter resets; the check itself is + // independent validity, taken after the counters are captured. + var pattern = MakePattern("concave", "rotated"); + var direction = mode == "vertical-stripe" ? NestDirection.Vertical : NestDirection.Horizontal; + var area = mode == "vertical-stripe" + ? new Box(0, 0, pattern.BoundingBox.Length, 96) + : new Box(0, 0, 96, pattern.BoundingBox.Width); + long observedExact = -1, observedPreparations = -1, observedTriangulations = -1; + List parts; + PerfCounters.Reset(); + try + { + parts = new FillLinear(area, 0.5).Fill(pattern, direction); + observedExact = PerfCounters.PartIntersects; + observedPreparations = PerfCounters.OverlapPolygonPreparations; + observedTriangulations = PerfCounters.PolygonTriangulations; + // Validity checks run after capturing the counters but inside the measured + // window's try, so the outer finally leaves zero residual counters. + Assert.Equal(count, parts.Count); + AssertValid(parts, area); + Assert.Equal(exact, observedExact); + Assert.Equal(preparations, observedPreparations); + Assert.Equal(triangulations, observedTriangulations); + output.WriteLine($"{mode}: exact={observedExact}; preparations={observedPreparations}; triangulations={observedTriangulations}"); + } + finally + { + PerfCounters.Reset(); + } + } + + [Theory] + [InlineData("single-seed-stripe", 7, 1, 8)] + [InlineData("full-grid", 36, 4, 44)] + [InlineData("partial-only", 36, 4, 38)] + public void GuardedControls_KeepEagerStep2Work(string mode, int exact, int preparations, int triangulations) + { + // Single seed (Step 1 skipped), nonzero perpendicular additions, and partial-only + // additions must all keep the eager Step 2 check: identical work counts to base. + var pattern = MakePattern("concave", mode == "single-seed-stripe" ? "single" : "rotated"); + var vertical = mode == "partial-only"; + var direction = vertical ? NestDirection.Vertical : NestDirection.Horizontal; + var area = mode switch + { + "full-grid" => new Box(0, 0, 96, 48), + "partial-only" => new Box(0, 0, 1.8 * pattern.BoundingBox.Length, 96), + _ => new Box(0, 0, 96, pattern.BoundingBox.Width), + }; + long observedExact = -1, observedPreparations = -1, observedTriangulations = -1; + List parts; + PerfCounters.Reset(); + try + { + parts = new FillLinear(area, 0.5).Fill(pattern, direction); + observedExact = PerfCounters.PartIntersects; + observedPreparations = PerfCounters.OverlapPolygonPreparations; + observedTriangulations = PerfCounters.PolygonTriangulations; + Assert.Equal(exact, observedExact); + Assert.Equal(preparations, observedPreparations); + Assert.Equal(triangulations, observedTriangulations); + output.WriteLine($"{mode}: exact={observedExact}; preparations={observedPreparations}; triangulations={observedTriangulations}"); + } + finally + { + PerfCounters.Reset(); + } + } + + [Theory] + [InlineData(NestDirection.Horizontal, 9)] + [InlineData(NestDirection.Vertical, 3)] + public void PerpOnly_KeepsPerpOnlyCheckWork(NestDirection direction, int exact) + { + // The untouched Step1-PerpOnly path keeps checking; PartIntersects counts every + // exact checker call the path makes (measured on the clean dbcf7de base). + var pattern = MakePattern("concave", "rotated"); + var area = direction == NestDirection.Horizontal + ? new Box(0, 0, pattern.BoundingBox.Length, 48) + : new Box(0, 0, 48, pattern.BoundingBox.Width); + long observed = -1; + List parts; + PerfCounters.Reset(); + try + { + parts = new FillLinear(area, 0.5).Fill(pattern, direction); + observed = PerfCounters.PartIntersects; + Assert.Equal(exact, observed); + Assert.NotEmpty(parts); + AssertValid(parts, area); + output.WriteLine($"PerpOnly {direction}: exact={observed}"); + } + finally + { + PerfCounters.Reset(); + } + } +#endif + [Fact] public void InvalidOverlappingSeeds_PreserveBothFallbacks_EvenWithoutPerpendicularAdditions() { @@ -184,14 +290,27 @@ public class FillLinearValidationReuseTests Trace.Listeners.Add(listener); try { -#endif var expected = frozen.Fill(pattern, NestDirection.Horizontal); - var actual = filler.Fill(pattern, NestDirection.Horizontal); - Assert.Equal(6, expected.Count); - AssertLayout(expected, actual, pattern.Parts.Select(p => p.Program).ToArray()); - Assert.True(FillHelpers.HasOverlappingParts(actual)); - Assert.Equal(before, PatternSnapshot(pattern)); -#if DEBUG + long exactWithGuard; + PerfCounters.Reset(); + try + { + var actual = filler.Fill(pattern, NestDirection.Horizontal); + exactWithGuard = PerfCounters.PartIntersects; + Assert.Equal(6, expected.Count); + AssertLayout(expected, actual, pattern.Parts.Select(p => p.Program).ToArray()); + Assert.True(FillHelpers.HasOverlappingParts(actual)); + Assert.Equal(before, PatternSnapshot(pattern)); + // The Step 1 fallback clears rowIsVerified, so the Step 2 check must + // still execute despite zero perpendicular additions: its exact + // short-circuit call is retained. Layout equality alone would miss a + // wrongly skipped check whose fallback happens to reproduce the same row. + Assert.Equal(2, exactWithGuard); + } + finally + { + PerfCounters.Reset(); + } foreach (var label in new[] { "invalid-frozen", "invalid-production" }) { var records = listener.Records.Where(r => r.Label == label).ToArray(); @@ -208,6 +327,15 @@ public class FillLinearValidationReuseTests { Trace.Listeners.Remove(listener); } +#else + { + var expected = frozen.Fill(pattern, NestDirection.Horizontal); + var actual = filler.Fill(pattern, NestDirection.Horizontal); + Assert.Equal(6, expected.Count); + AssertLayout(expected, actual, pattern.Parts.Select(p => p.Program).ToArray()); + Assert.True(FillHelpers.HasOverlappingParts(actual)); + Assert.Equal(before, PatternSnapshot(pattern)); + } #endif } diff --git a/docs/performance/fill-verification.md b/docs/performance/fill-verification.md index 680a8d3..c9dfa14 100644 --- a/docs/performance/fill-verification.md +++ b/docs/performance/fill-verification.md @@ -28,7 +28,7 @@ OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Rel `PreStep3FillLinear.cs` freezes production `FillLinear` at **1b23ad79f25d77fdd745bfea029de9dff2a91f6d**, separately from the older `LegacyFillLinear`. Reverse only the documented type/constructor rename, namespace/visibility/import additions, nullable directive and import formatting; retain a zero-diff comparison against `git show :OpenNest.Engine/Fill/FillLinear.cs` and SHA-256 hashes. Do not refactor the oracle or use it for before timings. `FillLinearValidationReuseTests` compares ordered IEEE-754 poses/bounds, CNC values/program sharing, drawing references and immutable inputs through both public `Fill` overloads, including concurrent calls on one filler. Valid fixtures assert closed positive-area material and no overlap. The separate invalid overlapping-seed case intentionally remains invalid; in Debug its listener confirms Step1 and Step2 fallback pair `(0,1)` with six parts at each step (zero perpendicular additions). It makes no claim that bbox fallback repairs the seeds. -Keep future validation-work counter assertions Debug-only in `FillCacheCollection`, reset counters in `finally`, and retain the invalid fallback and single-seed/partial-only controls. This baseline does not assert a future skipped check or introduce production counters. Whole-job exact preservation needs a separately repeated serial oracle (fresh process, `DOTNET_PROCESSOR_COUNT=1`, verified one-worker thread-pool cap, solve on that worker); ordinary parallel layouts can differ on the same tree, and the serial gate does not replace concurrent differential tests. +Keep validation-work counter assertions Debug-only in `FillCacheCollection`, reset counters in `finally`, and retain the invalid fallback and single-seed/partial-only controls. Since the S1 guard shipped (re-delivered 2026-09-30 on `dbcf7de`), `UnchangedRow_ReducesStep2OverlapWork` asserts the reduced Step 2 work (horizontal stripe 8→4 exact / 4→2 preparations / 16→8 triangulations; vertical stripe 36→18 / 4→2 / 38→19) and `GuardedControls_KeepEagerStep2Work` plus `PerpOnly_KeepsPerpOnlyCheckWork` assert single-seed (7/1/8), Horizontal full-grid (36/4/44, matching the 2026-09-27 table), partial-only (36/4/38) and PerpOnly exact-call (H=9/V=3) work stay at base on the current tree. The work-count methods compile entirely within DEBUG — Release counter properties read zero, and empty passing shells would misreport coverage. The invalid fallback control additionally pins production's exact-call total at 2 so a wrongly skipped Step 2 check cannot hide behind a layout match. Whole-job exact preservation needs a separately repeated serial oracle (fresh process, `DOTNET_PROCESSOR_COUNT=1`, verified one-worker thread-pool cap, solve on that worker); ordinary parallel layouts can differ on the same tree, and the serial gate does not replace concurrent differential tests. Debug behavior/skipped-work checks: