From 6addad7b2e334c1c3442435ae63c361daafc57ae Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 4 Oct 2026 20:26:17 -0400 Subject: [PATCH] test(cutting): characterize retained contour emission --- .../CuttingPlanning/ExplicitContourTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 OpenNest.Tests/CuttingPlanning/ExplicitContourTests.cs diff --git a/OpenNest.Tests/CuttingPlanning/ExplicitContourTests.cs b/OpenNest.Tests/CuttingPlanning/ExplicitContourTests.cs new file mode 100644 index 0000000..64da848 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ExplicitContourTests.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class ExplicitContourTests +{ + public static IEnumerable RetainedStyles() + { + foreach (var style in new[] { "line", "arc", "linearc", "lineline", "clean" }) + foreach (var reversed in new[] { false, true }) + foreach (var internalContour in new[] { false, true }) + yield return [style, reversed, internalContour]; + } + + [Theory] + [MemberData(nameof(RetainedStyles))] + public void LegacyEmission_RetainsStylesWindingAndCornerFallback(string style, bool reversed, bool internalContour) + { + var program = Square(reversed); + var entities = program.ToGeometry().Where(e => e.Layer != SpecialLayers.Rapid).ToArray(); + var parameters = Parameters(style); + if (internalContour) + { + program.MoveTo(-5, -5); program.LineTo(-5, 15); program.LineTo(15, 15); + program.LineTo(15, -5); program.LineTo(-5, -5); + } + var result = new ContourCuttingStrategy { Parameters = parameters }.ApplySingle(program, + new Vector(0, 0), entities[0], internalContour ? ContourType.Internal : ContourType.External); + var expected = $"{style}:{reversed}:{internalContour}" switch + { + "arc:False:False" => "4F89EDF317A545A0F496A1C7F9C633755663D98AFCF71F783B4AABF700D1711C", + "arc:False:True" => "478018E0A86BA1FF0F08204EF5449D4484ADD1B58A40EA0936069DE6B6002E3F", + "arc:True:False" => "CDC68828AD39D4B88C205347FDC06227C03A947F6AB3A19376F4BD670987C1A6", + "arc:True:True" => "83CAD7BDCF4A655730D779966355F535E36429B5AAFA4423CB58E3B3FAAD4376", + "clean:False:False" => "E3A6BE74CC4B72EC5DEFD9E53743DD080F6AF6854A0E7143860E5246745D1C12", + "clean:False:True" => "196CC9BE57E9740AB8E35A77A48473DCD8B62312DE2903B2E66F62BB39818DDA", + "clean:True:False" => "6E51376D40D93BD0DAD2E51FFAC732A9BE1717C252964BA2285A8440A23C3480", + "clean:True:True" => "80DB265B4E9846169421B3DDCB8DAE003D720EEE8EF9DAB64C40EE00CC322DE6", + "line:False:False" => "A9DAB8F802CDE0A89A0D26B4C5AC5546E121E71AF9EBA2283F307ADF88866347", + "line:False:True" => "61EE8CF9A30B18C72DDACD314886709692CA8E50E7DC61170A900429562A45B5", + "line:True:False" => "AB6E00410C3AB8592B9DD7C5EC1E9E8ABD39A2DCA9486FCEAE28593D9BA7504C", + "line:True:True" => "23E2404046438ECF67BAC46AC7692B6DF60EFA674C286D72B25F98FE77431588", + "linearc:False:False" => "E3A6BE74CC4B72EC5DEFD9E53743DD080F6AF6854A0E7143860E5246745D1C12", + "linearc:False:True" => "196CC9BE57E9740AB8E35A77A48473DCD8B62312DE2903B2E66F62BB39818DDA", + "linearc:True:False" => "6E51376D40D93BD0DAD2E51FFAC732A9BE1717C252964BA2285A8440A23C3480", + "linearc:True:True" => "80DB265B4E9846169421B3DDCB8DAE003D720EEE8EF9DAB64C40EE00CC322DE6", + "lineline:False:False" => "F25E4FA39713A53D04FC5DA22BD3FD1EB15330462153356906AD4DAA1C1A23C2", + "lineline:False:True" => "911C85F1C6A053F9C94F63A40B520C30B00AA43B804634E0203DD406BB98BF6E", + "lineline:True:False" => "AE8E6CD1B641D33A39DAE07A6FAF539668BD794DC8105F4D41C1BE33927FFC65", + "lineline:True:True" => "54E1F71B8420ACAF1C57E124A0F927BA00F04F52CDBAB48EDFC27BAB8C346EA5", + _ => throw new InvalidOperationException() + }; + Assert.Equal(expected, Fingerprint(result.Program)); + } + + [Fact] + public void LegacyCircle_RoundsActualEntryClampsAndSharesSubprograms() + { + var program = Square(false); + foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) }) + { + program.MoveTo(center.X + 0.5, center.Y); + program.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW); + } + var parameters = Parameters("line"); + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = 90; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 2 }; + var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(program, new Vector(20, 20)); + var calls = result.Program.Codes.OfType().ToArray(); + Assert.Equal(2, calls.Length); + Assert.Same(calls[0].Program, calls[1].Program); + var moves = ExecutionMotionReader.Read(result.Program, Vector.Zero, null, default).Motions; + Assert.All(moves.Where(m => m.Layer == LayerType.Leadin).Take(2), m => Assert.True(m.Length < 1)); + Assert.Equal("9BB777CDD1F7407EB0D4B0FE1D1A760370A094A606E61B7E3875DCB5EF051F20", Fingerprint(result.Program)); + } + + [Fact] + public void LegacyTab_LeavesGenuineGap() + { + var parameters = Parameters("line"); + parameters.TabsEnabled = true; + parameters.TabConfig = new NormalTab { Size = 0.2 }; + var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(Square(false), new Vector(-1, 5)); + var moves = ExecutionMotionReader.Read(result.Program, Vector.Zero, null, default).Motions; + var cuts = moves.Where(m => !m.Rapid && m.Layer == LayerType.Display).ToArray(); + Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1); + Assert.Equal("C5CDFACDE4019AEBFCD4AA304D149DD603313D341311F27C9ECBB4ED940D2204", Fingerprint(result.Program)); + } + + internal static Program Square(bool reversed) + { + var p = new Program(); + p.MoveTo(0, 0); + if (reversed) + { + p.LineTo(10, 0); p.LineTo(10, 10); p.LineTo(0, 10); + } + else + { + p.LineTo(0, 10); p.LineTo(10, 10); p.LineTo(10, 0); + } + p.LineTo(0, 0); + return p; + } + + internal static CuttingParameters Parameters(string style = "line") + { + LeadIn lead = style switch + { + "arc" => new ArcLeadIn { Radius = 0.2 }, + "linearc" => new LineArcLeadIn { LineLength = 0.1, ArcRadius = 0.2 }, + "lineline" => new LineLineLeadIn { Length1 = 0.1, Length2 = 0.2 }, + "clean" => new CleanHoleLeadIn { LineLength = 0.1, ArcRadius = 0.2, Kerf = 0.01 }, + _ => new LineLeadIn { Length = 0.3, ApproachAngle = 45 } + }; + return new CuttingParameters + { + ExternalLeadIn = lead, InternalLeadIn = lead, ArcCircleLeadIn = lead, + ExternalLeadOut = new NoLeadOut(), InternalLeadOut = new NoLeadOut(), + PierceClearance = 0.05 + }; + } + + internal static string Fingerprint(Program program) + { + var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default); + var text = string.Join("\n", execution.Motions.Select(m => string.Join("|", m.Rapid, m.Layer, + m.Start?.X.ToString("R", CultureInfo.InvariantCulture), m.Start?.Y.ToString("R", CultureInfo.InvariantCulture), + m.End.X.ToString("R", CultureInfo.InvariantCulture), m.End.Y.ToString("R", CultureInfo.InvariantCulture), + m.Length.ToString("R", CultureInfo.InvariantCulture)))); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + } +}