From fa6f66429632fec3cea3d017b4b7e2196174172f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 15:52:10 -0500 Subject: [PATCH] fix(posts): pick Cincinnati CL kerf side from the shared contour winding The CL post wrote the configured default side (G41) on every contour, so a contour wound against OpenNest's convention was compensated into the part. It now takes each feature's side from Core's shared PostContours winding rule, as the CI Fiber post does: the perimeter is picked across the part's cut features and hole sub-program contours are always holes. Correctly wound drawings post unchanged. Remove the "Default kerf side" post setting; saved configs that still carry it load as before. Expose PostContours.Flatten and Classify so a post can classify one part's features itself. Add CL tests for a square with a hole: the exact inline program, cuts that match the shared contours on the sheet, G41/G42 keeping the part out of the kerf inline and through part sub-programs, and identical output from absolute and incremental part and hole programs. The replay now records the compensation in force on each cut. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/CNC/Posting/PostContours.cs | 23 +- .../CincinnatiFeatureWriterTests.cs | 5 +- .../CincinnatiKerfAndCoordinateTests.cs | 200 ++++++++++++++++++ OpenNest.Tests/Cincinnati/CincinnatiReplay.cs | 21 ++ .../CincinnatiFeatureWriter.cs | 25 +-- .../CincinnatiPartSubprogramWriter.cs | 6 +- .../CincinnatiPostConfig.cs | 17 -- .../CincinnatiPostProcessor.cs | 3 +- .../CincinnatiSheetWriter.cs | 4 + .../OpenNest.Posts.Cincinnati/FeatureUtils.cs | 47 ++++ .../OpenNest.Posts.Cincinnati.json | 1 - docs/cincinnati-post-output.md | 8 +- 12 files changed, 318 insertions(+), 42 deletions(-) create mode 100644 OpenNest.Tests/Cincinnati/CincinnatiKerfAndCoordinateTests.cs diff --git a/OpenNest.Core/CNC/Posting/PostContours.cs b/OpenNest.Core/CNC/Posting/PostContours.cs index ac85ddd0..388a4e41 100644 --- a/OpenNest.Core/CNC/Posting/PostContours.cs +++ b/OpenNest.Core/CNC/Posting/PostContours.cs @@ -41,14 +41,25 @@ public static class PostContours /// public static List Extract(Program program, Vector origin) { - var contours = new List(); - Flatten(program, origin, contours); + var contours = Flatten(program, origin); Classify(contours); return contours; } - /// Picks the perimeter (marks excluded) and each contour's kerf side. - private static void Classify(List contours) + /// + /// The program's contours in the frame, with no perimeter or kerf + /// side chosen yet. A post that splits one part across several calls gathers them here and + /// classifies the whole part with . + /// + public static List Flatten(Program program, Vector origin) + { + var contours = new List(); + FlattenInto(program, origin, contours); + return contours; + } + + /// Picks one part's perimeter (marks excluded) and each contour's kerf side. + public static void Classify(IReadOnlyList contours) { var perimeter = ContourWinding.PerimeterIndex(contours); for (var i = 0; i < contours.Count; i++) @@ -68,7 +79,7 @@ public static class PostContours /// suppressed moves starts a new contour pierced where the run ends, so the gap is crossed /// with the beam off rather than cut as a chord. /// - private static void Flatten(Program program, Vector origin, List contours) + private static void FlattenInto(Program program, Vector origin, List contours) { if (program == null) return; @@ -118,7 +129,7 @@ public static class PostContours // A hole call is its own contour. Flush(); resumeAt = null; - Flatten(call.Program, origin + call.Offset, contours); + FlattenInto(call.Program, origin + call.Offset, contours); break; case Motion motion: diff --git a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs index b7e8ef70..20d36507 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs @@ -13,7 +13,6 @@ public class CincinnatiFeatureWriterTests FeatureLineNumberStart = 1, UseAntiDive = true, KerfCompensation = KerfMode.ControllerSide, - DefaultKerfSide = KerfSide.Left, InteriorM47 = M47Mode.Always, ExteriorM47 = M47Mode.Always, UseSpeedGas = false, @@ -112,8 +111,8 @@ public class CincinnatiFeatureWriterTests { var config = DefaultConfig(); config.KerfCompensation = KerfMode.ControllerSide; - config.DefaultKerfSide = KerfSide.Left; var ctx = SimpleContext(); + ctx.Kerf = KerfType.Left; var output = WriteFeature(config, ctx); Assert.Contains("G41", output); @@ -125,8 +124,8 @@ public class CincinnatiFeatureWriterTests { var config = DefaultConfig(); config.KerfCompensation = KerfMode.ControllerSide; - config.DefaultKerfSide = KerfSide.Right; var ctx = SimpleContext(); + ctx.Kerf = KerfType.Right; var output = WriteFeature(config, ctx); Assert.Contains("G42", output); diff --git a/OpenNest.Tests/Cincinnati/CincinnatiKerfAndCoordinateTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiKerfAndCoordinateTests.cs new file mode 100644 index 00000000..c50a2a78 --- /dev/null +++ b/OpenNest.Tests/Cincinnati/CincinnatiKerfAndCoordinateTests.cs @@ -0,0 +1,200 @@ +using OpenNest.CNC; +using OpenNest.CNC.Posting; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Cincinnati; + +/// +/// A square with a hole through the CL post: the exact program, where the replayed G-code cuts +/// against Core's shared contours, a kerf side that keeps the part out of the kerf in inline +/// posting, part sub-programs and the hole sub-program, and the same program whichever mode +/// the part and hole programs are stored in. +/// +public class CincinnatiKerfAndCoordinateTests +{ + private static readonly Vector Location = new(1, 2); + + /// + /// A 10x10 square cut CW from (0,0) after a linear lead-in, and a radius-0.5 hole at (5,5) + /// cut CCW as a hole call; winds both the other way. + /// + private static Nest SquareWithHole(Mode partMode = Mode.Absolute, Mode holeMode = Mode.Incremental, + bool reversed = false) + { + var hole = new Program(Mode.Absolute); + hole.Codes.Add(new RapidMove(new Vector(0, -0.6))); + hole.Codes.Add(new LinearMove(new Vector(0, -0.5)) { Layer = LayerType.Leadin }); + hole.Codes.Add(new ArcMove(new Vector(0, -0.5), Vector.Zero, reversed ? RotationType.CW : RotationType.CCW) + { + Layer = LayerType.Cut, + }); + hole.Mode = holeMode; + + var pgm = new Program(Mode.Absolute); + pgm.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(5, 5) }); + pgm.Codes.Add(new RapidMove(new Vector(0, -0.5))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Leadin }); + var corners = reversed + ? new[] { new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), Vector.Zero } + : new[] { new Vector(0, 10), new Vector(10, 10), new Vector(10, 0), Vector.Zero }; + foreach (var corner in corners) + pgm.Codes.Add(new LinearMove(corner) { Layer = LayerType.Cut }); + pgm.Mode = partMode; + + var plate = new Plate(60, 120); + plate.Parts.Add(new Part(new Drawing("square-hole", pgm), Location)); + var nest = new Nest("N"); + nest.Plates.Add(plate); + return nest; + } + + [Fact] + public void SquareWithHole_Inline_PostsTheExactProgram() + { + var output = CincinnatiReplay.Post(SquareWithHole(), partSubprograms: false); + + var body = output[output.IndexOf("GOTO1( Goto Feature )", StringComparison.Ordinal)..]; + Assert.Equal( + """ + GOTO1( Goto Feature ) + N1 G52 X6 Y7 + ( PART: square-hole ) + M98 P200 + G52 X0 Y0 + M47 + N1002 G0 X1 Y1.5 + (WARNING: No library found) + G84 + G41 G1 X1 Y2 F#126 + G1 X1 Y12 F#148 + G1 X11 Y12 + G1 X11 Y2 + G1 X1 Y2 + G40 + M35 + M42 + M99 (END OF N.001) + (*****************************************************) + :200 + ( PART: HOLE ) + N1 G0 X0 Y-0.6 + (WARNING: No library found) + G84 + G41 G1 X0 Y-0.5 F#126 + G3 X0 Y-0.5 I0 J0.5 F[#148*#128] + G40 + M35 + M99 (END OF HOLE) + + """, + body); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SquareWithHole_CutsTheSharedContoursOnTheSheet(bool partSubprograms) + { + var nest = SquareWithHole(); + var contours = PostContours.Extract(nest.Plates[0].Parts[0]); + + var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms)); + + AssertPoints(contours.Select(c => c.Pierce), run.PierceLog.Select(p => p.Point)); + var expected = contours.SelectMany(c => new[] { c.LeadIn }.Concat(c.Cuts)).ToList(); + Assert.Equal(expected.Count, run.Moves.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.True(CincinnatiReplay.Near(expected[i].EndPoint, run.Moves[i].To), + $"Move {i} ends at {run.Moves[i].To}, expected {expected[i].EndPoint}"); + if (expected[i] is ArcMove arc) + { + Assert.True(CincinnatiReplay.Near(arc.CenterPoint, run.Moves[i].Center!.Value)); + Assert.Equal(arc.Rotation == RotationType.CW, run.Moves[i].Clockwise); + } + } + } + + [Theory] + [InlineData(false, false, "G41")] + [InlineData(true, false, "G41")] + [InlineData(false, true, "G42")] + [InlineData(true, true, "G42")] + public void KerfSide_KeepsThePartOutOfTheKerf(bool partSubprograms, bool reversed, string expected) + { + var run = CincinnatiReplay.Run(CincinnatiReplay.Post(SquareWithHole(reversed: reversed), partSubprograms)); + + Assert.Equal(2, run.Pierces); + Assert.All(run.Moves, move => Assert.Equal(expected, move.Compensation)); + + // The first move after each pierce is the lead-in, which runs through scrap. + var checkedCuts = 0; + foreach (var move in run.Moves.Where((m, i) => i > 0 && run.Moves[i - 1].Pierce == m.Pierce)) + { + AssertPartOnCompensatedSide(move); + checkedCuts++; + } + Assert.Equal(5, checkedCuts); + } + + [Theory] + [InlineData(false, Mode.Absolute, Mode.Absolute)] + [InlineData(false, Mode.Incremental, Mode.Absolute)] + [InlineData(false, Mode.Incremental, Mode.Incremental)] + [InlineData(true, Mode.Absolute, Mode.Absolute)] + [InlineData(true, Mode.Incremental, Mode.Absolute)] + [InlineData(true, Mode.Incremental, Mode.Incremental)] + public void AbsoluteAndIncrementalPrograms_PostTheSameProgram(bool partSubprograms, Mode partMode, Mode holeMode) + { + var expected = WithoutDate(CincinnatiReplay.Post(SquareWithHole(Mode.Absolute, Mode.Incremental), partSubprograms)); + + var actual = WithoutDate(CincinnatiReplay.Post(SquareWithHole(partMode, holeMode), partSubprograms)); + + Assert.Equal(expected, actual); + } + + // The header's third line is the posting time. + private static string WithoutDate(string output) => + string.Join('\n', output.Split('\n').Where((_, i) => i != 2)); + + /// + /// Mid-line (or at an arc's start), the point just right of travel is in the part under G41 + /// and the point just left of it under G42; the other side is the kerf's. + /// + private static void AssertPartOnCompensatedSide(CincinnatiReplay.CutMove move) + { + Vector at, travel; + if (move.Center is { } center) + { + at = move.From; + var radial = move.From - center; + travel = move.Clockwise ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X); + } + else + { + at = (move.From + move.To) * 0.5; + travel = move.To - move.From; + } + + var length = System.Math.Sqrt(travel.X * travel.X + travel.Y * travel.Y); + var right = new Vector(travel.Y / length, -travel.X / length); + var partSide = move.Compensation == "G41" ? right : new Vector(-right.X, -right.Y); + + Assert.True(InPart(at + partSide * 0.01), $"Part is not on the compensated side at {at}"); + Assert.False(InPart(at - partSide * 0.01), $"Kerf side is in the part at {at}"); + } + + private static bool InPart(Vector p) => + p.X > Location.X && p.X < Location.X + 10 + && p.Y > Location.Y && p.Y < Location.Y + 10 + && p.DistanceTo(Location + new Vector(5, 5)) > 0.5; + + private static void AssertPoints(IEnumerable expected, IEnumerable actual) + { + var want = expected.ToList(); + var got = actual.ToList(); + Assert.Equal(want.Count, got.Count); + for (var i = 0; i < want.Count; i++) + Assert.True(CincinnatiReplay.Near(want[i], got[i]), $"Point {i} is {got[i]}, expected {want[i]}"); + } +} diff --git a/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs index 4cb4c062..67df6309 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs @@ -43,6 +43,12 @@ internal static class CincinnatiReplay /// Every cutting move, start to end, in machine coordinates. public List<(Vector From, Vector To)> Cuts { get; } = new(); + /// + /// Every cutting move with the pierce it follows (1-based), its arc centre (null for + /// a line), direction and the compensation in force: "G41", "G42" or null after G40. + /// + public List Moves { get; } = new(); + /// Head raises posted while the head was already raised. public int DoubleRaises { get; set; } @@ -50,6 +56,8 @@ internal static class CincinnatiReplay public int HeadDownRapids { get; set; } } + internal sealed record CutMove(int Pierce, Vector From, Vector To, Vector? Center, bool Clockwise, string? Compensation); + /// /// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames, /// G0-G3 under G90 with incremental I/J, and M47 head raises (a block-deleted @@ -74,6 +82,7 @@ internal static class CincinnatiReplay var cutSincePierce = false; string? raise = null; // raise posted since the last pierce var headDown = false; + string? compensation = null; var pc = 0; while (pc < lines.Count) @@ -89,6 +98,10 @@ internal static class CincinnatiReplay line = line[1..]; var words = Words(line); + if (line.Contains("G41", System.StringComparison.Ordinal)) + compensation = "G41"; + else if (line.Contains("G42", System.StringComparison.Ordinal)) + compensation = "G42"; if (words.TryGetValue("M", out var m)) { if (m == "30") @@ -127,6 +140,9 @@ internal static class CincinnatiReplay switch (g) { + case "40": + compensation = null; + break; case "84": case "85": run.Pierces++; @@ -150,9 +166,11 @@ internal static class CincinnatiReplay case "3": { var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"])); + Vector? arcCenter = null; if (g is "2" or "3") { var center = position + new Vector(Num(words["I"]), Num(words["J"])); + arcCenter = center; var radius = position.DistanceTo(center); Assert.True( System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3, @@ -167,7 +185,10 @@ internal static class CincinnatiReplay { cutSincePierce = true; if (pierced) + { run.Cuts.Add((position, target)); + run.Moves.Add(new CutMove(run.Pierces, position, target, arcCenter, g == "2", compensation)); + } } position = target; break; diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs index 39dbd8dc..3311976b 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs @@ -21,6 +21,12 @@ public sealed class FeatureContext /// True when this feature cuts the part's exterior (selects ExteriorM47). public bool IsExteriorFeature { get; set; } public bool IsEtch { get; set; } + + /// + /// Compensation that keeps the kerf in the scrap for the way this contour runs + /// (): Left posts G41, Right G42, None neither. + /// + public KerfType Kerf { get; set; } = KerfType.Left; public string LibraryFile { get; set; } = ""; public double CutDistance { get; set; } public double SheetDiagonal { get; set; } @@ -148,13 +154,9 @@ public sealed class CincinnatiFeatureWriter var sb = new StringBuilder(); // Kerf compensation on first cutting move (skip for etch) - if ( - !ctx.IsEtch - && !kerfEmitted - && _config.KerfCompensation == KerfMode.ControllerSide - ) + if (!kerfEmitted && Compensates(ctx)) { - sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 "); + sb.Append(ctx.Kerf == KerfType.Left ? "G41 " : "G42 "); kerfEmitted = true; } @@ -188,13 +190,9 @@ public sealed class CincinnatiFeatureWriter var sb = new StringBuilder(); // Kerf compensation on first cutting move (skip for etch) - if ( - !ctx.IsEtch - && !kerfEmitted - && _config.KerfCompensation == KerfMode.ControllerSide - ) + if (!kerfEmitted && Compensates(ctx)) { - sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 "); + sb.Append(ctx.Kerf == KerfType.Left ? "G41 " : "G42 "); kerfEmitted = true; } @@ -303,6 +301,9 @@ public sealed class CincinnatiFeatureWriter return null; } + private bool Compensates(FeatureContext ctx) => + !ctx.IsEtch && ctx.Kerf != KerfType.None && _config.KerfCompensation == KerfMode.ControllerSide; + private Vector FindPiercePoint(List codes) { foreach (var code in codes) diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs index 299713ba..75450ecd 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -42,6 +42,7 @@ public sealed class CincinnatiPartSubprogramWriter /// /// Writes a complete part sub-program for the given normalized program. /// The program must be in absolute mode with coordinates normalized to origin (0,0). + /// With every contour is a hole, as in a hole sub-program. /// public void Write( TextWriter w, @@ -50,7 +51,8 @@ public sealed class CincinnatiPartSubprogramWriter int subNumber, string cutLibrary, string etchLibrary, - double sheetDiagonal + double sheetDiagonal, + bool holes = false ) { var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes); @@ -60,6 +62,7 @@ public sealed class CincinnatiPartSubprogramWriter // Classify and order: etch features first, then cut features var ordered = FeatureUtils.ClassifyAndOrder(allFeatures); var exterior = FeatureUtils.FindExterior(ordered); + var kerf = FeatureUtils.KerfSides(ordered, holes); w.WriteLine("(*****************************************************)"); w.WriteLine($":{subNumber}"); @@ -90,6 +93,7 @@ public sealed class CincinnatiPartSubprogramWriter IsFirstFeatureOfPart = false, IsLastFeatureOnSheet = isLastFeature, IsExteriorFeature = exterior[i], + Kerf = kerf[i], IsEtch = isEtch, LibraryFile = isEtch ? etchLibrary : cutLibrary, CutDistance = cutDistance, diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs index 35811918..1e515367 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs @@ -18,18 +18,6 @@ namespace OpenNest.Posts.Cincinnati PreApplied, } - /// - /// Specifies which side of the cut line kerf compensation is applied to. - /// - public enum KerfSide - { - /// Kerf applied to the left side of the cut. - Left, - - /// Kerf applied to the right side of the cut. - Right, - } - /// /// Specifies how the M47 head raise before each rapid is posted. /// @@ -209,11 +197,6 @@ namespace OpenNest.Posts.Cincinnati [PostSetting(KerfSection, 0)] public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide; - [DisplayName("Default kerf side")] - [Description("Default side for kerf compensation (Left or Right).")] - [PostSetting(KerfSection, 1)] - public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left; - [DisplayName("Interior M47")] [Description("How M47 is posted after features other than a part's exterior, such as cutouts and etch.")] [PostSetting(HeadRaiseSection, 0)] diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index 5c76fa63..ccbd0392 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -250,7 +250,8 @@ namespace OpenNest.Posts.Cincinnati subNum, initialCutLibrary, etchLibrary, - sheetDiagonal + sheetDiagonal, + holes: true ); } } diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index a532bafb..7086ffe1 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -117,6 +117,7 @@ public sealed class CincinnatiSheetWriter public List Codes { get; init; } public bool IsEtch { get; init; } public bool IsExterior { get; init; } + public KerfType Kerf { get; init; } public PartSubprogram Subprogram { get; init; } /// First rapid target: the sub-program corner, else the first pierce. @@ -166,6 +167,7 @@ public sealed class CincinnatiSheetWriter var features = FeatureUtils.SplitAndClassify(part); var exterior = FeatureUtils.FindExterior(features); + var kerf = FeatureUtils.KerfSides(features); for (var f = 0; f < features.Count; f++) { var (codes, isEtch) = features[f]; @@ -178,6 +180,7 @@ public sealed class CincinnatiSheetWriter Codes = codes, IsEtch = isEtch, IsExterior = exterior[f], + Kerf = kerf[f], Entry = start, Start = start, End = end, @@ -261,6 +264,7 @@ public sealed class CincinnatiSheetWriter IsFirstFeatureOfPart = isFirstFeatureOfPart, IsLastFeatureOnSheet = isLastFeature, IsExteriorFeature = item.IsExterior, + Kerf = item.Kerf, IsEtch = item.IsEtch, LibraryFile = item.IsEtch ? etchLibrary : cutLibrary, CutDistance = FeatureUtils.ComputeCutDistance(codes), diff --git a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs index c8c5d9fd..ad71c580 100644 --- a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs +++ b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using OpenNest.CNC; +using OpenNest.CNC.Posting; using OpenNest.Geometry; using OpenNest.Math; @@ -239,6 +240,52 @@ public static class FeatureUtils return result; } + /// + /// Each feature's kerf side from Core's shared winding rule (): + /// the side that keeps the kerf in the scrap the way the contour actually runs. The part's + /// perimeter is picked across all its cut features; with every + /// feature is a hole, as in a hole sub-program. Etch features and hole calls get + /// : etch is never compensated and a hole sub-program picks its own. + /// + public static KerfType[] KerfSides( + IReadOnlyList<(List codes, bool isEtch)> features, + bool holes = false + ) + { + var contours = new List(); + var owners = new List(); + for (var i = 0; i < features.Count; i++) + { + var (codes, isEtch) = features[i]; + if (isEtch || (codes.Count == 1 && codes[0] is SubProgramCall)) + continue; + + var program = new Program(Mode.Absolute); + program.Codes.AddRange(codes); + foreach (var contour in PostContours.Flatten(program, Vector.Zero)) + { + contours.Add(contour); + owners.Add(i); + } + } + + if (holes) + { + foreach (var contour in contours) + contour.Kerf = ContourWinding.CompensationSide(contour, isPerimeter: false); + } + else + { + PostContours.Classify(contours); + } + + // A feature is one contour; its first contour decides if it ever splits. + var sides = new KerfType[features.Count]; + for (var k = contours.Count - 1; k >= 0; k--) + sides[owners[k]] = contours[k].Kerf; + return sides; + } + private static Box CutBounds(List codes) { var cut = new Program(Mode.Absolute); diff --git a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json index f771701f..86f555c4 100644 --- a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json +++ b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json @@ -15,7 +15,6 @@ "UseAntiDive": true, "UseSmartRapids": false, "KerfCompensation": "ControllerSide", - "DefaultKerfSide": "Left", "InteriorM47": "Always", "ExteriorM47": "Always", "M47OverrideDistanceThreshold": null, diff --git a/docs/cincinnati-post-output.md b/docs/cincinnati-post-output.md index de7f2fa0..cf9dd5c7 100644 --- a/docs/cincinnati-post-output.md +++ b/docs/cincinnati-post-output.md @@ -104,7 +104,13 @@ block in a sheet or sub-program output follows this order 5. `M130 (ANTI DIVE OFF)` — disable anti-dive, only if configured (§3.130). 6. Contour moves: - `G41` (left) or `G42` (right) kerf compensation on the first cut move - (§1.41 / §1.42), suppressed for etch features. + (§1.41 / §1.42), suppressed for etch features. The side comes from Core's + shared `PostContours` winding rule, the same one the CI Fiber post uses: + whichever keeps the kerf in the scrap for the way the contour runs. Imported + drawings (perimeter CW, holes CCW) get `G41` throughout; a contour wound the + other way gets `G42`. The perimeter is the part's cut feature with the + largest bounding box; contours in a hole sub-program are always holes. + Paths that enclose no area, such as cut-off lines, keep `G41`. - `G1 X_ Y_ [F]` — linear cut move (§1.01). Feedrate references a machine variable such as `#148` and is emitted only when it changes. - `G2 X_ Y_ I_ J_ [F]` (CW) or `G3` (CCW) — arc (§1.02 / §1.03).