using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Threading; using OpenNest.CNC; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Engine.Tests.NestingEngines; using OpenNest.Geometry; namespace OpenNest.Engine.Tests.Jobs; /// /// Characterization pins for the Fill/fixed-strategy placement stack on irregular parts: the /// interactive surface (single-item fill, whole-plate nest, clamp /// refills) and one whole job per . Each case pins the /// sorted poses (part, X, Y, rotation rounded to 1e-6) as a count plus SHA-256 digest; a failure /// prints the actual layout. They exist to prove filler/nester refactors layout-neutral, so a /// changed digest is a layout change, never a value to re-capture without explaining it. /// /// /// Captured on Windows/.NET 8 without the optional ML angle model, so irregular parts take the /// 5-degree sweep; each case gave one layout over 5 test processes of 2 solves each, and every /// case solves twice in-process and must agree. The whole-plate Fill nests fill several /// irregular drawings with one filler, so later items use the angles /// AngleCandidateBuilder.RecordProductive learned from earlier ones. /// Not pinned: richer mixes that pair parallelogram, chevron or tee parts at oblique angles /// with other irregular items. Their Fill and Vertical Remnant nests vary between runs, most /// likely because best-fit pair candidates are gathered in a ConcurrentBag and equal /// scores keep that arbitrary order. /// public class FillStackCharacterizationTests { private static Program LShape() { var program = new Program(); program.MoveTo(0, 0); program.LineTo(6, 0); program.LineTo(6, 4); program.LineTo(3, 4); program.LineTo(3, 2); program.LineTo(0, 2); program.LineTo(0, 0); return program; } /// 6x4 block with a 2x2 notch cut into the middle of its top edge. private static Program Notched() { var program = new Program(); program.MoveTo(0, 0); program.LineTo(6, 0); program.LineTo(6, 4); program.LineTo(4, 4); program.LineTo(4, 2); program.LineTo(2, 2); program.LineTo(2, 4); program.LineTo(0, 4); program.LineTo(0, 0); return program; } /// D-shape: 3x5 block closed by a semicircular arc. private static Program ArcPart() { var program = new Program(); program.MoveTo(0, 0); program.LineTo(3, 0); program.ArcTo(3, 5, 3, 2.5, RotationType.CCW); program.LineTo(0, 5); program.LineTo(0, 0); return program; } /// /// 46 x 3 bar with a stepped end: irregular, and too long for the 48 x 28 work area at all /// but the sweep angles near 0 and 180 degrees, so only those prove productive and get learned. /// private static Program LongBar() { var program = new Program(); program.MoveTo(0, 0); program.LineTo(46, 0); program.LineTo(46, 3); program.LineTo(42, 3); program.LineTo(42, 1.5); program.LineTo(0, 1.5); program.LineTo(0, 0); return program; } /// /// 6 x 4 chevron. Filled after , its layout differs between the learned /// (pruned) angle list and a full sweep. /// private static Program Chevron() { var program = new Program(); program.MoveTo(0, 0); program.LineTo(3, 2); program.LineTo(6, 0); program.LineTo(6, 2); program.LineTo(3, 4); program.LineTo(0, 2); program.LineTo(0, 0); return program; } private static Program Shape(string name) => name switch { "lshape" => LShape(), "notched" => Notched(), "arc" => ArcPart(), "bar" => LongBar(), "chevron" => Chevron(), _ => TestDrawingFactory.Rectangle(4, 3), }; private static Plate MakePlate() { var plate = new Plate(new Size(30, 50)) { EdgeSpacing = new Spacing(1, 1, 1, 1), PartSpacing = 0.5, }; return plate; } /// Two front-edge clamps (all on one edge, so the trimmed-strip layout competes). private static Plate MakeClampedPlate() { var plate = MakePlate(); plate.ClampZones.Add(new ClampZone("front-1", new Box(12, 0, 2, 1.5))); plate.ClampZones.Add(new ClampZone("front-2", new Box(32, 0, 2, 1.5))); return plate; } // --- pose digests --- private static string FormatPose(string id, double x, double y, double rotation) { var twoPi = System.Math.PI * 2; var angle = rotation % twoPi; if (angle < 0) angle += twoPi; if (twoPi - angle < 1e-9) angle = 0; return string.Format( CultureInfo.InvariantCulture, "{0}@({1},{2},{3})", id, Round(x), Round(y), Round(angle) ); } // Normalizes negative zero so rounding never splits one pose into two spellings. private static string Round(double value) => (System.Math.Round(value, 6) + 0.0).ToString("F6", CultureInfo.InvariantCulture); private static string Layout(IEnumerable parts) => string.Join( "\n", parts .Select(p => FormatPose(p.BaseDrawing.Name, p.Location.X, p.Location.Y, p.Rotation)) .Order(StringComparer.Ordinal) ); private static string Layout(NestJobResult result) => string.Join( "\n", result .Plates.SelectMany(plate => plate.Placements.Select(p => $"{plate.PlateIndex}:{p.InstanceIndex}:" + FormatPose(p.PartId, p.X, p.Y, p.Rotation) ) ) .Order(StringComparer.Ordinal) ); private static string Digest(string layout) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(layout)))[..16]; /// Runs twice and requires identical layouts. private static string Stable(Func solve) { var first = solve(); var second = solve(); Assert.True( first == second, $"layout must be deterministic across repeat runs:\n{first}\n---\n{second}" ); return first; } private static void AssertPinned(string layout, int expectedCount, string expectedDigest) { var count = layout.Split('\n').Count(line => line.Contains('@')); var digest = Digest(layout); Assert.True( count == expectedCount && digest == expectedDigest, $"layout changed: expected {expectedCount} parts digest {expectedDigest}, " + $"actual {count} parts digest {digest}\n{layout}" ); } // --- interactive single-item fill --- [Theory] [InlineData("Fill", "lshape", 52, "F1FB09254D935531")] [InlineData("Fill", "notched", 46, "32FB33C3C19FDCC8")] [InlineData("Fill", "arc", 40, "4E2F91AA3151E9E7")] [InlineData("Strip", "lshape", 52, "F1FB09254D935531")] [InlineData("Strip", "notched", 46, "32FB33C3C19FDCC8")] [InlineData("Strip", "arc", 40, "4E2F91AA3151E9E7")] [InlineData("Vertical Remnant", "lshape", 52, "F1FB09254D935531")] [InlineData("Vertical Remnant", "notched", 46, "32FB33C3C19FDCC8")] [InlineData("Vertical Remnant", "arc", 40, "BBC5C583C3C7A9A7")] [InlineData("Horizontal Remnant", "lshape", 52, "F1FB09254D935531")] [InlineData("Horizontal Remnant", "notched", 46, "32FB33C3C19FDCC8")] [InlineData("Horizontal Remnant", "arc", 40, "4E2F91AA3151E9E7")] public void FillItem_IrregularPart_PinnedLayout( string strategy, string shape, int expectedCount, string expectedDigest ) { var layout = Stable(() => { var plate = MakePlate(); var item = new NestItem { Drawing = new Drawing(shape, Shape(shape)), Quantity = 0 }; return Layout( PlateFillService.FillItem( strategy, plate, item, plate.WorkArea(), null, CancellationToken.None ) ); }); AssertPinned(layout, expectedCount, expectedDigest); } // --- interactive whole-plate nest --- /// /// Three irregular fill items (each over 10% of the plate) plus a single rectangle that /// takes the pack path. Strip runs the iterative shrink fill on the multi-quantity items. /// private static List MixedItems() => [ new() { Drawing = new Drawing("lshape", LShape()), Quantity = 14 }, new() { Drawing = new Drawing("notched", Notched()), Quantity = 9 }, new() { Drawing = new Drawing("arc", ArcPart()), Quantity = 11 }, new() { Drawing = new Drawing("rect", TestDrawingFactory.Rectangle(4, 3)), Quantity = 1 }, ]; /// /// The bar fills first and is productive only near 0 and 180 degrees, so the Fill filler /// prunes the chevron and notched fills to the angles it learned (see /// ). /// private static List LearnedAngleItems() => [ new() { Drawing = new Drawing("bar", LongBar()), Quantity = 3 }, new() { Drawing = new Drawing("chevron", Chevron()), Quantity = 20 }, new() { Drawing = new Drawing("notched", Notched()), Quantity = 6 }, new() { Drawing = new Drawing("rect", TestDrawingFactory.Rectangle(4, 3)), Quantity = 1 }, ]; private static string NestLayout(string strategy, Func> createItems) { var plate = MakePlate(); var items = createItems(); var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None); // Remaining demand is part of the contract callers read back. var remaining = string.Join(",", items.Select(i => $"{i.Drawing.Name}={i.Quantity}")); return Layout(parts) + "\nremaining:" + remaining; } [Theory] [InlineData("Fill", 33, "5F41788D261A5085")] [InlineData("Strip", 35, "865B16D4365A2492")] [InlineData("Vertical Remnant", 35, "8CC17C7708C8049F")] [InlineData("Horizontal Remnant", 30, "A0700052AE771E06")] public void Nest_MixedIrregularItems_PinnedLayout( string strategy, int expectedCount, string expectedDigest ) { var layout = Stable(() => NestLayout(strategy, MixedItems)); AssertPinned(layout, expectedCount, expectedDigest); } [Theory] [InlineData("Fill", 30, "5732030365D13B9A")] [InlineData("Strip", 30, "A573BA6919FCD300")] [InlineData("Vertical Remnant", 30, "5732030365D13B9A")] [InlineData("Horizontal Remnant", 30, "C05F6A30A648B929")] public void Nest_LearnedAngleItems_PinnedLayout( string strategy, int expectedCount, string expectedDigest ) { var layout = Stable(() => NestLayout(strategy, LearnedAngleItems)); AssertPinned(layout, expectedCount, expectedDigest); } /// /// Keeps sensitive to learned angles: the /// Fill layout must differ from the same nest with a full sweep for every item. If this /// fails, the fixture no longer catches a filler that forgets learned angles between items. /// [Fact] public void Nest_LearnedAngleItems_DependsOnLearnedAngles() { string Solve(bool fullSweep) { var filler = new DefaultPlateFiller(MakePlate()) { ForceFullAngleSweep = fullSweep }; return Layout(filler.Nest(LearnedAngleItems(), null, CancellationToken.None)); } Assert.NotEqual(Solve(fullSweep: true), Solve(fullSweep: false)); } // --- interactive fill beside clamps --- [Theory] [InlineData("Fill", 40, "9BF5FC7FC47364A9")] [InlineData("Strip", 40, "9BF5FC7FC47364A9")] [InlineData("Vertical Remnant", 40, "9BF5FC7FC47364A9")] [InlineData("Horizontal Remnant", 40, "C1E81564A620302A")] public void FillItem_WithClampZones_PinnedLayout( string strategy, int expectedCount, string expectedDigest ) { var layout = Stable(() => { var plate = MakeClampedPlate(); // A quantity close to a full sheet makes clamp-dropped copies trigger refills. var item = new NestItem { Drawing = new Drawing("lshape", LShape()), Quantity = 40 }; return Layout( PlateFillService.FillItem( strategy, plate, item, plate.WorkArea(), null, CancellationToken.None ) ); }); AssertPinned(layout, expectedCount, expectedDigest); } // --- whole-job fixed strategies --- private static NestJobPart JobPart(string id, int quantity) => new(id, PartGeometrySnapshot.FromProgram(Shape(id)), quantity); private static NestJob IrregularJob() => new( new[] { JobPart("lshape", 10), JobPart("notched", 6), JobPart("arc", 7), JobPart("rect", 3), }, new[] { new NestPlateStock("stock", new Size(30, 50), 3, 0.5, new Spacing(1, 1, 1, 1)) }, new NestJobOptions("Fill") ); [Theory] [InlineData("Fill", 26, "64CE33B01B205978")] [InlineData("Strip", 26, "A66602F707290A36")] [InlineData("Vertical Remnant", 26, "BDCA1DA111B396F0")] [InlineData("Horizontal Remnant", 26, "C356EC216CD56627")] public void FixedStrategyEngine_IrregularJob_PinnedLayout( string strategy, int expectedCount, string expectedDigest ) { var layout = Stable(() => { var job = IrregularJob(); var result = new FixedStrategyNestingEngine(strategy).Solve(job); Assert.Equal(NestJobStatus.Complete, result.Status); // Not LayoutAssert.Valid: today the Fill and Vertical Remnant layouts of this job // fail NestLayoutCheck's arc-to-arc spacing check on an interlocked arc pair, though // the runner's placement validator accepts them. These tests pin current behavior. return Layout(result); }); AssertPinned(layout, expectedCount, expectedDigest); } }