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 }