From 2b3f8e7b526704e8b7c8bdee11a0553ccb2bc9f8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 01:28:42 -0400 Subject: [PATCH] fix(posts): Cincinnati CL sub-program frames, head raises and dead settings Part sub-program calls restored the sheet frame with G92 at the part corner while the tool sat at the sub-program's last endpoint, shifting every later part. The call now raises the head, rapids back to local 0,0 and restores there. Parts share a sub-program only when their posted toolpaths match, so per-part lead-ins are kept. Suppressed moves are left uncut: the cut resumes with a rapid past them. Head raises go before the rapid that leaves a feature, one per rapid: the safety raise now precedes the rapid to a new part instead of following its first feature, and sub-program mode no longer posts M47 then M47 P2000. M47Mode.Auto posts M47 before rapids longer than M47OverrideDistanceThreshold and /M47 otherwise. ExteriorM47 applies to the contour spanning the part's cut extents. The sheet GOTO targets FeatureLineNumberStart and is omitted without line numbers. PrepareForNest pre-selects only the gas- and thickness-matching library. Part programs are read through absolute copies, never switched to absolute in place. Removes the unimplemented UseSheetSubprograms, CoordModeBetweenParts and ProcessParameterMode (G89Mode); old configs still load. Co-Authored-By: Claude Opus 5.5 --- .../CincinnatiFeatureWriterTests.cs | 3 - .../CincinnatiPostRegressionTests.cs | 335 ++++++++++++++++ OpenNest.Tests/Cincinnati/CincinnatiReplay.cs | 207 ++++++++++ .../HoleCallOutputCharacterizationTests.cs | 31 +- .../HolePlacementRegressionTests.cs | 186 +-------- .../CincinnatiFeatureWriter.cs | 61 ++- .../CincinnatiHeadRaise.cs | 37 ++ .../CincinnatiPartSubprogramWriter.cs | 92 +++-- .../CincinnatiPostConfig.cs | 80 +--- .../CincinnatiPostProcessor.cs | 21 +- .../CincinnatiPreambleWriter.cs | 5 +- .../CincinnatiSheetWriter.cs | 371 +++++++++--------- .../OpenNest.Posts.Cincinnati/FeatureUtils.cs | 194 ++++++++- .../MaterialLibraryResolver.cs | 19 +- .../OpenNest.Posts.Cincinnati.json | 3 - docs/cincinnati-post-output.md | 102 ++++- 16 files changed, 1203 insertions(+), 544 deletions(-) create mode 100644 OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs create mode 100644 OpenNest.Tests/Cincinnati/CincinnatiReplay.cs create mode 100644 Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs diff --git a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs index 8475cf90..e003f8ae 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs @@ -14,7 +14,6 @@ public class CincinnatiFeatureWriterTests UseAntiDive = true, KerfCompensation = KerfMode.ControllerSide, DefaultKerfSide = KerfSide.Left, - ProcessParameterMode = G89Mode.LibraryFile, InteriorM47 = M47Mode.Always, ExteriorM47 = M47Mode.Always, UseSpeedGas = false, @@ -303,7 +302,6 @@ public class CincinnatiFeatureWriterTests public void G89_EmittedWithLibraryFile() { var config = DefaultConfig(); - config.ProcessParameterMode = G89Mode.LibraryFile; var ctx = SimpleContext(); ctx.LibraryFile = "MILD10"; ctx.CutDistance = 18.0; @@ -317,7 +315,6 @@ public class CincinnatiFeatureWriterTests public void G89_WarningEmittedWhenNoLibrary() { var config = DefaultConfig(); - config.ProcessParameterMode = G89Mode.LibraryFile; var ctx = SimpleContext(); ctx.LibraryFile = ""; var output = WriteFeature(config, ctx); diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs new file mode 100644 index 00000000..bfa9964f --- /dev/null +++ b/OpenNest.Tests/Cincinnati/CincinnatiPostRegressionTests.cs @@ -0,0 +1,335 @@ +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; +using static OpenNest.Tests.Cincinnati.CincinnatiReplay; + +namespace OpenNest.Tests.Cincinnati; + +/// +/// Posts off-origin parts of several drawings and replays the G-code to check what the +/// machine actually does: where it cuts, what it leaves uncut and where it raises the head. +/// +public class CincinnatiPostRegressionTests +{ + [Fact] + public void PartSubprograms_CutEveryPartWhereInlinePostingDoes() + { + // Outer square first, so each part's toolpath ends away from its corner. + var nest = SquaresNest( + OuterFirst, + ("A", new Vector(5, 5)), + ("B", new Vector(30, 20)), + ("A", new Vector(50, 60)) + ); + + var inline = Run(Post(nest, partSubprograms: false)); + var grouped = Run(Post(nest, partSubprograms: true)); + + var expected = nest.Plates[0].Parts + .SelectMany(p => new[] { p.Location, p.Location + new Vector(2, 2) }) + .ToList(); + AssertPoints(expected, inline.PierceLog.Select(p => p.Point).ToList()); + AssertSamePath(inline, grouped); + } + + [Fact] + public void PartSubprograms_PartsWithOwnLeadIns_PostTheirOwnToolpath() + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + nest.Plates.Add(plate); + var drawing = new Drawing("A") { Program = HoledSquare() }; + AddLeadInPart(plate, drawing, 0.25, new Vector(5, 5)); + AddLeadInPart(plate, drawing, 0.75, new Vector(20, 5)); + AddLeadInPart(plate, new Drawing("B") { Program = HoledSquare() }, 0.25, new Vector(5, 30)); + AddLeadInPart(plate, drawing, 0.25, new Vector(20, 30)); + + var output = Post(nest, partSubprograms: true); + + // Lead-ins differ between the two (drawing, rotation) twins; the identical one shares. + Assert.Equal(2, Count(output, "M99 (END OF A)")); + Assert.Equal(1, Count(output, "M99 (END OF B)")); + AssertSamePath(Run(Post(nest, partSubprograms: false)), Run(output)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SuppressedMoves_AreLeftUncut(bool partSubprograms) + { + var suppressed = SquaresProgram(); + ((Motion)suppressed.Codes[7]).Suppressed = true; // outer (10,0) -> (10,10) + var nest = new Nest("N"); + var plate = new Plate(48, 96); + nest.Plates.Add(plate); + var a = AddPart(plate, "A", suppressed, new Vector(5, 5)); + AddPart(plate, "B", SquaresProgram(), new Vector(30, 20)); + + var run = Run(Post(nest, partSubprograms)); + + Assert.DoesNotContain(run.Cuts, c => Near(c.To, a.Location + new Vector(10, 10)) && Near(c.From, a.Location + new Vector(10, 0))); + Assert.DoesNotContain(run.Cuts, c => Near(c.From, a.Location + new Vector(10, 0)) && !Near(c.To, a.Location + new Vector(10, 0))); + Assert.Contains(run.PierceLog, p => Near(p.Point, a.Location + new Vector(10, 10))); + Assert.Equal(15, run.Cuts.Count); + Assert.Equal(0, run.EmptyPierces); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SafetyHeadraise_IsTheOneRaiseBeforeTheRapidToADifferentPart(bool partSubprograms) + { + var nest = SquaresNest(("A", new Vector(5, 5)), ("A", new Vector(20, 5)), ("B", new Vector(40, 30))); + var b = nest.Plates[0].Parts[2]; + + var run = Run(Post(nest, partSubprograms)); + + Assert.Equal(0, run.DoubleRaises); + Assert.Equal(0, run.HeadDownRapids); + var safety = Assert.Single(run.PierceLog, p => p.Raise == "M47 P2000"); + Assert.True(Near(safety.Point, b.Location + new Vector(2, 2)), $"Safety raise before {safety.Point}"); + Assert.All(run.PierceLog.Where(p => p.Raise != "M47 P2000"), p => Assert.Equal("M47", p.Raise)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SheetGoto_TargetsThePostedFirstFeatureNumber(bool partSubprograms) + { + var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(30, 20))); + var config = Config(partSubprograms); + config.FeatureLineNumberStart = 5; + + var numbered = Sheet(Post(nest, config)); + config.UseLineNumbers = false; + var unnumbered = Sheet(Post(nest, config)); + + Assert.Contains("GOTO5( Goto Feature )", numbered); + Assert.Contains(numbered, l => l.StartsWith("N5 ", System.StringComparison.Ordinal)); + Assert.DoesNotContain(unnumbered, l => l.StartsWith("GOTO", System.StringComparison.Ordinal)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AutoM47_RaisesFullyOnlyBeforeLongRapids(bool partSubprograms) + { + var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(40, 30))); + var config = Config(partSubprograms); + config.InteriorM47 = M47Mode.Auto; + config.ExteriorM47 = M47Mode.Auto; + config.SafetyHeadraiseDistance = null; + config.M47OverrideDistanceThreshold = 5; + + var run = Run(Post(nest, config)); + config.M47OverrideDistanceThreshold = null; + var unlimited = Run(Post(nest, config)); + + // Pierces: header raise, A outer (short hop), B inner (long rapid), B outer (short hop). + Assert.Equal(new[] { "M47", "/M47", "M47", "/M47" }, run.PierceLog.Select(p => p.Raise)); + Assert.All(unlimited.PierceLog, p => Assert.Equal("M47", p.Raise)); + Assert.Equal(0, run.HeadDownRapids); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExteriorM47_AppliesAfterThePartExterior(bool partSubprograms) + { + var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(40, 30))); + var config = Config(partSubprograms); + config.InteriorM47 = M47Mode.BlockDelete; + config.ExteriorM47 = M47Mode.Always; + config.SafetyHeadraiseDistance = null; + + var run = Run(Post(nest, config)); + + Assert.Equal(new[] { "M47", "/M47", "M47", "/M47" }, run.PierceLog.Select(p => p.Raise)); + } + + [Theory] + [InlineData("N2", 0.25, "MS250N2")] + [InlineData("O2", 0.25, "MS250O2")] + [InlineData("N2", 0.30, "")] + [InlineData("O2", 0.188, "")] + public void PrepareForNest_SelectsOnlyTheGasAndThicknessMatch(string gas, double thickness, string expected) + { + var nest = SquaresNest(("A", new Vector(5, 5)), ("B", new Vector(30, 20))); + nest.Material = new Material("Carbon Steel"); + nest.Thickness = thickness; + nest.AssistGas = gas; + var config = Config(partSubprograms: false); + config.MaterialLibraries = new() + { + new() { Material = "Carbon Steel", Thickness = 0.25, Gas = "O2", Library = "MS250O2" }, + new() { Material = "Carbon Steel", Thickness = 0.25, Gas = "N2", Library = "MS250N2" }, + new() { Material = "Carbon Steel", Thickness = 0.188, Gas = "N2", Library = "MS188N2" }, + }; + var post = new CincinnatiPostProcessor(config); + + post.PrepareForNest(nest); + + Assert.Equal(expected, config.SelectedLibrary); + var libraries = Sheet(Post(nest, config)) + .Where(l => l.StartsWith("G89 P", System.StringComparison.Ordinal)) + .Select(l => l.Split(' ')[1]) + .Distinct() + .ToList(); + if (expected == "") + Assert.Empty(libraries); + else + Assert.Equal(new[] { $"P{expected}.lib" }, libraries); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IncrementalPartPrograms_PostWithoutChangingMode(bool partSubprograms) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + nest.Plates.Add(plate); + var large = HolePlacementRegressionTests.AddPart(plate, "Large", 0.5, new Vector(10, 20)); + var small = HolePlacementRegressionTests.AddPart(plate, "Small", 0.25, new Vector(30, 40)); + large.Program.Mode = Mode.Incremental; + small.Program.Mode = Mode.Incremental; + + var run = Run(Post(nest, partSubprograms)); + + Assert.Equal(Mode.Incremental, large.Program.Mode); + Assert.Equal(Mode.Incremental, small.Program.Mode); + HolePlacementRegressionTests.AssertHole(run, large, 0.5); + HolePlacementRegressionTests.AssertHole(run, small, 0.25); + } + + [Fact] + public void Config_WithRemovedSettings_StillLoads() + { + const string json = """ + { + "UseSheetSubprograms": true, + "CoordModeBetweenParts": "G92", + "ProcessParameterMode": "Explicit", + "InteriorM47": "Auto", + "M47OverrideDistanceThreshold": 2.5 + } + """; + var options = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }; + + var config = JsonSerializer.Deserialize(json, options)!; + + Assert.Equal(M47Mode.Auto, config.InteriorM47); + Assert.Equal(2.5, config.M47OverrideDistanceThreshold); + } + + // Inner square cut first, then the outer square: pierces at (2,2) and (0,0). + private static Program SquaresProgram() + { + var pgm = new Program(Mode.Absolute); + pgm.Codes.Add(new RapidMove(2, 2)); + pgm.Codes.Add(new LinearMove(4, 2)); + pgm.Codes.Add(new LinearMove(4, 4)); + pgm.Codes.Add(new LinearMove(2, 4)); + pgm.Codes.Add(new LinearMove(2, 2)); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(10, 0)); + pgm.Codes.Add(new LinearMove(10, 10)); + pgm.Codes.Add(new LinearMove(0, 10)); + pgm.Codes.Add(new LinearMove(0, 0)); + return pgm; + } + + private static Program OuterFirst() + { + var pgm = SquaresProgram(); + var inner = pgm.Codes.GetRange(0, 5); + pgm.Codes.RemoveRange(0, 5); + pgm.Codes.AddRange(inner); + return pgm; + } + + private static Program HoledSquare() + { + var pgm = new Program(Mode.Absolute); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(10, 0)); + pgm.Codes.Add(new LinearMove(10, 10)); + pgm.Codes.Add(new LinearMove(0, 10)); + pgm.Codes.Add(new LinearMove(0, 0)); + pgm.Codes.Add(new RapidMove(3.5, 3)); + pgm.Codes.Add(new ArcMove(new Vector(3.5, 3), new Vector(3, 3), RotationType.CW)); + return pgm; + } + + private static Nest SquaresNest(params (string Name, Vector Location)[] parts) => + SquaresNest(SquaresProgram, parts); + + private static Nest SquaresNest(Func program, params (string Name, Vector Location)[] parts) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + nest.Plates.Add(plate); + var drawings = new Dictionary(); + foreach (var (name, location) in parts) + { + if (!drawings.TryGetValue(name, out var drawing)) + drawings[name] = drawing = new Drawing(name) { Program = program() }; + plate.Parts.Add(new Part(drawing) { Location = location }); + } + return nest; + } + + private static Part AddPart(Plate plate, string name, Program program, Vector location) + { + var part = new Part(new Drawing(name) { Program = program }) { Location = location }; + plate.Parts.Add(part); + return part; + } + + private static void AddLeadInPart(Plate plate, Drawing drawing, double leadIn, Vector location) + { + var part = new Part(drawing); + part.ApplyLeadIns( + new CuttingParameters + { + ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ArcCircleLeadOut = new NoLeadOut(), + ExternalLeadIn = new LineLeadIn { Length = leadIn, ApproachAngle = 90 }, + ExternalLeadOut = new NoLeadOut(), + }, + new Vector(10, 10) + ); + part.Location = location; + plate.Parts.Add(part); + } + + private static List Sheet(string output) + { + var lines = output.Split('\n').Select(l => l.Trim()).ToList(); + var start = lines.IndexOf(":101"); + var end = lines.FindIndex(start, l => l.StartsWith("M99 (END OF", System.StringComparison.Ordinal)); + return lines.GetRange(start, end - start + 1); + } + + private static int Count(string output, string line) => + output.Split('\n').Count(l => l.Trim() == line); + + private static void AssertPoints(IReadOnlyList expected, IReadOnlyList actual) + { + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + Assert.True(Near(expected[i], actual[i]), $"Point {i}: expected {expected[i]}, got {actual[i]}"); + } + + private static void AssertSamePath(Result expected, Result actual) + { + AssertPoints(expected.PierceLog.Select(p => p.Point).ToList(), actual.PierceLog.Select(p => p.Point).ToList()); + AssertPoints(expected.Cuts.Select(c => c.From).ToList(), actual.Cuts.Select(c => c.From).ToList()); + AssertPoints(expected.Cuts.Select(c => c.To).ToList(), actual.Cuts.Select(c => c.To).ToList()); + Assert.Equal(expected.FullCircles.Count, actual.FullCircles.Count); + } +} diff --git a/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs new file mode 100644 index 00000000..4cb4c062 --- /dev/null +++ b/OpenNest.Tests/Cincinnati/CincinnatiReplay.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; + +namespace OpenNest.Tests.Cincinnati; + +/// +/// Posts nests with the Cincinnati CL post and replays the G-code to see where the +/// machine actually cuts. +/// +internal static class CincinnatiReplay +{ + /// A config whose output is easy to replay: no anti-dive, no pallet exchange. + internal static CincinnatiPostConfig Config(bool partSubprograms) => new() + { + PostedAccuracy = 4, + UsePartSubprograms = partSubprograms, + UseAntiDive = false, + PalletExchange = PalletMode.None, + }; + + internal static string Post(Nest nest, bool partSubprograms) => Post(nest, Config(partSubprograms)); + + internal static string Post(Nest nest, CincinnatiPostConfig config) + { + using var ms = new MemoryStream(); + new CincinnatiPostProcessor(config).Post(nest, ms); + return Encoding.UTF8.GetString(ms.ToArray()).Replace("\r", ""); + } + + internal sealed class Result + { + public List<(Vector Center, double Radius)> FullCircles { get; } = new(); + public int Pierces { get; set; } + public int EmptyPierces { get; set; } + + /// Every pierce with the head raise posted before it (null for none). + public List<(Vector Point, string? Raise)> PierceLog { get; } = new(); + + /// Every cutting move, start to end, in machine coordinates. + public List<(Vector From, Vector To)> Cuts { get; } = new(); + + /// Head raises posted while the head was already raised. + public int DoubleRaises { get; set; } + + /// Rapids taken after a cut with no head raise since. + public int HeadDownRapids { get; set; } + } + + /// + /// 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 + /// /M47 counts as a raise). Every arc must end on its own radius. + /// + internal static Result Run(string output) + { + var lines = output.Split('\n').Select(Clean).ToList(); + var labels = new Dictionary(); + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].StartsWith(':')) + labels[int.Parse(lines[i][1..], CultureInfo.InvariantCulture)] = i; + } + + var run = new Result(); + var position = Vector.Zero; // machine position + var g92 = Vector.Zero; // work origin in machine frame + var g52 = Vector.Zero; // local shift in the work frame + var returns = new Stack(); + var pierced = false; + var cutSincePierce = false; + string? raise = null; // raise posted since the last pierce + var headDown = false; + var pc = 0; + + while (pc < lines.Count) + { + var line = lines[pc++]; + if (line.Length == 0 || line.StartsWith(':') || line.StartsWith("GOTO", System.StringComparison.Ordinal)) + continue; + if (line.StartsWith("#", System.StringComparison.Ordinal)) + continue; + + var blockDelete = line.StartsWith('/'); + if (blockDelete) + line = line[1..]; + + var words = Words(line); + if (words.TryGetValue("M", out var m)) + { + if (m == "30") + break; + if (m == "98") + { + returns.Push(pc); + pc = labels[int.Parse(words["P"], CultureInfo.InvariantCulture)] + 1; + continue; + } + if (m == "99") + { + pc = returns.Pop(); + continue; + } + if (m is "35" or "135") + { + if (pierced && !cutSincePierce) + run.EmptyPierces++; + pierced = false; + } + if (m == "47") + { + if (raise != null) + run.DoubleRaises++; + raise = blockDelete ? "/" + line : line; + headDown = false; + } + if (m == "42") + headDown = false; + continue; + } + + if (!words.TryGetValue("G", out var g)) + continue; + + switch (g) + { + case "84": + case "85": + run.Pierces++; + run.PierceLog.Add((position, raise)); + raise = null; + pierced = true; + headDown = true; + cutSincePierce = false; + break; + case "52": + g52 = new Vector(Num(words["X"]), Num(words["Y"])); + break; + case "92": + g92 = words["X"].StartsWith('#') + ? Vector.Zero + : position - g52 - new Vector(Num(words["X"]), Num(words["Y"])); + break; + case "0": + case "1": + case "2": + case "3": + { + var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"])); + if (g is "2" or "3") + { + var center = position + new Vector(Num(words["I"]), Num(words["J"])); + var radius = position.DistanceTo(center); + Assert.True( + System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3, + $"Arc ends off its radius: {line}" + ); + if (target.DistanceTo(position) < 1e-6) + run.FullCircles.Add((center, radius)); + } + if (g == "0" && headDown) + run.HeadDownRapids++; + if (g != "0") + { + cutSincePierce = true; + if (pierced) + run.Cuts.Add((position, target)); + } + position = target; + break; + } + } + } + + return run; + } + + internal static bool Near(Vector a, Vector b) => a.DistanceTo(b) < 1e-3; + + private static string Clean(string line) + { + var text = line.Trim(); + var comment = text.IndexOf('('); + if (comment >= 0) + text = text[..comment].Trim(); + if (text.StartsWith('N')) + { + var space = text.IndexOf(' '); + text = space < 0 ? "" : text[(space + 1)..]; + } + return text; + } + + // Words in a block, keyed by letter; a leading G41/G42 gives way to the motion code after it. + private static Dictionary Words(string line) + { + var words = new Dictionary(); + foreach (var word in line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)) + words[word[..1]] = word[1..]; + return words; + } + + private static double Num(string value) => double.Parse(value, CultureInfo.InvariantCulture); +} diff --git a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs index 9eaba266..006d035e 100644 --- a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs +++ b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs @@ -24,7 +24,7 @@ public class HoleCallOutputCharacterizationTests var body = FirstHole(route, lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0"); - Assert.Equal(WrapOutput(route, body), output.ToString()); + Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString()); } [Theory] @@ -61,7 +61,7 @@ public class HoleCallOutputCharacterizationTests "M98 P0", "G52 X0 Y0" ); - Assert.Equal(WrapOutput(route, body), output.ToString()); + Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString()); } [Theory] @@ -92,6 +92,7 @@ public class HoleCallOutputCharacterizationTests if (route != "part") body += Lines("( PART: Sample )"); body += Lines( + "(WARNING: No library found)", "G84", "G1 X2 Y1 F#148", "G1 X1 Y1", @@ -102,12 +103,13 @@ public class HoleCallOutputCharacterizationTests "G52 X0 Y0", "M47", lineNumbers ? "N1003 G0 X3 Y3" : "G0 X3 Y3", + "(WARNING: No library found)", "G84", "G1 X4 Y3 F#148", "G1 X3 Y3", "M35" ); - Assert.Equal(WrapOutput(route, body), output.ToString()); + Assert.Equal(WrapOutput(route, body, lineNumbers ? 73 : null), output.ToString()); } [Theory] @@ -127,14 +129,14 @@ public class HoleCallOutputCharacterizationTests write(first, program); Assert.Equal( - WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")), + WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0"), 87), first.ToString() ); config.UseLineNumbers = false; using var second = new StringWriter { NewLine = "\n" }; write(second, program); Assert.Equal( - WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")), + WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0"), null), second.ToString() ); } @@ -152,7 +154,7 @@ public class HoleCallOutputCharacterizationTests var error = Assert.Throws(() => write(output, program)); Assert.Same(output.Failure, error); - Assert.Equal(Header(route) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString()); + Assert.Equal(Header(route, 73) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString()); } private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new() @@ -161,7 +163,6 @@ public class HoleCallOutputCharacterizationTests FeatureLineNumberStart = 73, UseLineNumbers = lineNumbers, PalletExchange = PalletMode.None, - ProcessParameterMode = G89Mode.Explicit, KerfCompensation = KerfMode.PreApplied, UseAntiDive = false, }; @@ -204,7 +205,7 @@ public class HoleCallOutputCharacterizationTests } var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), byProgram); - Dictionary<(int, long), int>? partMapping = route == "grouped-sheet" ? new() : null; + Dictionary? partMapping = route == "grouped-sheet" ? new() : null; return (output, program) => { var plate = new Plate(48, 96); @@ -234,15 +235,17 @@ public class HoleCallOutputCharacterizationTests } // Literal snapshots deliberately do not call the production emitter/formatter. - private static string WrapOutput(string route, string body) => Header(route) + body + + private static string WrapOutput(string route, string body, int? gotoTarget) => Header(route, gotoTarget) + body + (route == "part" ? Lines("M99 (END OF Sample)") : Lines("M42", "M99 (END OF Nest.001)")); - private static string Header(string route) + // The sheet's GOTO names the first feature's line number, posted only with line numbers. + private static string Header(string route, int? gotoTarget) { if (route == "part") return Lines("(*****************************************************)", ":201", "( PART: Sample )"); - return Lines( + var lines = new List + { "(*****************************************************)", "( START OF Nest.001 )", ":101", @@ -257,8 +260,10 @@ public class HoleCallOutputCharacterizationTests "M98 P100 (Variable Declaration)", "G90", "M47", - "GOTO1( Goto Feature )" - ); + }; + if (gotoTarget != null) + lines.Add($"GOTO{gotoTarget}( Goto Feature )"); + return Lines(lines.ToArray()); } // A sheet part that opens with a hole call names the part after the call's G52 line. diff --git a/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs index bf6d841d..ef7ac8bc 100644 --- a/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs +++ b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs @@ -1,7 +1,4 @@ -using System.Globalization; -using System.IO; using System.Linq; -using System.Text; using OpenNest.CNC; using OpenNest.CNC.CuttingStrategy; using OpenNest.Geometry; @@ -27,7 +24,7 @@ public class HolePlacementRegressionTests var small = AddPart(plate, "Small", 0.25, new Vector(30, 20)); nest.Plates.Add(plate); - var run = Replay(Post(nest, partSubprograms: false)); + var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: false)); AssertHole(run, large, 0.5); AssertHole(run, small, 0.25); @@ -46,7 +43,7 @@ public class HolePlacementRegressionTests nest.Plates.Add(first); nest.Plates.Add(second); - var run = Replay(Post(nest, partSubprograms: true)); + var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: true)); AssertHole(run, large, 0.5); AssertHole(run, small, 0.25); @@ -67,20 +64,17 @@ public class HolePlacementRegressionTests HoleCall(large).Id = 1; HoleCall(small).Id = 1; - var output = Post(nest, partSubprograms); + var output = CincinnatiReplay.Post(nest, partSubprograms); var holeSubs = output.Split('\n').Count(l => l.Trim() == "( PART: HOLE )"); Assert.Equal(2, holeSubs); - var run = Replay(output); + var run = CincinnatiReplay.Run(output); AssertHole(run, large, 0.5); - // Part sub-program calls restore the sheet frame with G92 at the part corner while - // the tool is elsewhere, shifting later parts on the sheet (a separate known defect). - if (!partSubprograms) - AssertHole(run, small, 0.25); + AssertHole(run, small, 0.25); } [Fact] - public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraise() + public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraiseBeforeItsRapid() { var nest = new Nest("N"); var plate = new Plate(48, 96); @@ -88,13 +82,15 @@ public class HolePlacementRegressionTests AddPart(plate, "Second", 0.5, new Vector(20, 0)); nest.Plates.Add(plate); - var lines = Post(nest, partSubprograms: false).Split('\n').Select(l => l.Trim()).ToList(); + var lines = CincinnatiReplay.Post(nest, partSubprograms: false).Split('\n').Select(l => l.Trim()).ToList(); var second = lines.IndexOf("( PART: Second )"); Assert.True(second > 0); Assert.EndsWith("G52 X23 Y3", lines[second - 1]); Assert.Single(lines, l => l == "( PART: First )"); - Assert.Equal("M47 P2000 (Safety Headraise)", lines[second + 3]); + // The safety raise ends the previous part, before the rapid to this one. + Assert.Equal("M47 P2000 (Safety Headraise)", lines[second - 2]); + Assert.Equal("M47", lines[second + 3]); } [Theory] @@ -108,7 +104,7 @@ public class HolePlacementRegressionTests nest.Plates.Add(plate); Assert.IsType(part.Program.Codes[0]); - var run = Replay(Post(nest, partSubprograms)); + var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms)); Assert.Equal(0, run.EmptyPierces); Assert.Equal(2, run.Pierces); @@ -126,7 +122,7 @@ public class HolePlacementRegressionTests var hole = HoleCall(part).Program; var before = hole.Codes.Count; - Post(nest, partSubprograms); + CincinnatiReplay.Post(nest, partSubprograms); Assert.Equal(Mode.Incremental, hole.Mode); Assert.Equal(before, hole.Codes.Count); @@ -145,7 +141,7 @@ public class HolePlacementRegressionTests AddPart(plate, "Second", 0.5, new Vector(20, 0)); nest.Plates.Add(plate); - var output = Post(nest, partSubprograms); + var output = CincinnatiReplay.Post(nest, partSubprograms); var sheet = output[output.IndexOf(":101", System.StringComparison.Ordinal)..]; var order = sheet @@ -157,7 +153,7 @@ public class HolePlacementRegressionTests Assert.Equal(new[] { "( PART: Third )", "( PART: First )", "( PART: Second )" }, order); } - private static Part AddPart(Plate plate, string name, double holeRadius, Vector location) + internal static Part AddPart(Plate plate, string name, double holeRadius, Vector location) { var pgm = new Program(Mode.Absolute); pgm.Codes.Add(new RapidMove(0, 0)); @@ -186,10 +182,10 @@ public class HolePlacementRegressionTests return part; } - private static SubProgramCall HoleCall(Part part) => + internal static SubProgramCall HoleCall(Part part) => part.Program.Codes.OfType().Single(); - private static void AssertHole(Replayed run, Part part, double radius) + internal static void AssertHole(CincinnatiReplay.Result run, Part part, double radius) { var expected = part.Location + HoleCenter; Assert.Contains( @@ -197,154 +193,4 @@ public class HolePlacementRegressionTests c => c.Center.DistanceTo(expected) < 1e-3 && System.Math.Abs(c.Radius - radius) < 1e-3 ); } - - private static string Post(Nest nest, bool partSubprograms) - { - var config = new CincinnatiPostConfig - { - PostedAccuracy = 4, - UsePartSubprograms = partSubprograms, - ProcessParameterMode = G89Mode.Explicit, - UseAntiDive = false, - PalletExchange = PalletMode.None, - }; - using var ms = new MemoryStream(); - new CincinnatiPostProcessor(config).Post(nest, ms); - return Encoding.UTF8.GetString(ms.ToArray()).Replace("\r", ""); - } - - private sealed class Replayed - { - public List<(Vector Center, double Radius)> FullCircles { get; } = new(); - public int Pierces { get; set; } - public int EmptyPierces { get; set; } - } - - /// - /// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames, - /// G0-G3 under G90 with incremental I/J. Every arc must end on its own radius. - /// - private static Replayed Replay(string output) - { - var lines = output.Split('\n').Select(Clean).ToList(); - var labels = new Dictionary(); - for (var i = 0; i < lines.Count; i++) - { - if (lines[i].StartsWith(':')) - labels[int.Parse(lines[i][1..], CultureInfo.InvariantCulture)] = i; - } - - var run = new Replayed(); - var position = Vector.Zero; // machine position - var g92 = Vector.Zero; // work origin in machine frame - var g52 = Vector.Zero; // local shift in the work frame - var returns = new Stack(); - var pierced = false; - var cutSincePierce = false; - var pc = 0; - - while (pc < lines.Count) - { - var line = lines[pc++]; - if (line.Length == 0 || line.StartsWith(':') || line.StartsWith("GOTO", System.StringComparison.Ordinal)) - continue; - if (line.StartsWith("#", System.StringComparison.Ordinal) || line.StartsWith("/", System.StringComparison.Ordinal)) - continue; - - var words = Words(line); - if (words.TryGetValue("M", out var m)) - { - if (m == "30") - break; - if (m == "98") - { - returns.Push(pc); - pc = labels[int.Parse(words["P"], CultureInfo.InvariantCulture)] + 1; - continue; - } - if (m == "99") - { - pc = returns.Pop(); - continue; - } - if (m is "35" or "135") - { - if (pierced && !cutSincePierce) - run.EmptyPierces++; - pierced = false; - } - continue; - } - - if (!words.TryGetValue("G", out var g)) - continue; - - switch (g) - { - case "84": - case "85": - run.Pierces++; - pierced = true; - cutSincePierce = false; - break; - case "52": - g52 = new Vector(Num(words["X"]), Num(words["Y"])); - break; - case "92": - g92 = words["X"].StartsWith('#') - ? Vector.Zero - : position - g52 - new Vector(Num(words["X"]), Num(words["Y"])); - break; - case "0": - case "1": - case "2": - case "3": - { - var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"])); - if (g is "2" or "3") - { - var center = position + new Vector(Num(words["I"]), Num(words["J"])); - var radius = position.DistanceTo(center); - Assert.True( - System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3, - $"Arc ends off its radius: {line}" - ); - if (target.DistanceTo(position) < 1e-6) - run.FullCircles.Add((center, radius)); - } - if (g != "0") - cutSincePierce = true; - position = target; - break; - } - } - } - - return run; - } - - private static string Clean(string line) - { - var text = line.Trim(); - var comment = text.IndexOf('('); - if (comment >= 0) - text = text[..comment].Trim(); - if (text.StartsWith('N')) - { - var space = text.IndexOf(' '); - text = space < 0 ? "" : text[(space + 1)..]; - } - return text; - } - - // Words in a block, keyed by letter; a leading G41/G42 gives way to the motion code after it. - private static Dictionary Words(string line) - { - var words = new Dictionary(); - foreach (var word in line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)) - words[word[..1]] = word[1..]; - return words; - } - - private static double Num(string value) => double.Parse(value, CultureInfo.InvariantCulture); } diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs index 034b3538..0209644a 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs @@ -17,13 +17,26 @@ public sealed class FeatureContext public string PartName { get; set; } = ""; public bool IsFirstFeatureOfPart { get; set; } public bool IsLastFeatureOnSheet { get; set; } + + /// + /// True when the rapid after this feature goes to a different part, so its head + /// raise is the safety head raise. + /// public bool IsSafetyHeadraise { get; set; } + + /// True when this feature cuts the part's exterior (selects ExteriorM47). public bool IsExteriorFeature { get; set; } public bool IsEtch { get; set; } public string LibraryFile { get; set; } = ""; public double CutDistance { get; set; } public double SheetDiagonal { get; set; } + /// + /// Rapid travel from this feature's end to the next pierce, used by + /// . Null when unknown. + /// + public double? RapidDistance { get; set; } + /// /// Part location on the plate. Added to all output X/Y coordinates /// so part-relative programs become plate-absolute under G90. @@ -93,19 +106,16 @@ public sealed class CincinnatiFeatureWriter writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}")); // 3. G89 process params - if (_config.ProcessParameterMode == G89Mode.LibraryFile) + var lib = ctx.LibraryFile; + if (!string.IsNullOrEmpty(lib)) { - var lib = ctx.LibraryFile; - if (!string.IsNullOrEmpty(lib)) - { - var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal); - var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal); - writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})"); - } - else - { - writer.WriteLine("(WARNING: No library found)"); - } + var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal); + var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal); + writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})"); + } + else + { + writer.WriteLine("(WARNING: No library found)"); } // 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut @@ -322,25 +332,14 @@ public sealed class CincinnatiFeatureWriter private void WriteM47(TextWriter writer, FeatureContext ctx) { - if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue) - { - writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)"); - return; - } - - var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47; - - switch (mode) - { - case M47Mode.Always: - writer.WriteLine("M47"); - break; - case M47Mode.BlockDelete: - writer.WriteLine("/M47"); - break; - case M47Mode.None: - break; - } + var line = CincinnatiHeadRaise.Line( + _config, + ctx.IsSafetyHeadraise, + ctx.IsExteriorFeature, + ctx.RapidDistance + ); + if (line != null) + writer.WriteLine(line); } private static string GetLinearFeedVariable(LayerType layer) diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs new file mode 100644 index 00000000..173f82d7 --- /dev/null +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHeadRaise.cs @@ -0,0 +1,37 @@ +namespace OpenNest.Posts.Cincinnati; + +/// +/// Chooses the M47 head raise (§3.47) written before the rapid that leaves a feature. +/// +internal static class CincinnatiHeadRaise +{ + /// True when the rapid goes to a different part. + /// True when the feature just cut is a part exterior. + /// Travel to the next pierce; null when unknown. + /// The block to write, or null for no head raise. + internal static string Line( + CincinnatiPostConfig config, + bool isSafetyHeadraise, + bool isExterior, + double? rapidDistance + ) + { + if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue) + return $"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)"; + + var mode = isExterior ? config.ExteriorM47 : config.InteriorM47; + return mode switch + { + M47Mode.Always => "M47", + M47Mode.BlockDelete => "/M47", + M47Mode.Auto => IsLongRapid(config, rapidDistance) ? "M47" : "/M47", + _ => null, + }; + } + + // Unknown travel, or no threshold configured, always gets the full raise. + private static bool IsLongRapid(CincinnatiPostConfig config, double? rapidDistance) => + config.M47OverrideDistanceThreshold is not { } threshold + || rapidDistance is not { } distance + || distance > threshold; +} diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs index f862b538..f409ab2c 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -7,10 +7,18 @@ using OpenNest.Geometry; namespace OpenNest.Posts.Cincinnati; +/// +/// A posted part sub-program and where one part's program frame puts its origin. +/// +/// Posted sub-program number. +/// The normalized, absolute program the sub-program is written from. +/// The sub-program origin in the part's program frame. +public sealed record PartSubprogram(int Number, Program Program, Vector Corner); + /// /// Writes a Cincinnati-format part sub-program definition. -/// Each sub-program contains the complete cutting sequence for one unique part geometry -/// (drawing + rotation), with coordinates normalized to origin (0,0). +/// Each sub-program contains the complete cutting sequence for one unique posted part +/// toolpath, with coordinates normalized to origin (0,0). /// Called via M98 from sheet sub-programs. /// public sealed class CincinnatiPartSubprogramWriter @@ -51,6 +59,7 @@ public sealed class CincinnatiPartSubprogramWriter // Classify and order: etch features first, then cut features var ordered = FeatureUtils.ClassifyAndOrder(allFeatures); + var exterior = FeatureUtils.FindExterior(ordered); w.WriteLine("(*****************************************************)"); w.WriteLine($":{subNumber}"); @@ -81,11 +90,14 @@ public sealed class CincinnatiPartSubprogramWriter IsFirstFeatureOfPart = false, IsLastFeatureOnSheet = isLastFeature, IsSafetyHeadraise = false, - IsExteriorFeature = false, + IsExteriorFeature = exterior[i], IsEtch = isEtch, LibraryFile = isEtch ? etchLibrary : cutLibrary, CutDistance = cutDistance, SheetDiagonal = sheetDiagonal, + RapidDistance = isLastFeature + ? null + : FeatureUtils.EndPoint(codes).DistanceTo(FeatureUtils.StartPoint(ordered[i + 1].codes)), }; _featureWriter.Write(w, ctx); @@ -102,22 +114,19 @@ public sealed class CincinnatiPartSubprogramWriter internal static void EnsureLeadingRapid(Program pgm) => FeatureUtils.InsertLeadingRapid(pgm.Codes); /// - /// Creates a sub-program key for matching parts to their sub-programs. - /// - internal static (int drawingId, long rotationKey) SubprogramKey(Part part) => - (part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6)); - - /// - /// Scans all plates and builds a mapping of unique part geometries to sub-program numbers, - /// along with their normalized programs for writing. + /// Scans all plates and assigns each non-cutoff part the sub-program for its posted + /// toolpath: parts share a sub-program only when their normalized programs, lead-ins + /// included, post identically under the same drawing name. Part programs are copied, + /// never modified. /// internal static ( - Dictionary<(int, long), int> mapping, + Dictionary mapping, List<(int subNum, string name, Program program)> entries ) BuildRegistry(IEnumerable plates, int startNumber) { - var mapping = new Dictionary<(int, long), int>(); + var mapping = new Dictionary(ReferenceEqualityComparer.Instance); var entries = new List<(int, string, Program)>(); + var contentIndex = new Dictionary(); var nextSubNum = startNumber; foreach (var plate in plates) @@ -126,32 +135,44 @@ public sealed class CincinnatiPartSubprogramWriter { if (part.BaseDrawing.IsCutOff) continue; - var key = SubprogramKey(part); - if (!mapping.ContainsKey(key)) + + var pgm = PostedCopy(part.Program); + var bbox = pgm.BoundingBox(); + pgm.Offset(-bbox.Location.X, -bbox.Location.Y); + + // If the program has no leading rapid, the feature writer + // will use the first motion endpoint as the pierce point, + // losing the first contour segment. Insert a synthetic rapid + // at the contour's return point (last motion endpoint) so + // the full contour is preserved. + EnsureLeadingRapid(pgm); + + var name = part.BaseDrawing.Name; + var key = name + "\n" + ProgramToCanonical(pgm); + if (!contentIndex.TryGetValue(key, out var entry)) { - var subNum = nextSubNum++; - mapping[key] = subNum; - - var pgm = part.Program.Clone() as Program; - pgm.Mode = Mode.Absolute; - var bbox = pgm.BoundingBox(); - pgm.Offset(-bbox.Location.X, -bbox.Location.Y); - - // If the program has no leading rapid, the feature writer - // will use the first motion endpoint as the pierce point, - // losing the first contour segment. Insert a synthetic rapid - // at the contour's return point (last motion endpoint) so - // the full contour is preserved. - EnsureLeadingRapid(pgm); - - entries.Add((subNum, part.BaseDrawing.Name, pgm)); + entry = (nextSubNum++, pgm); + contentIndex[key] = entry; + entries.Add((entry.subNum, name, pgm)); } + + mapping[part] = new PartSubprogram(entry.subNum, entry.program, bbox.Location); } } return (mapping, entries); } + // An absolute copy with suppressed moves left uncut, as the post writes it. + private static Program PostedCopy(Program program) + { + var copy = FeatureUtils.ToAbsoluteCopy(program); + var codes = FeatureUtils.RemoveSuppressed(copy.Codes); + copy.Codes.Clear(); + copy.Codes.AddRange(codes); + return copy; + } + /// /// Builds a nest-level registry of unique hole sub-programs from every hole call in /// the plates' parts and in the part sub-program copies. Calls are keyed by their @@ -179,10 +200,10 @@ public sealed class CincinnatiPartSubprogramWriter { if (code is not SubProgramCall { Program: not null } call) continue; - if (mapping.ContainsKey(call.Program)) + if (mapping.ContainsKey(call.Program) || !FeatureUtils.HasCut(call.Program)) continue; - var absolute = FeatureUtils.ToAbsoluteCopy(call.Program); + var absolute = PostedCopy(call.Program); EnsureLeadingRapid(absolute); var canonical = ProgramToCanonical(absolute); @@ -232,6 +253,11 @@ public sealed class CincinnatiPartSubprogramWriter ); else if (code is RapidMove rm) sb.Append(CultureInfo.InvariantCulture, $"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}"); + else if (code is SubProgramCall { Program: not null } call) + sb.Append( + CultureInfo.InvariantCulture, + $"S{call.Offset.X:F6},{call.Offset.Y:F6}[{ProgramToCanonical(PostedCopy(call.Program))}]" + ); } return sb.ToString(); } diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs index 64258d72..0d7fd7e6 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs @@ -5,33 +5,6 @@ using System.Linq; namespace OpenNest.Posts.Cincinnati { - /// - /// Specifies how coordinate positioning is handled between parts. - /// - public enum CoordinateMode - { - /// Set absolute position. - G92, - - /// Use relative/incremental positioning. - G91, - - /// Use machine coordinate system. - G53, - } - - /// - /// Specifies how G89 (hole drilling/tapping parameters) are provided. - /// - public enum G89Mode - { - /// Use external library file for G89 parameters. - LibraryFile, - - /// Explicitly define G89 parameters in the program. - Explicit, - } - /// /// Specifies where kerf compensation is applied. /// @@ -57,17 +30,20 @@ namespace OpenNest.Posts.Cincinnati } /// - /// Specifies how M47 (optional stop) commands are used. + /// Specifies how the M47 head raise before each rapid is posted. /// public enum M47Mode { /// Always include M47. Always, - /// Include M47 with block delete functionality. + /// Post /M47, which the operator can skip with block delete. BlockDelete, - /// Automatically determine M47 placement. + /// + /// M47 before rapids longer than the M47 override distance threshold, /M47 before + /// shorter ones. Without a threshold every raise is M47. + /// Auto, /// Do not use M47. @@ -120,11 +96,6 @@ namespace OpenNest.Posts.Cincinnati [Description("Starting line number for features.")] public int FeatureLineNumberStart { get; set; } = 1; - [Category("2. Subprograms")] - [DisplayName("Use Sheet Subprograms")] - [Description("Use subprograms for sheet operations.")] - public bool UseSheetSubprograms { get; set; } = true; - [Category("2. Subprograms")] [DisplayName("Sheet Subprogram Start")] [Description("Starting subprogram number for sheet operations.")] @@ -147,16 +118,6 @@ namespace OpenNest.Posts.Cincinnati [Description("Subprogram number for variable declarations.")] public int VariableDeclarationSubprogram { get; set; } = 100; - [Category("3. Positioning")] - [DisplayName("Coordinate Mode Between Parts")] - [Description("How coordinate positioning is handled between parts (G92, G91, or G53).")] - public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92; - - [Category("4. Process")] - [DisplayName("Process Parameter Mode")] - [Description("How G89 parameters are provided (LibraryFile or Explicit).")] - public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile; - [Category("4. Process")] [DisplayName("Default Assist Gas")] [Description("Default assist gas when Nest.AssistGas is empty.")] @@ -199,22 +160,28 @@ namespace OpenNest.Posts.Cincinnati [Category("6. M47 (Optional Stop)")] [DisplayName("Interior M47")] - [Description("How M47 is used in interior cuts.")] + [Description("How M47 is posted after features other than a part's exterior, such as cutouts and etch.")] public M47Mode InteriorM47 { get; set; } = M47Mode.Always; [Category("6. M47 (Optional Stop)")] [DisplayName("Exterior M47")] - [Description("How M47 is used in exterior cuts.")] + [Description( + "How M47 is posted after a part's exterior: the cut contour spanning the part's cut extents, or a part sub-program call." + )] public M47Mode ExteriorM47 { get; set; } = M47Mode.Always; [Category("6. M47 (Optional Stop)")] [DisplayName("M47 Override Distance Threshold")] - [Description("Distance threshold for M47 override. Null = no override.")] + [Description( + "Auto M47 mode: rapids to the next pierce longer than this get M47, shorter ones /M47. Program units. Null = Auto always posts M47." + )] public double? M47OverrideDistanceThreshold { get; set; } = null; [Category("7. Safety")] [DisplayName("Safety Headraise Distance")] - [Description("Safety head raise distance in machine units. Null = disabled.")] + [Description( + "Safety head raise distance in machine units, posted as M47 P before the rapid to a different part. Null = disabled." + )] public int? SafetyHeadraiseDistance { get; set; } = 2000; [Category("8. Pallet")] @@ -309,21 +276,6 @@ namespace OpenNest.Posts.Cincinnati )] [TypeConverter(typeof(MaterialLibraryNameConverter))] public string SelectedLibrary { get; set; } = ""; - - public string FindBestLibrary(string materialName, double thickness) - { - if (MaterialLibraries == null || string.IsNullOrEmpty(materialName)) - return ""; - - return MaterialLibraries - .Where(e => - string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) - ) - .OrderBy(e => System.Math.Abs(e.Thickness - thickness)) - .Select(e => e.Library) - .FirstOrDefault() - ?? ""; - } } public class MaterialLibraryEntry diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index 08f1e884..14aea7ca 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -38,11 +38,24 @@ namespace OpenNest.Posts.Cincinnati .Where(s => !string.IsNullOrWhiteSpace(s)); } + /// + /// Pre-selects the library auto-resolution would post for this nest (material, + /// thickness within tolerance and assist gas), so the configuration dialog shows + /// it. With no matching entry the selection is cleared rather than guessed. + /// public void PrepareForNest(Nest nest) { - var materialName = nest?.Material?.Name ?? ""; - var thickness = nest?.Thickness ?? 0.0; - Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness); + Config.SelectedLibrary = ""; + if (nest == null) + return; + + var entry = MaterialLibraryResolver.FindCutEntry( + Config.MaterialLibraries, + nest.Material?.Name ?? "", + nest.Thickness, + MaterialLibraryResolver.ResolveGas(nest, Config) + ); + Config.SelectedLibrary = entry?.Library ?? ""; } public CincinnatiPostProcessor() @@ -105,7 +118,7 @@ namespace OpenNest.Posts.Cincinnati ); // 5. Build part sub-program registry (if enabled) - Dictionary<(int, long), int> partSubprograms = null; + Dictionary partSubprograms = null; List<(int subNum, string name, Program program)> subprogramEntries = null; if (Config.UsePartSubprograms) diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs index 2dc34f26..120ee1a3 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs @@ -57,10 +57,7 @@ public sealed class CincinnatiPreambleWriter w.WriteLine("M42"); - if ( - _config.ProcessParameterMode == G89Mode.LibraryFile - && !string.IsNullOrEmpty(initialLibrary) - ) + if (!string.IsNullOrEmpty(initialLibrary)) w.WriteLine($"G89 P{initialLibrary}"); w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)"); diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index 9ffa8a11..959f5f6b 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -1,9 +1,9 @@ -using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using OpenNest.CNC; +using OpenNest.Geometry; namespace OpenNest.Posts.Cincinnati; @@ -38,8 +38,8 @@ public sealed class CincinnatiSheetWriter /// Resolved G89 library file for cut operations. /// Resolved G89 library file for etch operations. /// - /// Optional mapping of (drawingId, rotationKey) to sub-program number. - /// When provided, non-cutoff parts are emitted as M98 calls instead of inline features. + /// Optional mapping of parts (by reference) to their posted part sub-programs. + /// When provided, mapped non-cutoff parts are emitted as M98 calls instead of inline features. /// public void Write( TextWriter w, @@ -49,7 +49,7 @@ public sealed class CincinnatiSheetWriter int subNumber, string cutLibrary, string etchLibrary, - Dictionary<(int, long), int> partSubprograms = null, + IReadOnlyDictionary partSubprograms = null, Dictionary<(int drawingId, string varName), int> userVarMapping = null ) { @@ -87,36 +87,18 @@ public sealed class CincinnatiSheetWriter w.WriteLine("M47"); if (!string.IsNullOrEmpty(cutLibrary)) w.WriteLine($"G89 P{cutLibrary}"); - w.WriteLine("GOTO1( Goto Feature )"); + + // The operator edits this target to restart at a feature. It names the first + // feature's line number, which exists only when line numbers are posted. + if (_config.UseLineNumbers) + w.WriteLine($"GOTO{_config.FeatureLineNumberStart}( Goto Feature )"); // 3. Plate order is the cut sequence, cut-offs included: manual, cut-first // and automatic sequencing all set it, so the post must not reorder. - var allParts = plate.Parts.ToList(); + var items = BuildItems(plate.Parts, partSubprograms); // 4. Emit parts - if (partSubprograms != null) - WritePartsWithSubprograms( - w, - allParts, - cutLibrary, - etchLibrary, - sheetDiagonal, - width, - length, - partSubprograms, - userVarMapping - ); - else - WritePartsInline( - w, - allParts, - cutLibrary, - etchLibrary, - sheetDiagonal, - width, - length, - userVarMapping - ); + WriteItems(w, items, cutLibrary, etchLibrary, sheetDiagonal, width, length, userVarMapping); // 5. Footer w.WriteLine("M42"); @@ -125,182 +107,135 @@ public sealed class CincinnatiSheetWriter w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})"); } - private void WritePartsWithSubprograms( + /// + /// One posted step of the sheet: a part feature, a hole call or a part sub-program + /// call. Points are in sheet coordinates. + /// + private sealed class SheetItem + { + public Part Part { get; init; } + public List Codes { get; init; } + public bool IsEtch { get; init; } + public bool IsExterior { get; init; } + public PartSubprogram Subprogram { get; init; } + + /// First rapid target: the sub-program corner, else the first pierce. + public Vector Entry { get; init; } + + public Vector Start { get; init; } + public Vector End { get; init; } + + /// Where the tool leaves from: back at the corner after a sub-program call. + public Vector Exit { get; init; } + } + + private static List BuildItems( + IEnumerable parts, + IReadOnlyDictionary partSubprograms + ) + { + var items = new List(); + foreach (var part in parts) + { + if ( + partSubprograms != null + && !part.BaseDrawing.IsCutOff + && partSubprograms.TryGetValue(part, out var sub) + ) + { + var subFeatures = FeatureUtils.ClassifyAndOrder( + FeatureUtils.SplitByRapids(sub.Program.Codes) + ); + if (subFeatures.Count == 0) + continue; + + var corner = part.Location + sub.Corner; + items.Add( + new SheetItem + { + Part = part, + Subprogram = sub, + Entry = corner, + Start = corner + FeatureUtils.StartPoint(subFeatures[0].codes), + End = corner + FeatureUtils.EndPoint(subFeatures[^1].codes), + Exit = corner, + } + ); + continue; + } + + var features = FeatureUtils.SplitAndClassify(part); + var exterior = FeatureUtils.FindExterior(features); + for (var f = 0; f < features.Count; f++) + { + var (codes, isEtch) = features[f]; + var start = part.Location + FeatureUtils.StartPoint(codes); + var end = part.Location + FeatureUtils.EndPoint(codes); + items.Add( + new SheetItem + { + Part = part, + Codes = codes, + IsEtch = isEtch, + IsExterior = exterior[f], + Entry = start, + Start = start, + End = end, + Exit = end, + } + ); + } + } + return items; + } + + private void WriteItems( TextWriter w, - List allParts, + List items, string cutLibrary, string etchLibrary, double sheetDiagonal, double plateWidth, double plateLength, - Dictionary<(int, long), int> partSubprograms, Dictionary<(int drawingId, string varName), int> userVarMapping ) { var lastPartName = ""; - var featureIndex = 0; - - for (var p = 0; p < allParts.Count; p++) + for (var i = 0; i < items.Count; i++) { - var part = allParts[p]; + var item = items[i]; + var part = item.Part; var partName = part.BaseDrawing.Name; - var isNewPart = partName != lastPartName; - var isSafetyHeadraise = isNewPart && lastPartName != ""; - var isLastPart = p == allParts.Count - 1; + var isFirstFeatureOfPart = partName != lastPartName; + var next = i + 1 < items.Count ? items[i + 1] : null; + var isLastFeature = next == null; - var key = CincinnatiPartSubprogramWriter.SubprogramKey(part); - partSubprograms.TryGetValue(key, out var subNum); - var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0; + // A head raise belongs before the rapid that leaves this item, so the safety + // raise goes on a part's last item when a different part follows. + var isSafetyHeadraise = next != null && next.Part.BaseDrawing.Name != partName; + double? rapidDistance = + next == null + ? null + : item.End.DistanceTo(item.Exit) + + item.Exit.DistanceTo(next.Entry) + + next.Entry.DistanceTo(next.Start); + lastPartName = partName; - if (hasSubprogram) + if (item.Subprogram != null) { WriteSubprogramCall( w, - part, - subNum, - featureIndex, + item, + i, partName, isSafetyHeadraise, - isLastPart + rapidDistance, + isLastFeature ); - featureIndex++; - } - else - { - // Inline features for cutoffs or parts without sub-programs - var features = FeatureUtils.SplitAndClassify(part); - for (var f = 0; f < features.Count; f++) - { - var (codes, isEtch) = features[f]; - var isLastFeature = isLastPart && f == features.Count - 1; - - // SubProgramCall features are emitted as M98 hole calls - if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) - { - CincinnatiHoleCallWriter.Write( - w, - holeCall, - part.Location, - featureIndex, - isLastFeature, - _config, - _fmt, - _holeSubprograms, - isNewPart && f == 0 ? partName : null, - isSafetyHeadraise && f == 0 - ); - featureIndex++; - lastPartName = partName; - continue; - } - - var featureNumber = - featureIndex == 0 - ? _config.FeatureLineNumberStart - : 1000 + featureIndex + 1; - - var cutDistance = FeatureUtils.ComputeCutDistance(codes); - - var ctx = new FeatureContext - { - Codes = codes, - FeatureNumber = featureNumber, - PartName = partName, - IsFirstFeatureOfPart = isNewPart && f == 0, - IsLastFeatureOnSheet = isLastFeature, - IsSafetyHeadraise = isSafetyHeadraise && f == 0, - IsExteriorFeature = false, - IsEtch = isEtch, - LibraryFile = isEtch ? etchLibrary : cutLibrary, - CutDistance = cutDistance, - SheetDiagonal = sheetDiagonal, - PartLocation = part.Location, - UserVariableMapping = userVarMapping, - DrawingId = part.BaseDrawing.Id, - IsCutOff = part.BaseDrawing.IsCutOff, - PlateWidth = plateWidth, - PlateLength = plateLength, - }; - - _featureWriter.Write(w, ctx); - featureIndex++; - } + continue; } - lastPartName = partName; - } - } - - private void WriteSubprogramCall( - TextWriter w, - Part part, - int subNum, - int featureIndex, - string partName, - bool isSafetyHeadraise, - bool isLastPart - ) - { - // Safety headraise before rapid to new part - if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue) - w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)"); - - // Rapid to part position (bounding box lower-left) - var featureNumber = - featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1; - - var sb = new StringBuilder(); - if (_config.UseLineNumbers) - sb.Append($"N{featureNumber} "); - sb.Append($"G0 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}"); - w.WriteLine(sb.ToString()); - - // Part name comment - w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}")); - - // Set local coordinate system at part position - w.WriteLine("G92 X0 Y0"); - - // Call part sub-program - w.WriteLine($"M98 P{subNum} ({partName})"); - - // Restore sheet coordinate system - w.WriteLine($"G92 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}"); - - // Head raise (unless last part on sheet) - if (!isLastPart) - w.WriteLine("M47"); - } - - private void WritePartsInline( - TextWriter w, - List allParts, - string cutLibrary, - string etchLibrary, - double sheetDiagonal, - double plateWidth, - double plateLength, - Dictionary<(int drawingId, string varName), int> userVarMapping - ) - { - // Split and classify features, ordering etch before cut per part - var features = new List<(Part part, List codes, bool isEtch)>(); - foreach (var part in allParts) - { - var partFeatures = FeatureUtils.SplitAndClassify(part); - foreach (var (codes, isEtch) in partFeatures) - features.Add((part, codes, isEtch)); - } - - // Emit features - var lastPartName = ""; - for (var i = 0; i < features.Count; i++) - { - var (part, codes, isEtch) = features[i]; - var partName = part.BaseDrawing.Name; - var isFirstFeatureOfPart = partName != lastPartName; - var isSafetyHeadraise = partName != lastPartName && lastPartName != ""; - var isLastFeature = i == features.Count - 1; + var codes = item.Codes; // SubProgramCall features are emitted as M98 hole calls if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) @@ -317,27 +252,23 @@ public sealed class CincinnatiSheetWriter isFirstFeatureOfPart ? partName : null, isSafetyHeadraise ); - lastPartName = partName; continue; } - var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1; - - var cutDistance = FeatureUtils.ComputeCutDistance(codes); - var ctx = new FeatureContext { Codes = codes, - FeatureNumber = featureNumber, + FeatureNumber = FeatureNumber(i), PartName = partName, IsFirstFeatureOfPart = isFirstFeatureOfPart, IsLastFeatureOnSheet = isLastFeature, IsSafetyHeadraise = isSafetyHeadraise, - IsExteriorFeature = false, - IsEtch = isEtch, - LibraryFile = isEtch ? etchLibrary : cutLibrary, - CutDistance = cutDistance, + IsExteriorFeature = item.IsExterior, + IsEtch = item.IsEtch, + LibraryFile = item.IsEtch ? etchLibrary : cutLibrary, + CutDistance = FeatureUtils.ComputeCutDistance(codes), SheetDiagonal = sheetDiagonal, + RapidDistance = rapidDistance, PartLocation = part.Location, UserVariableMapping = userVarMapping, DrawingId = part.BaseDrawing.Id, @@ -347,7 +278,59 @@ public sealed class CincinnatiSheetWriter }; _featureWriter.Write(w, ctx); - lastPartName = partName; } } + + private int FeatureNumber(int index) => + index == 0 ? _config.FeatureLineNumberStart : 1000 + index + 1; + + /// + /// Calls a part sub-program in a G92 frame at the part corner (§1.92). After the + /// call the head is raised, the tool rapids back to local 0,0 and G92 gives that + /// point its sheet coordinates again, so the frame is restored where the tool really + /// is. The last part skips the restore: nothing moves before the next sheet + /// re-anchors with G92 X#5021 Y#5022. + /// + private void WriteSubprogramCall( + TextWriter w, + SheetItem item, + int index, + string partName, + bool isSafetyHeadraise, + double? rapidDistance, + bool isLastPart + ) + { + var x = _fmt.FormatCoord(item.Entry.X); + var y = _fmt.FormatCoord(item.Entry.Y); + + // Rapid to the sub-program origin (the part's toolpath corner) + var sb = new StringBuilder(); + if (_config.UseLineNumbers) + sb.Append($"N{FeatureNumber(index)} "); + sb.Append($"G0 X{x} Y{y}"); + w.WriteLine(sb.ToString()); + + // Part name comment + w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}")); + + // Set local coordinate system at part position + w.WriteLine("G92 X0 Y0"); + + // Call part sub-program + w.WriteLine($"M98 P{item.Subprogram.Number} ({partName})"); + + if (isLastPart) + return; + + // One head raise covers the rapid back and the rapid on. The part is complete, + // so the raise follows the exterior M47 mode. + var raise = CincinnatiHeadRaise.Line(_config, isSafetyHeadraise, true, rapidDistance); + if (raise != null) + w.WriteLine(raise); + + // Restore the sheet coordinate system at the corner + w.WriteLine("G0 X0 Y0"); + w.WriteLine($"G92 X{x} Y{y}"); + } } diff --git a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs index 2f20de54..516a389c 100644 --- a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs +++ b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs @@ -78,17 +78,205 @@ public static class FeatureUtils /// /// Splits a part's program into features by rapids, classifies each as etch or cut, - /// and orders etch features before cut features. + /// and orders etch features before cut features. The part's program is not modified. /// public static List<(List codes, bool isEtch)> SplitAndClassify(Part part) { - part.Program.Mode = Mode.Absolute; - var codes = new List(part.Program.Codes); + var codes = RemoveSuppressed(AbsoluteCodes(part.Program)); InsertLeadingRapid(codes); return ClassifyAndOrder(SplitByRapids(codes)); } + /// + /// Returns 's codes in absolute mode without changing the + /// program. Hole calls are the program's own instances, so lookups by hole program + /// reference still match; every other code of an incremental program is a copy. + /// + public static List AbsoluteCodes(Program program) + { + if (program.Mode == Mode.Absolute) + return new List(program.Codes); + + var copy = ToAbsoluteCopy(program); + var codes = new List(copy.Codes.Count); + for (var i = 0; i < copy.Codes.Count; i++) + codes.Add(copy.Codes[i] is SubProgramCall ? program.Codes[i] : copy.Codes[i]); + return codes; + } + + /// + /// Leaves suppressed moves uncut, matching the other posts and the cut simulation. + /// Absolute codes only. A cut that resumes after a suppressed run starts with a + /// rapid to the run's end, so the gap is crossed with the beam off rather than cut + /// as a chord. Rapids left with nothing to cut, and hole calls whose moves are all + /// suppressed, are dropped so they cannot post an empty pierce. + /// + public static List RemoveSuppressed(List codes) + { + var kept = new List(codes.Count); + Vector? resumeAt = null; + foreach (var code in codes) + { + if (code is Motion { Suppressed: true } suppressed) + { + resumeAt = suppressed.EndPoint; + continue; + } + + if (code is SubProgramCall call && !HasCut(call.Program)) + continue; + + if (resumeAt.HasValue && code is Motion and not RapidMove) + kept.Add(new RapidMove(resumeAt.Value)); + resumeAt = null; + kept.Add(code); + } + + var result = new List(kept.Count); + for (var i = 0; i < kept.Count; i++) + { + if (kept[i] is RapidMove && !CutFollows(kept, i + 1)) + continue; + result.Add(kept[i]); + } + return result; + } + + // True when a cutting move comes before the next rapid or hole call. + private static bool CutFollows(List codes, int start) + { + for (var i = start; i < codes.Count; i++) + { + if (codes[i] is RapidMove or SubProgramCall) + return false; + if (codes[i] is Motion) + return true; + } + return false; + } + + /// True when has an unsuppressed cutting move. + public static bool HasCut(Program program) + { + if (program == null) + return false; + foreach (var code in program.Codes) + { + if (code is Motion { Suppressed: false } and not RapidMove) + return true; + if (code is SubProgramCall call && HasCut(call.Program)) + return true; + } + return false; + } + + /// + /// The pierce point of a feature in its program's frame: its first rapid, or for a + /// hole call the hole's first pierce shifted to the call offset. + /// + public static Vector StartPoint(List feature) + { + if (feature.Count == 1 && feature[0] is SubProgramCall call && call.Program != null) + return call.Offset + StartPoint(AbsoluteCodes(call.Program)); + + foreach (var code in feature) + { + if (code is RapidMove rapid) + return rapid.EndPoint; + } + foreach (var code in feature) + { + if (code is Motion motion) + return motion.EndPoint; + } + return Vector.Zero; + } + + /// Where the tool stops at the end of a feature, in its program's frame. + public static Vector EndPoint(List feature) + { + if (feature.Count == 1 && feature[0] is SubProgramCall call && call.Program != null) + return call.Offset + EndPoint(AbsoluteCodes(call.Program)); + + for (var i = feature.Count - 1; i >= 0; i--) + { + if (feature[i] is Motion motion) + return motion.EndPoint; + } + return Vector.Zero; + } + + /// + /// Marks each part's exterior features: cut features whose cut moves (lead moves + /// excluded) span the extents of all the part's cut moves. Etch features and hole + /// calls are never exterior. A part whose outline is cut in pieces has none. + /// + public static bool[] FindExterior(IReadOnlyList<(List codes, bool isEtch)> features) + { + var bounds = new Box[features.Count]; + Box all = null; + for (var i = 0; i < features.Count; i++) + { + var (codes, isEtch) = features[i]; + if (isEtch || (codes.Count == 1 && codes[0] is SubProgramCall)) + continue; + bounds[i] = CutBounds(codes); + if (bounds[i] != null) + all = all == null ? bounds[i] : Union(all, bounds[i]); + } + + var result = new bool[features.Count]; + for (var i = 0; i < features.Count; i++) + result[i] = bounds[i] != null && SameExtents(bounds[i], all); + return result; + } + + private static Box CutBounds(List codes) + { + var cut = new Program(Mode.Absolute); + var position = Vector.Zero; + var inCut = false; + foreach (var code in codes) + { + if (code is not Motion motion) + continue; + + var layer = motion switch + { + LinearMove linear => linear.Layer, + ArcMove arc => arc.Layer, + _ => LayerType.Leadin, + }; + var isCut = motion is not RapidMove && layer is not (LayerType.Leadin or LayerType.Leadout); + if (isCut) + { + if (!inCut) + cut.Codes.Add(new RapidMove(position)); + cut.Codes.Add(motion); + } + inCut = isCut; + position = motion.EndPoint; + } + + return cut.Codes.Count == 0 ? null : cut.BoundingBox(); + } + + private static Box Union(Box a, Box b) + { + var left = System.Math.Min(a.Left, b.Left); + var bottom = System.Math.Min(a.Bottom, b.Bottom); + var right = System.Math.Max(a.Right, b.Right); + var top = System.Math.Max(a.Top, b.Top); + return new Box(left, bottom, right - left, top - bottom); + } + + private static bool SameExtents(Box a, Box b) => + a.Left.IsEqualTo(b.Left) + && a.Right.IsEqualTo(b.Right) + && a.Bottom.IsEqualTo(b.Bottom) + && a.Top.IsEqualTo(b.Top); + /// /// If the first contour motion has no rapid before it, the feature writer would /// pierce at that motion's endpoint and lose the first segment. Inserts a synthetic diff --git a/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs b/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs index 63fd24cd..4f9b007b 100644 --- a/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs +++ b/Posts/OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs @@ -24,13 +24,26 @@ public sealed class MaterialLibraryResolver if (!string.IsNullOrEmpty(_selectedLibrary)) return EnsureLibExtension(_selectedLibrary); - var entry = _materialLibraries.FirstOrDefault(e => + var entry = FindCutEntry(_materialLibraries, materialName, thickness, gas); + return EnsureLibExtension(entry?.Library ?? ""); + } + + /// + /// The first cut library entry for this material, thickness (within tolerance) and + /// assist gas, or null when none matches. + /// + public static MaterialLibraryEntry FindCutEntry( + IEnumerable entries, + string materialName, + double thickness, + string gas + ) + { + return entries?.FirstOrDefault(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) && System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance && string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase) ); - - return EnsureLibExtension(entry?.Library ?? ""); } public string ResolveEtchLibrary(string gas) diff --git a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json index 692d9983..e16ad7f6 100644 --- a/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json +++ b/Posts/OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.json @@ -4,13 +4,10 @@ "PostedAccuracy": 4, "UseLineNumbers": true, "FeatureLineNumberStart": 1, - "UseSheetSubprograms": true, "SheetSubprogramStart": 101, "UsePartSubprograms": false, "PartSubprogramStart": 200, "VariableDeclarationSubprogram": 100, - "CoordModeBetweenParts": "G92", - "ProcessParameterMode": "LibraryFile", "DefaultAssistGas": "O2", "DefaultEtchGas": "N2", "UseExactStopMode": false, diff --git a/docs/cincinnati-post-output.md b/docs/cincinnati-post-output.md index 10c79f44..ab77a575 100644 --- a/docs/cincinnati-post-output.md +++ b/docs/cincinnati-post-output.md @@ -37,8 +37,11 @@ A generated file contains, in order: never reorders them. 4. **Part sub-programs** (`CincinnatiPartSubprogramWriter.Write`), one per - unique `(drawing, rotation)` pair, only emitted when - `Config.UsePartSubprograms` is enabled. + unique posted part toolpath, only emitted when `Config.UsePartSubprograms` + is enabled. Parts share a sub-program only when they have the same drawing + name and their normalized programs (lead-ins, hole calls and suppression + included) post identically, so parts of one drawing and rotation with + different lead-ins get their own sub-programs. 5. **Hole sub-programs** (`CincinnatiPartSubprogramWriter.Write` reused with a `"HOLE"` label), one per unique posted hole toolpath. Calls are matched to @@ -49,6 +52,36 @@ A generated file contains, in order: Sub-program bodies start with a `:` label and end with `M99`. +The post reads part programs through absolute-mode copies and never changes the +nest: an incremental part program stays incremental after posting. + +### Sheet header + +Each sheet sub-program re-anchors the work frame with `G92 X#5021 Y#5022` +(§1.92; `#5021`/`#5022` are the machine X/Y system variables), loads the cut +library with `G89 P` (§2.89) when one resolved, and raises the head +with `M47`. When `Config.UseLineNumbers` is on it then posts +`GOTO( Goto Feature )` (§5.5), which targets the +sheet's first feature line; the operator edits it to restart at another +feature. Without line numbers there is no target, so no `GOTO` is posted. + +### Suppressed moves + +Moves with `Motion.Suppressed` set are not cut, as in the CI Fiber post and the +cut simulation. When cutting resumes after a suppressed run, the post ends the +feature and starts a new one with a rapid to the run's end point, so the gap is +crossed with the beam off. Rapids left with nothing to cut, and hole calls whose +moves are all suppressed, are dropped. + +### Cut library selection + +`MaterialLibraryResolver` picks the first `MaterialLibraries` entry matching the +nest material, thickness within 0.001 and assist gas (`Nest.AssistGas`, else +`Config.DefaultAssistGas`). Before the configuration dialog opens, +`PrepareForNest` pre-selects that same entry as `SelectedLibrary`, or clears the +selection when nothing matches; it never substitutes another gas or thickness. +A `SelectedLibrary` the operator then picks overrides auto-resolution. + ## Feature blocks A "feature" is a single contour: lead-in → cut moves → lead-out. Each feature @@ -58,7 +91,8 @@ block in a sheet or sub-program output follows this order 1. `G0 X_ Y_` — rapid to the pierce point (§1.00). 2. Optional part-name comment, only on the first feature of each part. 3. `G89 P` — load process parameters (§2.89). `P` is a library file - name; the `(...)` trailing comment carries speed-class info. + name; the `(...)` trailing comment carries speed-class info. With no + resolved library the post writes `(WARNING: No library found)` instead. 4. `G84` (cut) or `G85` (etch / no-pierce) — pierce and start cut, or start cut without pierce (§2.84 / §2.85). 5. `M130 (ANTI DIVE OFF)` — disable anti-dive, only if configured (§3.130). @@ -72,13 +106,35 @@ block in a sheet or sub-program output follows this order 7. `G40` — cancel kerf compensation (§1.40), only if it was applied. 8. `M35` (or `M135` if SpeedGas is enabled) — beam off (§3.35 / §3.135). 9. `M131 (ANTI DIVE ON)` — re-enable anti-dive (§3.131). -10. `M47` or `M47 P` — raise Z-axis, unless this is the last feature - on the sheet (§3.47). A leading `/` (block delete, §5.6) is prepended when - the configured override distance exceeds the default. +10. Head raise before the rapid to the next feature, unless this is the last + feature on the sheet (§3.47). See [Head raises](#head-raises-m47). -Sheet sub-program and sheet-level feature calls add `G92 X#5021 Y#5022` -(§1.92) at the top so the local origin is anchored to the machine's current -absolute position (`#5021`/`#5022` are the machine X/Y system variables). +## Head raises (M47) + +Every raise is written at the end of the feature it follows, before the rapid +that leaves it, and there is exactly one raise per rapid. The block is chosen +in this order (`CincinnatiHeadRaise`): + +1. `M47 P (Safety Headraise)` when the next feature + belongs to a part with a different drawing name and the distance is set. +2. Otherwise the M47 mode for the feature just cut: `ExteriorM47` after a + part's exterior, `InteriorM47` after anything else. + - `Always`: `M47`. + - `BlockDelete`: `/M47`; the operator can skip it with block delete (§5.6). + - `Auto`: `M47` when the rapid travel to the next pierce exceeds + `M47OverrideDistanceThreshold` (program units), `/M47` when it does not. + With no threshold, `Auto` always posts `M47`. + - `None`: no raise. + +The exterior is the cut feature whose cut moves (lead-in and lead-out moves +excluded) span the extents of all of the part's cut moves; etch features and +hole calls are never exterior, and a part whose outline is cut in several +features has no exterior. The raise after a part sub-program call follows +`ExteriorM47`. These rules are this project's interpretation of the settings, +not vendor-documented behavior. + +Hole calls (see below) write `M47`, or the safety raise, after `G52 X0 Y0` +regardless of the M47 mode. ## Sub-program call patterns @@ -88,24 +144,32 @@ targets a whole-part sub-program or a hole sub-program. ### Part sub-program call (`WriteSubprogramCall`) Used when `Config.UsePartSubprograms` is enabled. The tool physically rapids -to the part corner, then G92 sets the current position as the local origin, -the sub-program executes in its own local coordinate frame, and G92 restores -the original absolute position after return. +to the corner of the part's toolpath, G92 sets that position as the local +origin, and the sub-program executes in its own local coordinate frame. After +it returns, the tool is wherever the sub-program's last feature ended, so the +head is raised, the tool rapids back to local `0,0` (the corner) and G92 gives +that point its sheet coordinates again. ``` -G0 X Y ; rapid to part bounding box corner (§1.00) -(PART: ) +G0 X Y ; rapid to the toolpath corner (§1.00) +( PART: ) G92 X0 Y0 ; set local origin at current position (§1.92) M98 P () ; call the part sub-program (§3.98) +M47 ; head raise for the rapids that follow (§3.47) +G0 X0 Y0 ; back to the corner (§1.00) G92 X Y ; restore the sheet coordinate system (§1.92) -M47 ; head raise unless this is the last part (§3.47) ``` -This pattern uses G92 because the tool is physically positioned at the part -corner first. The sub-program's coordinates are part-local, so they are -interpreted against the new origin until G92 restores the sheet frame. +The last part on a sheet stops after `M98`: nothing moves before the next +sheet re-anchors with `G92 X#5021 Y#5022`. The sub-program's last feature +posts no raise, so the raise after the call is the only one before the rapids, +chosen by the [head raise](#head-raises-m47) rules. -### Hole sub-program call (`WriteHoleSubprogramCall`) +This pattern uses G92 rather than G52 because hole calls inside a part +sub-program use G52 themselves, and a nested G52 would replace the part shift +instead of adding to it. + +### Hole sub-program call (`CincinnatiHoleCallWriter.Write`) Used for the `SubProgramCall` codes that a `ContourCuttingStrategy` emits for each circular hole. Unlike parts, we do **not** want a physical rapid to the