From bc5fd86996da3abb98bb3b1da5f81de82692bf5e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 15:10:01 -0400 Subject: [PATCH] test(fill): characterize unchanged-row validation reuse --- .../FillLinearValidationPerformanceTests.cs | 165 ++++++ .../Fill/FillLinearValidationReuseTests.cs | 439 ++++++++++++++ OpenNest.Tests/Fill/PreStep3FillLinear.cs | 540 ++++++++++++++++++ docs/performance/fill-verification.md | 19 + 4 files changed, 1163 insertions(+) create mode 100644 OpenNest.Tests/Fill/FillLinearValidationPerformanceTests.cs create mode 100644 OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs create mode 100644 OpenNest.Tests/Fill/PreStep3FillLinear.cs diff --git a/OpenNest.Tests/Fill/FillLinearValidationPerformanceTests.cs b/OpenNest.Tests/Fill/FillLinearValidationPerformanceTests.cs new file mode 100644 index 0000000..503d423 --- /dev/null +++ b/OpenNest.Tests/Fill/FillLinearValidationPerformanceTests.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; +using OpenNest.Tests.BestFit; +using Xunit.Abstractions; + +namespace OpenNest.Tests.Fill; + +[Collection(nameof(FillCacheCollection))] +[Trait("Category", "FillPerformance")] +public class FillLinearValidationPerformanceTests +{ + private readonly ITestOutputHelper output; + + public FillLinearValidationPerformanceTests(ITestOutputHelper output) => this.output = output; + + [SkippableFact] + public void FillLinearValidation_ReportsStripeAndControls() + { + Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1", + "Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks."); + + // Self-contained, baseline-API-only harness: copy this file byte-for-byte into + // the before tree. Never time a frozen oracle or an overlap-only helper here. + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + foreach (var point in new[] { new Vector(10, 0), new Vector(10, 3), new Vector(4, 3), + new Vector(4, 8), new Vector(0, 8), new Vector(0, 0) }) + program.Codes.Add(new LinearMove(point)); + var drawing = new Drawing("concave", program); + var profile = new ShapeProfile(ConvertProgram.ToGeometry(program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()); + Assert.True(profile.Perimeter.IsClosed()); + Assert.Empty(profile.Cutouts); + Assert.Equal(50.0, drawing.Area); + Assert.True(profile.Perimeter.Area() > 0); + var first = Part.CreateAtOrigin(drawing, 0); + var second = Part.CreateAtOrigin(drawing, System.Math.PI); + second.Offset(new Vector(10.5, 0)); + var pair = FillHelpers.BuildRotatedPattern(new List { first, second }, 0.37); + var single = FillHelpers.BuildRotatedPattern(new List { first }, 0.37); + var patterns = new[] { pair, pair, pair, pair, single }; + var names = new[] { "horizontal-stripe", "vertical-stripe", "full-grid", "partial-only", "single-seed-stripe" }; + var counts = new[] { 8, 19, 36, 29, 8 }; + var directions = new[] { NestDirection.Horizontal, NestDirection.Vertical, NestDirection.Horizontal, + NestDirection.Vertical, NestDirection.Horizontal }; + var areas = new[] + { + new Box(0, 0, 96, pair.BoundingBox.Width), + new Box(0, 0, pair.BoundingBox.Length, 96), + new Box(0, 0, 96, 48), + new Box(0, 0, 1.8 * pair.BoundingBox.Length, 96), + new Box(0, 0, 96, single.BoundingBox.Width), + }; + var fills = areas.Select((area, mode) => + { + var filler = new FillLinear(area, 0.5); + return new Func>(() => filler.Fill(patterns[mode], directions[mode])); + }).ToArray(); + var expected = fills.Select(fill => fill()).ToArray(); + for (var mode = 0; mode < fills.Length; mode++) + { + Assert.Equal(counts[mode], expected[mode].Count); + AssertValid(expected[mode], areas[mode]); + } + var warmupCalls = 100; + var callsPerBatch = 200; + var repetitions = 7; +#if DEBUG + output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements)."); +#else + output.WriteLine("Configuration=Release."); +#endif + output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; " + + $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; " + + $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s."); + output.WriteLine("linear-validation: closed concave (0,0)-(10,0)-(10,3)-(4,3)-(4,8)-(0,8)-(0,0); " + + "pair at 0/PI, second offset=(10.5,0), BuildRotatedPattern=0.37; spacing=0.5; origin=(0,0). " + + "Stripe primary span=96, perpendicular span=exact pattern bbox; full-grid=(96,48); " + + "vertical partial-only perpendicular span=1.8*pair.Length; single rotated seed H stripe. " + + "Counts in mode order=8,19,36,29,8; full-grid row=8+28; partial-only row=19+10."); + output.WriteLine($"warmup=2 x {warmupCalls} calls/mode; measured={repetitions} x {callsPerBatch} calls/mode. " + + "Mode order alternates forward/reverse in warmup and measurement. Real synchronous production Fill only; " + + "setup, correctness assertions and output excluded. Geometry, tiling, overlap checks, GC, delegate/loop, " + + "count accumulation and last-result assignment included identically in every mode. " + + "Allocations=GC.GetAllocatedBytesForCurrentThread, not RSS. Warm JIT/drawing, no forced GC. " + + "Baseline harness only: no reduced-work assertion, timing gate or whole-job speedup claim."); + + for (var batch = 0; batch < 2; batch++) + RunBatch("warmup", batch, warmupCalls); + for (var batch = 0; batch < repetitions; batch++) + RunBatch("measured", batch, callsPerBatch); + + void RunBatch(string phase, int batch, int calls) + { + // Capture all modes first; assertions/output cannot enter any timed window. + var samples = new Sample[fills.Length]; + for (var slot = 0; slot < fills.Length; slot++) + { + var mode = batch % 2 == 0 ? slot : fills.Length - 1 - slot; + samples[mode] = Measure(fills[mode], calls); + } + for (var mode = 0; mode < fills.Length; mode++) + { + var sample = samples[mode]; + Assert.Equal((long)calls * counts[mode], sample.PartCount); + AssertSameLayout(expected[mode], sample.LastResult); + AssertValid(sample.LastResult, areas[mode]); + output.WriteLine(FormattableString.Invariant( + $"linear-validation phase={phase} mode={names[mode]} batch={batch + 1} order={(batch % 2 == 0 ? "forward" : "reverse")} calls={calls}: ticks={sample.ElapsedTicks}; ms={sample.ElapsedTicks * 1000.0 / Stopwatch.Frequency:R}; bytes={sample.AllocatedBytes}; parts={sample.PartCount}; last={sample.LastResult.Count}.")); + } + } + } + + private static Sample Measure(Func> fill, int calls) + { + var count = 0L; + var last = new List(); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var start = Stopwatch.GetTimestamp(); + for (var i = 0; i < calls; i++) + { + last = fill(); + count += last.Count; + } + var elapsed = Stopwatch.GetTimestamp() - start; + var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + return new Sample(elapsed, allocated, count, last); + } + + private static void AssertValid(List parts, Box area) + { + Assert.All(parts, p => + { + Assert.True(p.BaseDrawing.Area > 0); + Assert.True(p.Left >= area.Left - OpenNest.Math.Tolerance.Epsilon + && p.Right <= area.Right + OpenNest.Math.Tolerance.Epsilon + && p.Bottom >= area.Bottom - OpenNest.Math.Tolerance.Epsilon + && p.Top <= area.Top + OpenNest.Math.Tolerance.Epsilon); + }); + Assert.False(FillHelpers.HasOverlappingParts(parts)); + } + + private static void AssertSameLayout(List expected, List actual) + { + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing); + Assert.Same(expected[i].Program, actual[i].Program); + Assert.Equal(PartBits(expected[i]), PartBits(actual[i])); + } + } + + private static long[] PartBits(Part part) => new[] { part.Location.X, part.Location.Y, part.Rotation, + part.BoundingBox.X, part.BoundingBox.Y, part.BoundingBox.Length, part.BoundingBox.Width } + .Select(BitConverter.DoubleToInt64Bits).ToArray(); + + private readonly record struct Sample(long ElapsedTicks, long AllocatedBytes, long PartCount, List LastResult); +} diff --git a/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs b/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs new file mode 100644 index 0000000..b33deb4 --- /dev/null +++ b/OpenNest.Tests/Fill/FillLinearValidationReuseTests.cs @@ -0,0 +1,439 @@ +using System.Diagnostics; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; +using OpenNest.Engine.Tests.Fill; +using OpenNest.Geometry; +using OpenNest.Math; +using OpenNest.Shapes; +using OpenNest.Tests.BestFit; +using Xunit.Abstractions; + +namespace OpenNest.Tests.Fill; + +[Collection(nameof(FillCacheCollection))] +public class FillLinearValidationReuseTests +{ + private readonly ITestOutputHelper output; + + public FillLinearValidationReuseTests(ITestOutputHelper output) => this.output = output; + + public static IEnumerable DrawingCases() + { + foreach (var shape in new[] { "rectangle", "concave", "arc", "circle", "ring" }) + foreach (var spacing in new[] { 0.0, 0.5 }) + foreach (var angle in new[] { 0.0, 0.37, System.Math.PI / 2 }) + foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical }) + yield return new object[] { shape, spacing, angle, direction }; + } + + [Theory] + [MemberData(nameof(DrawingCases))] + public void DrawingFill_MatchesPreStep3_OrderedBitsProgramsAndImmutableInput( + string shape, double spacing, double angle, NestDirection direction) + { + var drawing = MakeDrawing(shape); + var before = DrawingSnapshot(drawing); + var area = new Box(3.1, -5.3, 42, 29); + var areaBefore = BoxBits(area); + var expected = new PreStep3FillLinear(area, spacing).Fill(drawing, angle, direction); + var filler = new FillLinear(area, spacing); + AssertLayout(expected, filler.Fill(drawing, angle, direction), new[] { drawing.Program }); + Assert.Equal(before, DrawingSnapshot(drawing)); + AssertLayout(expected, filler.Fill(drawing, angle, direction), new[] { drawing.Program }); + AssertValid(expected, area); + Assert.Equal(before, DrawingSnapshot(drawing)); + Assert.Equal(areaBefore, BoxBits(area)); + Assert.Equal(areaBefore, BoxBits(filler.WorkArea)); + } + + public static IEnumerable PatternCases() + { + foreach (var shape in new[] { "concave", "arc", "circle", "ring" }) + foreach (var kind in new[] { "single", "shared", "rotated" }) + foreach (var spacing in new[] { 0.0, 0.5 }) + foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical }) + yield return new object[] { shape, kind, spacing, direction }; + } + + [Theory] + [MemberData(nameof(PatternCases))] + public void PatternFill_MatchesPreStep3_SharingAndImmutableInput( + string shape, string kind, double spacing, NestDirection direction) + { + var pattern = MakePattern(shape, kind); + CheckPattern(pattern, new Box(-7.1, 11.3, 52, 39), spacing, direction); + } + + [Theory] + [InlineData("horizontal-stripe", 8)] + [InlineData("vertical-stripe", 19)] + [InlineData("full-grid", 36)] + [InlineData("partial-only", 29)] + [InlineData("single-seed-stripe", 8)] + public void CharacterizedControls_MatchPreStep3(string mode, int count) + { + var pattern = MakePattern("concave", mode == "single-seed-stripe" ? "single" : "rotated"); + var vertical = mode is "vertical-stripe" or "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), + "vertical-stripe" => new Box(0, 0, pattern.BoundingBox.Length, 96), + _ => new Box(0, 0, 96, pattern.BoundingBox.Width), + }; + var parts = CheckPattern(pattern, area, 0.5, direction); + Assert.Equal(count, parts.Count); + var stripe = vertical ? new Box(0, 0, pattern.BoundingBox.Length, 96) + : new Box(0, 0, 96, pattern.BoundingBox.Width); + var row = new PreStep3FillLinear(stripe, 0.5).Fill(pattern, direction); + if (mode == "full-grid") + { + Assert.Equal(8, row.Count); + Assert.Equal(28, parts.Count - row.Count); + } + if (mode == "partial-only") + { + Assert.Equal(19, row.Count); + Assert.Equal(10, parts.Count - row.Count); + // Fewer appended parts than the row: TilePattern's incomplete-copy path, + // not a full row. The ordered prefix must be unchanged. + AssertLayout(row, parts.Take(row.Count).ToList(), pattern.Parts.Select(p => p.Program).ToArray()); + } + output.WriteLine($"{mode}: row={row.Count}; total={parts.Count}; appended={parts.Count - row.Count}"); + } + + [Theory] + [InlineData(NestDirection.Horizontal, 9)] + [InlineData(NestDirection.Vertical, 4)] + public void PerpendicularOnly_MatchesPreStep3(NestDirection direction, int count) + { + 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); + var seedOnlyArea = new Box(0, 0, pattern.BoundingBox.Length, pattern.BoundingBox.Width); + Assert.Equal(pattern.Parts.Count, new PreStep3FillLinear(seedOnlyArea, 0.5).Fill(pattern, direction).Count); + var parts = CheckPattern(pattern, area, 0.5, direction); + Assert.True(parts.Count > pattern.Parts.Count); + Assert.Equal(count, parts.Count); + output.WriteLine($"PerpOnly {direction}: total={parts.Count}"); + } + + [Theory] + [InlineData(NestDirection.Horizontal)] + [InlineData(NestDirection.Vertical)] + public void LastCopy_AdjacentDoubleThreshold_MatchesBothPublicFillOverloads(NestDirection direction) + { + var drawing = MakeDrawing("rectangle"); + var pattern = new Pattern(); + pattern.Parts.Add(Part.CreateAtOrigin(drawing)); + pattern.UpdateBounds(); + var before = PatternSnapshot(pattern); + var dim = direction == NestDirection.Horizontal ? 10.0 : 8.0; + Box Area(double size) => new(3.1, 5.3, direction == NestDirection.Horizontal ? size : 10, + direction == NestDirection.Vertical ? size : 8); + var rejected = 2 * dim + 0.5; + var accepted = 4 * dim + 1.5; + // Find the actual adjacent-double boundary through the frozen public Fill; + // nominal pitch arithmetic is not an oracle for the accumulated FP endpoint. + for (var i = 0; i < 64; i++) + { + var middle = (rejected + accepted) / 2; + if (new PreStep3FillLinear(Area(middle), 0.5).Fill(pattern, direction).Count < 3) + rejected = middle; + else + accepted = middle; + } + Assert.Equal(Bits(System.Math.BitIncrement(rejected)), Bits(accepted)); + foreach (var size in new[] { rejected, accepted }) + { + var area = Area(size); + var frozen = new PreStep3FillLinear(area, 0.5); + var filler = new FillLinear(area, 0.5); + var expectedPattern = frozen.Fill(pattern, direction); + var expectedDrawing = frozen.Fill(drawing, 0, direction); + Assert.Equal(size == rejected ? 2 : 3, expectedPattern.Count); + Assert.Equal(expectedPattern.Count, expectedDrawing.Count); + AssertLayout(expectedPattern, filler.Fill(pattern, direction), new[] { pattern.Parts[0].Program }); + AssertLayout(expectedDrawing, filler.Fill(drawing, 0, direction), new[] { drawing.Program }); + } + Assert.Equal(before, PatternSnapshot(pattern)); + output.WriteLine($"{direction}: rejected={rejected:R} accepted={accepted:R}"); + } + + [Fact] + public void InvalidOverlappingSeeds_PreserveBothFallbacks_EvenWithoutPerpendicularAdditions() + { + // Deliberately invalid: bbox fallback does not repair an overlapping seed. + // Keep separate from assertions that valid fixtures never overlap. + var first = Part.CreateAtOrigin(MakeDrawing("rectangle")); + var pattern = new Pattern(); + pattern.Parts.AddRange(new[] { first, first.CloneAtOffset(new Vector(0.25, 0.25)) }); + pattern.UpdateBounds(); + Assert.True(FillHelpers.HasOverlappingParts(pattern.Parts)); + var before = PatternSnapshot(pattern); + var area = new Box(0, 0, 35, 8.25); + var frozen = new PreStep3FillLinear(area, 0.5) { Label = "invalid-frozen" }; + var filler = new FillLinear(area, 0.5) { Label = "invalid-production" }; +#if DEBUG + using var listener = new FallbackListener(); + 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 + foreach (var label in new[] { "invalid-frozen", "invalid-production" }) + { + var records = listener.Records.Where(r => r.Label == label).ToArray(); + Assert.Equal(new[] { "Step1-Primary", "Step2-Perp" }, records.Select(r => r.Step)); + Assert.All(records, r => + { + Assert.Equal(6, r.Count); + Assert.Equal("Overlapping pair [0] vs [1]:", r.Pair); + }); + output.WriteLine($"{label}: Step1-Primary and Step2-Perp, both total=6 pair=(0,1); zero perpendicular additions"); + } + } + finally + { + Trace.Listeners.Remove(listener); + } +#endif + } + + [Theory] + [InlineData(NestDirection.Horizontal)] + [InlineData(NestDirection.Vertical)] + public void EmptyNoFitAndMalformedInputs_PreserveResultsAndExceptionTypes(NestDirection direction) + { + var area = new Box(3, 5, 1, 1); + var filler = new FillLinear(area, 0.5); + var frozen = new PreStep3FillLinear(area, 0.5); + var drawing = MakeDrawing("rectangle"); + var pattern = MakePattern("concave", "rotated"); + var before = PatternSnapshot(pattern); + Assert.Empty(frozen.Fill(new Pattern(), direction)); + Assert.Empty(filler.Fill(new Pattern(), direction)); + Assert.Empty(frozen.Fill(pattern, direction)); + Assert.Empty(filler.Fill(pattern, direction)); + Assert.Empty(frozen.Fill(drawing, 0.37, direction)); + Assert.Empty(filler.Fill(drawing, 0.37, direction)); + Assert.Equal(before, PatternSnapshot(pattern)); + Assert.Throws(() => frozen.Fill((Pattern)null!, direction)); + Assert.Throws(() => filler.Fill((Pattern)null!, direction)); + Assert.Throws(() => frozen.Fill((Drawing)null!, 0, direction)); + Assert.Throws(() => filler.Fill((Drawing)null!, 0, direction)); + Assert.Throws(() => new PreStep3FillLinear(null!, 0.5)); + Assert.Throws(() => new FillLinear(null!, 0.5)); + foreach (var rapidOnly in new[] { false, true }) + { + var program = new Program(); + if (rapidOnly) + program.Codes.Add(new RapidMove(new Vector(0, 0))); + var empty = new Drawing("empty-material", program); + var emptyBefore = DrawingSnapshot(empty); + var emptyPattern = new Pattern(); + emptyPattern.Parts.Add(Part.CreateAtOrigin(empty)); + emptyPattern.UpdateBounds(); + var patternBefore = PatternSnapshot(emptyPattern); + Assert.Throws(() => frozen.Fill(empty, 0, direction)); + Assert.Throws(() => filler.Fill(empty, 0, direction)); + Assert.Throws(() => frozen.Fill(emptyPattern, direction)); + Assert.Throws(() => filler.Fill(emptyPattern, direction)); + Assert.Equal(emptyBefore, DrawingSnapshot(empty)); + Assert.Equal(patternBefore, PatternSnapshot(emptyPattern)); + } + } + + [Fact] + public void ConcurrentIndependentCalls_OneFiller_MatchFrozenAndPreserveInputs() + { + var filler = new FillLinear(new Box(3.1, -5.3, 52, 39), 0.5); + var frozen = new PreStep3FillLinear(filler.WorkArea, 0.5); + var patterns = Enumerable.Range(0, 24).Select(i => MakePattern(i % 2 == 0 ? "concave" : "arc", + i % 3 == 0 ? "shared" : i % 3 == 1 ? "rotated" : "single")).ToArray(); + var drawings = patterns.Select(p => p.Parts[0].BaseDrawing).ToArray(); + var before = patterns.Select(PatternSnapshot).ToArray(); + var expectedPatterns = patterns.Select((p, i) => frozen.Fill(p, + i % 2 == 0 ? NestDirection.Horizontal : NestDirection.Vertical)).ToArray(); + var expectedDrawings = drawings.Select((d, i) => frozen.Fill(d, 0.37, + i % 2 == 0 ? NestDirection.Horizontal : NestDirection.Vertical)).ToArray(); + Parallel.For(0, patterns.Length, new ParallelOptions { MaxDegreeOfParallelism = 4 }, i => + { + var direction = i % 2 == 0 ? NestDirection.Horizontal : NestDirection.Vertical; + AssertLayout(expectedPatterns[i], filler.Fill(patterns[i], direction), patterns[i].Parts.Select(p => p.Program).ToArray()); + AssertLayout(expectedDrawings[i], filler.Fill(drawings[i], 0.37, direction), new[] { drawings[i].Program }); + Assert.Equal(before[i], PatternSnapshot(patterns[i])); + }); + } + + private static Drawing MakeDrawing(string shape) + { + var drawing = shape switch + { + "circle" => new CircleShape { Diameter = 8 }.GetDrawing(), + "ring" => new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing(), + _ => FillExtentsTests.MakeFixture(shape), + }; + var profile = new ShapeProfile(ConvertProgram.ToGeometry(drawing.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()); + Assert.True(profile.Perimeter.IsClosed()); + Assert.All(profile.Cutouts, cutout => Assert.True(cutout.IsClosed())); + Assert.True(profile.Perimeter.Area() - profile.Cutouts.Sum(c => c.Area()) > 0); + Assert.True(drawing.Area > 0); + if (shape == "concave") + Assert.Equal(50.0, drawing.Area); + if (shape is "arc" or "circle" or "ring") + Assert.Contains(drawing.Program.Codes, code => code is ArcMove); + return drawing; + } + + private static Pattern MakePattern(string shape, string kind) + { + var drawing = MakeDrawing(shape); + var first = Part.CreateAtOrigin(drawing, 0); + if (kind == "single") + return FillHelpers.BuildRotatedPattern(new List { first }, 0.37); + var second = kind == "shared" ? first.CloneAtOffset(new Vector(10.5, 0)) + : Part.CreateAtOrigin(drawing, System.Math.PI); + if (kind != "shared") + second.Offset(new Vector(10.5, 0)); + if (kind == "rotated") + return FillHelpers.BuildRotatedPattern(new List { first, second }, 0.37); + var pattern = new Pattern(); + pattern.Parts.AddRange(new[] { first, second }); + pattern.UpdateBounds(); + Assert.Same(first.Program, second.Program); + return pattern; + } + + private static List CheckPattern(Pattern pattern, Box area, double spacing, NestDirection direction) + { + var before = PatternSnapshot(pattern); + var areaBefore = BoxBits(area); + var inputs = pattern.Parts.Select(p => p.Program).ToArray(); + Assert.False(FillHelpers.HasOverlappingParts(pattern.Parts)); + var expected = new PreStep3FillLinear(area, spacing).Fill(pattern, direction); + Assert.Equal(before, PatternSnapshot(pattern)); + var filler = new FillLinear(area, spacing); + var actual = filler.Fill(pattern, direction); + AssertLayout(expected, actual, inputs); + Assert.Equal(before, PatternSnapshot(pattern)); + AssertLayout(expected, filler.Fill(pattern, direction), inputs); + Assert.Equal(before, PatternSnapshot(pattern)); + AssertValid(actual, area); + Assert.Equal(areaBefore, BoxBits(area)); + Assert.Equal(areaBefore, BoxBits(filler.WorkArea)); + return actual; + } + + private static void AssertValid(List parts, Box area) + { + Assert.NotEmpty(parts); + Assert.All(parts, p => + { + Assert.True(p.Left >= area.Left - Tolerance.Epsilon && p.Right <= area.Right + Tolerance.Epsilon); + Assert.True(p.Bottom >= area.Bottom - Tolerance.Epsilon && p.Top <= area.Top + Tolerance.Epsilon); + }); + Assert.False(FillHelpers.HasOverlappingParts(parts)); + } + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + + private static long[] BoxBits(Box box) => new[] { Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width) }; + + private static object[] ProgramValues(Program program) + { + var values = new List { program.Mode, Bits(program.Rotation), program.Codes.Count }; + values.AddRange(BoxBits(program.BoundingBox()).Cast()); + foreach (var code in program.Codes) + { + values.Add(code.GetType()); + var motion = Assert.IsAssignableFrom(code); + values.AddRange(new object[] { code.Type, Bits(motion.EndPoint.X), Bits(motion.EndPoint.Y), + motion.Feedrate, motion.UseExactStop, motion.Suppressed, motion.VariableRefs?.Count ?? -1 }); + if (motion.VariableRefs != null) + foreach (var entry in motion.VariableRefs.OrderBy(e => e.Key)) + values.AddRange(new object[] { entry.Key, entry.Value }); + if (code is LinearMove line) + values.Add(line.Layer); + else if (code is ArcMove arc) + values.AddRange(new object[] { Bits(arc.CenterPoint.X), Bits(arc.CenterPoint.Y), arc.Rotation, arc.Layer }); + else + Assert.IsType(code); + } + return values.ToArray(); + } + + private static object[] DrawingSnapshot(Drawing drawing) => new object[] { drawing, drawing.Program, Bits(drawing.Area) } + .Concat(drawing.Program.Codes.Cast()).Concat(ProgramValues(drawing.Program)).ToArray(); + + private static object[] PatternSnapshot(Pattern pattern) => BoxBits(pattern.BoundingBox).Cast() + .Concat(pattern.Parts.SelectMany(p => new object[] { p, p.Program, Bits(p.Location.X), Bits(p.Location.Y), Bits(p.Rotation) } + .Concat(BoxBits(p.BoundingBox).Cast()).Concat(p.Program.Codes.Cast()) + .Concat(ProgramValues(p.Program)).Concat(DrawingSnapshot(p.BaseDrawing)))).ToArray(); + + private static void AssertLayout(List expected, List actual, Program[] inputs) + { + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing); + Assert.Equal(Bits(expected[i].Location.X), Bits(actual[i].Location.X)); + Assert.Equal(Bits(expected[i].Location.Y), Bits(actual[i].Location.Y)); + Assert.Equal(Bits(expected[i].Rotation), Bits(actual[i].Rotation)); + Assert.Equal(BoxBits(expected[i].BoundingBox), BoxBits(actual[i].BoundingBox)); + Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program)); + foreach (var input in inputs) + Assert.Equal(ReferenceEquals(expected[i].Program, input), ReferenceEquals(actual[i].Program, input)); + for (var j = 0; j < expected.Count; j++) + Assert.Equal(ReferenceEquals(expected[i].Program, expected[j].Program), + ReferenceEquals(actual[i].Program, actual[j].Program)); + } + } + +#if DEBUG + private sealed class FallbackListener : TraceListener + { + internal sealed class Entry + { + public string Label = ""; + public string Step = ""; + public int Count; + public string Pair = ""; + } + + public List Records { get; } = new(); + private Entry? current; + + public override void Write(string? message) { } + + public override void WriteLine(string? message) + { + var line = message?.Trim() ?? ""; + const string prefix = "[FillLinear] OVERLAP FALLBACK ("; + if (line.StartsWith(prefix, StringComparison.Ordinal)) + { + current = new Entry { Label = line[prefix.Length..^1] }; + Records.Add(current); + } + else if (current != null && line.StartsWith("Step: ", StringComparison.Ordinal)) + current.Step = line[6..].Split(',')[0]; + else if (current != null && line.StartsWith("Total parts after tiling: ", StringComparison.Ordinal)) + current.Count = int.Parse(line[26..], System.Globalization.CultureInfo.InvariantCulture); + else if (current != null && line.StartsWith("Overlapping pair ", StringComparison.Ordinal)) + current.Pair = line; + } + } +#endif +} diff --git a/OpenNest.Tests/Fill/PreStep3FillLinear.cs b/OpenNest.Tests/Fill/PreStep3FillLinear.cs new file mode 100644 index 0000000..579173a --- /dev/null +++ b/OpenNest.Tests/Fill/PreStep3FillLinear.cs @@ -0,0 +1,540 @@ +// Frozen from OpenNest.Engine/Fill/FillLinear.cs at +// 1b23ad79f25d77fdd745bfea029de9dff2a91f6d (before unchanged-row validation reuse). +// Mechanical edits only: type/constructor renamed to PreStep3FillLinear; +// namespace changed to OpenNest.Engine.Tests.Fill; class made internal; +// Engine/Fill imports added; nullable disabled; import ordering/formatting only. +// Keep independent of future FillLinear edits. LegacyFillLinear is an older oracle. +#nullable disable + +using System.Collections.Generic; +using System.Diagnostics; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Tests.Fill +{ + internal class PreStep3FillLinear + { + // Owned by one public call: FillHelpers can use this filler concurrently. + // Cached local entities never escape; only translated clones reach spatial queries. + private sealed class OffsetPerimeterCache + { + private readonly Dictionary> perimeters = + new Dictionary>(ReferenceEqualityComparer.Instance); + private readonly double spacing; + + public OffsetPerimeterCache(double spacing) => this.spacing = spacing; + + public List AtLocation(CNC.Program program, Vector location) + { + if (!perimeters.TryGetValue(program, out var local)) + { + local = PartGeometry.GetOffsetPerimeterEntities(program, spacing); + perimeters.Add(program, local); + } + + var result = new List(local.Count); + foreach (var entity in local) + { + var clone = entity.Clone(); + clone.Offset(location); + result.Add(clone); + } + return result; + } + } + + public PreStep3FillLinear(Box workArea, double partSpacing) + { + PartSpacing = partSpacing; + WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width); + } + + public Box WorkArea { get; } + + public double PartSpacing { get; } + + public double HalfSpacing => PartSpacing / 2; + + /// + /// Diagnostic label set by callers to identify the engine/context in overlap logs. + /// + public string Label { get; set; } + + private static Vector MakeOffset(NestDirection direction, double distance) + { + return direction == NestDirection.Horizontal + ? new Vector(distance, 0) + : new Vector(0, distance); + } + + private static PushDirection GetPushDirection(NestDirection direction) + { + return direction == NestDirection.Horizontal ? PushDirection.Left : PushDirection.Down; + } + + private static double GetDimension(Box box, NestDirection direction) + { + return direction == NestDirection.Horizontal ? box.Length : box.Width; + } + + private static double GetStart(Box box, NestDirection direction) + { + return direction == NestDirection.Horizontal ? box.Left : box.Bottom; + } + + private double GetLimit(NestDirection direction) + { + return direction == NestDirection.Horizontal ? WorkArea.Right : WorkArea.Top; + } + + private static NestDirection PerpendicularAxis(NestDirection direction) + { + return direction == NestDirection.Horizontal + ? NestDirection.Vertical + : NestDirection.Horizontal; + } + + /// + /// Finds the geometry-aware copy distance between two identical parts along an axis. + /// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled + /// exactly without polygon sampling error. + /// + private double FindCopyDistance(Part partA, NestDirection direction, OffsetPerimeterCache cache) + { + var bboxDim = GetDimension(partA.BoundingBox, direction); + var pushDir = GetPushDirection(direction); + var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon; + var offset = MakeOffset(direction, startOffset); + + var stationaryEntities = cache.AtLocation(partA.Program, partA.Location); + var movingEntities = cache.AtLocation(partA.Program, partA.Location + offset); + + var slideDistance = SpatialQuery.DirectionalDistance( + movingEntities, + stationaryEntities, + pushDir + ); + + if (slideDistance >= double.MaxValue || slideDistance < 0) + return bboxDim + PartSpacing; + + return startOffset - slideDistance; + } + + /// + /// Finds the geometry-aware copy distance between two identical patterns along an axis. + /// Checks every pair of parts across adjacent pattern copies so multi-part patterns + /// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity + /// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs + /// are exact and no bbox clamp is needed. + /// + private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, OffsetPerimeterCache cache) + { + if (patternA.Parts.Count == 1) + return FindCopyDistance(patternA.Parts[0], direction, cache); + + var bboxDim = GetDimension(patternA.BoundingBox, direction); + var pushDir = GetPushDirection(direction); + var opposite = SpatialQuery.OppositeDirection(pushDir); + var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0); + + // bboxDim already spans max(upper) - min(lower) across all parts, + // so the start offset just needs to push beyond that plus spacing. + var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon; + var offset = MakeOffset(direction, startOffset); + + var parts = patternA.Parts; + var stationaryBoxes = new Box[parts.Count]; + var movingBoxes = new Box[parts.Count]; + var stationaryEntities = new List[parts.Count]; + var movingEntities = new List[parts.Count]; + + for (var i = 0; i < parts.Count; i++) + { + stationaryBoxes[i] = parts[i].BoundingBox; + movingBoxes[i] = stationaryBoxes[i].Translate(offset); + } + + var maxCopyDistance = 0.0; + + for (var j = 0; j < parts.Count; j++) + { + var movingBox = movingBoxes[j]; + + for (var i = 0; i < parts.Count; i++) + { + var stationaryBox = stationaryBoxes[i]; + + // Skip if stationary is already ahead of moving in the push direction + // (sliding forward would take them further apart). + if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0) + continue; + + // Skip if bboxes can't overlap along the axis perpendicular to the push. + if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec)) + continue; + + stationaryEntities[i] ??= cache.AtLocation(parts[i].Program, parts[i].Location); + movingEntities[j] ??= cache.AtLocation(parts[j].Program, parts[j].Location + offset); + + var slideDistance = SpatialQuery.DirectionalDistance( + movingEntities[j], + stationaryEntities[i], + pushDir + ); + + if (slideDistance >= double.MaxValue || slideDistance < 0) + continue; + + var copyDist = startOffset - slideDistance; + + if (copyDist > maxCopyDistance) + maxCopyDistance = copyDist; + } + } + + return maxCopyDistance; + } + + /// + /// Tiles a pattern along the given axis, returning the cloned parts + /// (does not include the original pattern's parts). For multi-part + /// patterns, also adds individual parts from the next incomplete copy + /// that still fit within the work area. + /// + private List TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache) + { + var copyDistance = FindPatternCopyDistance(basePattern, direction, cache); + + if (copyDistance <= 0) + return new List(); + + var dim = GetDimension(basePattern.BoundingBox, direction); + var start = GetStart(basePattern.BoundingBox, direction); + var limit = GetLimit(direction); + + var estimatedCopies = (int)((limit - start - dim) / copyDistance); + var result = new List(estimatedCopies * basePattern.Parts.Count); + + var count = 1; + + while (true) + { + var nextPos = start + copyDistance * count; + + if (nextPos + dim > limit + Tolerance.Epsilon) + break; + + var offset = MakeOffset(direction, copyDistance * count); + + foreach (var part in basePattern.Parts) + result.Add(part.CloneAtOffset(offset)); + + count++; + } + + // For multi-part patterns, try to place individual parts from the + // next copy that didn't fit as a whole. This handles cases where + // e.g. a 2-part pair only partially fits — one part may still be + // within the work area even though the full pattern exceeds it. + if (basePattern.Parts.Count > 1) + { + var offset = MakeOffset(direction, copyDistance * count); + + foreach (var basePart in basePattern.Parts) + { + var part = basePart.CloneAtOffset(offset); + + if ( + part.BoundingBox.Right <= WorkArea.Right + Tolerance.Epsilon + && part.BoundingBox.Top <= WorkArea.Top + Tolerance.Epsilon + && part.BoundingBox.Left >= WorkArea.Left - Tolerance.Epsilon + && part.BoundingBox.Bottom >= WorkArea.Bottom - Tolerance.Epsilon + ) + { + result.Add(part); + } + } + } + + return result; + } + + /// + /// Fallback tiling using bounding-box spacing when geometry-aware tiling + /// produces overlapping parts. + /// + private List TilePatternBbox(Pattern basePattern, NestDirection direction) + { + var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing; + + if (copyDistance <= 0) + return new List(); + + var dim = GetDimension(basePattern.BoundingBox, direction); + var start = GetStart(basePattern.BoundingBox, direction); + var limit = GetLimit(direction); + + var result = new List(); + var count = 1; + + while (true) + { + var nextPos = start + copyDistance * count; + + if (nextPos + dim > limit + Tolerance.Epsilon) + break; + + var offset = MakeOffset(direction, copyDistance * count); + + foreach (var part in basePattern.Parts) + result.Add(part.CloneAtOffset(offset)); + + count++; + } + + return result; + } + + private static bool HasOverlappingParts( + List parts, + out int overlapA, + out int overlapB + ) + { + var checker = new PartOverlapChecker(); + + for (var i = 0; i < parts.Count; i++) + { + var b1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) + { + var b2 = parts[j].BoundingBox; + + var overlapX = + System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left); + var overlapY = + System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom); + + if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon) + continue; + + if (checker.Overlaps(parts[i], parts[j])) + { + overlapA = i; + overlapB = j; + return true; + } + } + } + + overlapA = -1; + overlapB = -1; + return false; + } + + /// + /// Creates a seed pattern containing a single part positioned at the work area origin. + /// Returns an empty pattern if the part does not fit. + /// + private Pattern MakeSeedPattern(Drawing drawing, double rotationAngle) + { + var pattern = new Pattern(); + + var template = new Part(drawing); + + if (!rotationAngle.IsEqualTo(0)) + template.Rotate(rotationAngle); + + template.Offset(WorkArea.Location - template.BoundingBox.Location); + + if ( + template.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon + || template.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon + ) + return pattern; + + pattern.Parts.Add(template); + pattern.UpdateBounds(); + return pattern; + } + + /// + /// Fills the work area by tiling the pattern along the primary axis to form + /// a row, then tiling that row along the perpendicular axis to form a grid. + /// After the grid is formed, fills the remaining strip with individual parts. + /// + private List FillGrid(Pattern pattern, NestDirection direction, OffsetPerimeterCache cache) + { + var perpAxis = PerpendicularAxis(direction); + + // Step 1: Tile along primary axis + var row = new List(pattern.Parts); + row.AddRange(TilePattern(pattern, direction, cache)); + + if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1)) + { + LogOverlap("Step1-Primary", direction, pattern, row, a1, b1); + row = new List(pattern.Parts); + row.AddRange(TilePatternBbox(pattern, direction)); + } + + // If primary tiling didn't produce copies, just tile along perpendicular + if (row.Count <= pattern.Parts.Count) + { + row.AddRange(TilePattern(pattern, perpAxis, cache)); + + if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2)) + { + LogOverlap("Step1-PerpOnly", perpAxis, pattern, row, a2, b2); + row = new List(pattern.Parts); + row.AddRange(TilePatternBbox(pattern, perpAxis)); + } + + return row; + } + + // Step 2: Build row pattern and tile along perpendicular axis + var rowPattern = new Pattern(); + rowPattern.Parts.AddRange(row); + rowPattern.UpdateBounds(); + + var gridResult = new List(rowPattern.Parts); + gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache)); + + if (HasOverlappingParts(gridResult, out var a3, out var b3)) + { + LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3); + gridResult = new List(rowPattern.Parts); + gridResult.AddRange(TilePatternBbox(rowPattern, perpAxis)); + } + + return gridResult; + } + + private void LogOverlap( + string step, + NestDirection tilingDir, + Pattern pattern, + List parts, + int idxA, + int idxB + ) + { + var pa = parts[idxA]; + var pb = parts[idxB]; + var ba = pa.BoundingBox; + var bb = pb.BoundingBox; + + Debug.WriteLine($"[FillLinear] OVERLAP FALLBACK ({Label ?? "unknown"})"); + Debug.WriteLine($" Step: {step}, TilingDir: {tilingDir}"); + Debug.WriteLine( + $" WorkArea: ({WorkArea.X:F4},{WorkArea.Y:F4}) {WorkArea.Width:F4}x{WorkArea.Length:F4}, Spacing: {PartSpacing}" + ); + Debug.WriteLine( + $" Pattern: {pattern.Parts.Count} parts, bbox {pattern.BoundingBox.Width:F4}x{pattern.BoundingBox.Length:F4}" + ); + Debug.WriteLine($" Total parts after tiling: {parts.Count}"); + Debug.WriteLine($" Overlapping pair [{idxA}] vs [{idxB}]:"); + Debug.WriteLine( + $" [{idxA}]: drawing={pa.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pa.Rotation):F2}° " + + $"loc=({pa.Location.X:F4},{pa.Location.Y:F4}) bbox=({ba.Left:F4},{ba.Bottom:F4})-({ba.Right:F4},{ba.Top:F4})" + ); + Debug.WriteLine( + $" [{idxB}]: drawing={pb.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pb.Rotation):F2}° " + + $"loc=({pb.Location.X:F4},{pb.Location.Y:F4}) bbox=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4})" + ); + + // Log all pattern seed parts for reproduction + Debug.WriteLine($" Pattern seed parts:"); + for (var i = 0; i < pattern.Parts.Count; i++) + { + var p = pattern.Parts[i]; + Debug.WriteLine( + $" [{i}]: drawing={p.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(p.Rotation):F2}° " + + $"loc=({p.Location.X:F4},{p.Location.Y:F4}) bbox={p.BoundingBox.Width:F4}x{p.BoundingBox.Length:F4}" + ); + } + } + + /// + /// Fills a single row of identical parts along one axis using geometry-aware spacing. + /// + public Pattern FillRow(Drawing drawing, double rotationAngle, NestDirection direction) + { + var cache = new OffsetPerimeterCache(HalfSpacing); + var seed = MakeSeedPattern(drawing, rotationAngle); + + if (seed.Parts.Count == 0) + return seed; + + var template = seed.Parts[0]; + + var copyDistance = FindCopyDistance(template, direction, cache); + + if (copyDistance <= 0) + return seed; + + var dim = GetDimension(template.BoundingBox, direction); + var start = GetStart(template.BoundingBox, direction); + var limit = GetLimit(direction); + + var count = 1; + + while (true) + { + var nextPos = start + copyDistance * count; + + if (nextPos + dim > limit + Tolerance.Epsilon) + break; + + var clone = template.CloneAtOffset(MakeOffset(direction, copyDistance * count)); + seed.Parts.Add(clone); + count++; + } + + seed.UpdateBounds(); + return seed; + } + + /// + /// Fills the work area by tiling a pre-built pattern along both axes. + /// + public List Fill(Pattern pattern, NestDirection primaryAxis) + { + var cache = new OffsetPerimeterCache(HalfSpacing); + if (pattern.Parts.Count == 0) + return new List(); + + var offset = WorkArea.Location - pattern.BoundingBox.Location; + var basePattern = pattern.Clone(offset); + + if ( + basePattern.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon + || basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon + ) + return new List(); + + return FillGrid(basePattern, primaryAxis, cache); + } + + /// + /// Fills the work area by creating a seed part, then recursively tiling + /// along the primary axis and then the perpendicular axis. + /// + public List Fill(Drawing drawing, double rotationAngle, NestDirection primaryAxis) + { + var cache = new OffsetPerimeterCache(HalfSpacing); + var seed = MakeSeedPattern(drawing, rotationAngle); + + if (seed.Parts.Count == 0) + return new List(); + + return FillGrid(seed, primaryAxis, cache); + } + } +} diff --git a/docs/performance/fill-verification.md b/docs/performance/fill-verification.md index 0d92874..10787ee 100644 --- a/docs/performance/fill-verification.md +++ b/docs/performance/fill-verification.md @@ -11,6 +11,25 @@ Only the exact value `1` enables these tests; otherwise they skip; [README](../. The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, no-model angle, and FillLinear offset-geometry workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Overlap checks are measured separately by `OverlapCheck_ReportsPolygonPairsAndGridChecks` in `OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs` (same category). Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md). +### FillLinear unchanged-row validation baseline + +```bash +# Both configurations must characterize unchanged production; unset perf flag skips. +dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ + --filter 'FullyQualifiedName~FillLinearValidation' --logger 'trx;LogFileName=linear-validation-debug.trx' +dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'FullyQualifiedName~FillLinearValidation' --logger 'trx;LogFileName=linear-validation-release.trx' +OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'FullyQualifiedName~FillLinearValidation_ReportsStripeAndControls' \ + --logger 'console;verbosity=detailed' --logger 'trx;LogFileName=linear-validation-perf.trx' +``` + +`FillLinearValidationPerformanceTests.cs` is self-contained against base APIs: byte-copy and hash the same file in both trees. It times complete production `Fill` calls (not frozen code or overlap-only helpers): horizontal/vertical stripes, full grid, partial-only additions, and single-seed stripe. The closed concave fixture gives counts **8 / 19 / 36 / 29 / 8**; grid is row 8 + 28 additions, partial-only is row 19 + 10 (no full row). Two 100-call warmups and seven 200-call batches per mode alternate forward/reverse mode order. Retain every raw tick/time/current-thread allocation row, including warmups; setup/assertions/output are excluded, count/last-result consumption is identical. No timing or reduced-work assertions belong in this baseline harness. + +`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. + Debug behavior/skipped-work checks: ```bash