From 3a7073d38c8ba46622d7da738e9e41af533e05e9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 01:08:06 -0400 Subject: [PATCH] fix(posts): post Cincinnati CL holes where and as drawn, in sequence Hole sub-programs are stored incremental but were written under G90 as if absolute, producing invalid arcs. The registry now posts absolute copies and leaves the nest's programs untouched. Sheet-level hole calls omitted the part location from G52, so every part's holes were cut near the sheet origin. Calls now shift to the part location plus the hole offset. Hole calls were matched to sub-programs by call id, which planned emission numbers 1, 2, ... per drawing, so holes from different drawings shared the first drawing's sub-program and diameter. Calls are now keyed by program reference and deduplicated by posted toolpath. A part opening with a hole call got a synthetic rapid that posted an empty pierce on the part edge. The rapid is now only inserted before a contour motion with no rapid ahead of it; the hole call carries the part comment and safety head raise that feature used to hold. The sheet writer re-sorted parts bottom-then-left with cut-offs last. Plate order is the cut sequence, so parts now post in plate order. Regression tests replay the posted G-code for off-origin parts of several drawings and check each hole's center and radius. Co-Authored-By: Claude Opus 5.5 --- .../HoleCallOutputCharacterizationTests.cs | 66 ++-- .../HolePlacementRegressionTests.cs | 350 ++++++++++++++++++ .../CincinnatiHoleCallWriter.cs | 36 +- .../CincinnatiPartSubprogramWriter.cs | 104 +++--- .../CincinnatiPostProcessor.cs | 3 +- .../CincinnatiSheetWriter.cs | 39 +- .../OpenNest.Posts.Cincinnati/FeatureUtils.cs | 50 ++- docs/cincinnati-post-output.md | 18 +- 8 files changed, 556 insertions(+), 110 deletions(-) create mode 100644 OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs diff --git a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs index ae0c78ab..9eaba266 100644 --- a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs +++ b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs @@ -22,11 +22,8 @@ public class HoleCallOutputCharacterizationTests write(output, program); - var body = Lines( - lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79", - "M98 P7", - "G52 X0 Y0" - ); + 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()); } @@ -37,15 +34,12 @@ public class HoleCallOutputCharacterizationTests [InlineData("inline-sheet", false)] [InlineData("grouped-sheet", true)] [InlineData("grouped-sheet", false)] - public void MultipleHoles_LiveMap_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers) + public void MultipleHoles_MappedAndUnmapped_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers) { var config = CreateConfig(lineNumbers); config.InteriorM47 = M47Mode.None; - var mapping = new Dictionary { [7] = 900 }; - var write = CreateWriter(route, config, mapping); - // The caller-owned map is consulted when writing, not copied at construction. - mapping[7] = 901; - mapping[9] = 0; + // Hole 8 is unmapped and falls back to its call id. + var write = CreateWriter(route, config, new Dictionary { [7] = 901, [9] = 0 }); var program = CreateProgram( CreateCall(7, -12.34567, 6.78901), CreateCall(8, 0.00499, -0.006), @@ -55,8 +49,7 @@ public class HoleCallOutputCharacterizationTests write(output, program); - var body = Lines( - lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79", + var body = FirstHole(route, lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79") + Lines( "M98 P901", "G52 X0 Y0", "M47", @@ -134,14 +127,14 @@ public class HoleCallOutputCharacterizationTests write(first, program); Assert.Equal( - WrapOutput(route, Lines("N87 G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")), + WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")), first.ToString() ); config.UseLineNumbers = false; using var second = new StringWriter { NewLine = "\n" }; write(second, program); Assert.Equal( - WrapOutput(route, Lines("G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")), + WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")), second.ToString() ); } @@ -159,7 +152,7 @@ public class HoleCallOutputCharacterizationTests var error = Assert.Throws(() => write(output, program)); Assert.Same(output.Failure, error); - Assert.Equal(Header(route) + Lines("N73 G52 X-12.35 Y6.79"), output.ToString()); + Assert.Equal(Header(route) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString()); } private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new() @@ -173,24 +166,51 @@ public class HoleCallOutputCharacterizationTests UseAntiDive = false, }; + /// + /// Posted sub-program numbers by call id, resolved at write time to the hole programs + /// the writer actually sees (a part owns copies of its drawing's hole programs). + /// Writers are still constructed here, so construction-time settings stay fixed. + /// private static Action CreateWriter( string route, CincinnatiPostConfig config, Dictionary? mapping ) { - if (route == "part") + var byProgram = mapping == null + ? null + : new Dictionary(ReferenceEqualityComparer.Instance); + + void Resolve(Program owner) { - var writer = new CincinnatiPartSubprogramWriter(config, mapping); - return (output, program) => writer.Write(output, program, "Sample", 201, "", "", 10); + if (byProgram == null) + return; + byProgram.Clear(); + foreach (var call in owner.Codes.OfType()) + { + if (mapping!.TryGetValue(call.Id, out var number)) + byProgram[call.Program] = number; + } } - var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), mapping); + if (route == "part") + { + var writer = new CincinnatiPartSubprogramWriter(config, byProgram); + return (output, program) => + { + Resolve(program); + writer.Write(output, program, "Sample", 201, "", "", 10); + }; + } + + var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), byProgram); Dictionary<(int, long), int>? partMapping = route == "grouped-sheet" ? new() : null; return (output, program) => { var plate = new Plate(48, 96); - plate.Parts.Add(new Part(new Drawing("Sample", program))); + var part = new Part(new Drawing("Sample", program)); + plate.Parts.Add(part); + Resolve(part.Program); sheetWriter.Write(output, plate, "Nest", 1, 101, "", "", partMapping); }; } @@ -241,6 +261,10 @@ public class HoleCallOutputCharacterizationTests ); } + // A sheet part that opens with a hole call names the part after the call's G52 line. + private static string FirstHole(string route, string g52Line) => + route == "part" ? Lines(g52Line) : Lines(g52Line, "( PART: Sample )"); + private static string Lines(params string[] lines) => string.Join("\n", lines) + "\n"; private sealed class RejectHoleCallWriter : StringWriter diff --git a/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs new file mode 100644 index 00000000..bf6d841d --- /dev/null +++ b/OpenNest.Tests/Cincinnati/HolePlacementRegressionTests.cs @@ -0,0 +1,350 @@ +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; + +namespace OpenNest.Tests.Cincinnati; + +/// +/// Posts real lead-in programs (incremental hole sub-programs, per-drawing hole ids) +/// for off-origin parts of several drawings, then replays the G-code to check where +/// each hole is actually cut. +/// +public class HolePlacementRegressionTests +{ + private static readonly Vector HoleCenter = new(3, 3); + + [Fact] + public void InlineSheet_HolesCutAtPartLocationWithOwnDrawingRadius() + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + var large = AddPart(plate, "Large", 0.5, new Vector(10, 20)); + var small = AddPart(plate, "Small", 0.25, new Vector(30, 20)); + nest.Plates.Add(plate); + + var run = Replay(Post(nest, partSubprograms: false)); + + AssertHole(run, large, 0.5); + AssertHole(run, small, 0.25); + Assert.Equal(2, run.FullCircles.Count); + } + + [Fact] + public void PartSubprograms_HolesCutAtPartLocationWithOwnDrawingRadius() + { + // One part per sheet: each sheet re-anchors with G92 X#5021 Y#5022. + var nest = new Nest("N"); + var first = new Plate(48, 96); + var second = new Plate(48, 96); + var large = AddPart(first, "Large", 0.5, new Vector(10, 20)); + var small = AddPart(second, "Small", 0.25, new Vector(30, 40)); + nest.Plates.Add(first); + nest.Plates.Add(second); + + var run = Replay(Post(nest, partSubprograms: true)); + + AssertHole(run, large, 0.5); + AssertHole(run, small, 0.25); + Assert.Equal(2, run.FullCircles.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SameDrawingHoleIds_DifferentRadii_PostSeparateSubprograms(bool partSubprograms) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + var large = AddPart(plate, "Large", 0.5, new Vector(10, 20)); + var small = AddPart(plate, "Small", 0.25, new Vector(30, 20)); + nest.Plates.Add(plate); + // Planned emission labels holes 1, 2, ... within each drawing's program. + HoleCall(large).Id = 1; + HoleCall(small).Id = 1; + + var output = Post(nest, partSubprograms); + + var holeSubs = output.Split('\n').Count(l => l.Trim() == "( PART: HOLE )"); + Assert.Equal(2, holeSubs); + var run = Replay(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); + } + + [Fact] + public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraise() + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + AddPart(plate, "First", 0.5, new Vector(0, 0)); + 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 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]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Post_PartsStartingWithHoleCall_HaveNoEmptyPierce(bool partSubprograms) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + var part = AddPart(plate, "Large", 0.5, new Vector(10, 20)); + nest.Plates.Add(plate); + Assert.IsType(part.Program.Codes[0]); + + var run = Replay(Post(nest, partSubprograms)); + + Assert.Equal(0, run.EmptyPierces); + Assert.Equal(2, run.Pierces); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Post_LeavesNestHoleProgramsUntouched(bool partSubprograms) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + var part = AddPart(plate, "Large", 0.5, new Vector(10, 20)); + nest.Plates.Add(plate); + var hole = HoleCall(part).Program; + var before = hole.Codes.Count; + + Post(nest, partSubprograms); + + Assert.Equal(Mode.Incremental, hole.Mode); + Assert.Equal(before, hole.Codes.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Post_FollowsPlateSequence(bool partSubprograms) + { + var nest = new Nest("N"); + var plate = new Plate(48, 96); + // Plate order deliberately differs from bottom-then-left order. + AddPart(plate, "Third", 0.5, new Vector(30, 40)); + AddPart(plate, "First", 0.5, new Vector(0, 0)); + AddPart(plate, "Second", 0.5, new Vector(20, 0)); + nest.Plates.Add(plate); + + var output = Post(nest, partSubprograms); + + var sheet = output[output.IndexOf(":101", System.StringComparison.Ordinal)..]; + var order = sheet + .Split('\n') + .Select(l => l.Trim()) + .Where(l => l.StartsWith("( PART: ", System.StringComparison.Ordinal)) + .Take(3) + .ToArray(); + Assert.Equal(new[] { "( PART: Third )", "( PART: First )", "( PART: Second )" }, order); + } + + private static Part AddPart(Plate plate, string name, double holeRadius, Vector location) + { + 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)); + var start = new Vector(HoleCenter.X + holeRadius, HoleCenter.Y); + pgm.Codes.Add(new RapidMove(start)); + pgm.Codes.Add(new ArcMove(start, HoleCenter, RotationType.CW)); + + var part = new Part(new Drawing(name) { Program = pgm }); + part.ApplyLeadIns( + new CuttingParameters + { + ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ArcCircleLeadOut = new NoLeadOut(), + ExternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 }, + ExternalLeadOut = new NoLeadOut(), + }, + new Vector(10, 10) + ); + Assert.Equal(Mode.Incremental, HoleCall(part).Program.Mode); + part.Location = location; + plate.Parts.Add(part); + return part; + } + + private static SubProgramCall HoleCall(Part part) => + part.Program.Codes.OfType().Single(); + + private static void AssertHole(Replayed run, Part part, double radius) + { + var expected = part.Location + HoleCenter; + Assert.Contains( + run.FullCircles, + 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/CincinnatiHoleCallWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs index 81f8c0ef..8a077081 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs @@ -2,23 +2,42 @@ using System.Collections.Generic; using System.IO; using System.Text; using OpenNest.CNC; +using OpenNest.Geometry; namespace OpenNest.Posts.Cincinnati; internal static class CincinnatiHoleCallWriter { + /// + /// Position of the calling program's frame in the current work coordinate system: + /// the part location for sheet-level calls, zero inside a normalized part sub-program. + /// + /// + /// Maps each call's hole program (by reference) to its posted sub-program number. + /// Unmapped calls fall back to the call's own id. + /// + /// Part-name comment to emit when the call opens a part, else null. + /// + /// True when the call is a part's first feature after another part, matching the + /// feature writer's safety head raise. + /// internal static void Write( TextWriter w, SubProgramCall call, + Vector origin, int featureIndex, bool isLastFeature, CincinnatiPostConfig config, CoordinateFormatter fmt, - Dictionary holeSubprograms + IReadOnlyDictionary holeSubprograms, + string partName = null, + bool isSafetyHeadraise = false ) { var postSubNum = - holeSubprograms != null && holeSubprograms.TryGetValue(call.Id, out var num) + holeSubprograms != null + && call.Program != null + && holeSubprograms.TryGetValue(call.Program, out var num) ? num : call.Id; @@ -29,18 +48,27 @@ internal static class CincinnatiHoleCallWriter // G52 does not move the nozzle: the hole-local sub-program's first rapid // goes directly from the previous feature's end to pierce. // See docs/cincinnati-post-output.md for the full bracket. + var center = origin + call.Offset; var sb = new StringBuilder(); if (config.UseLineNumbers) sb.Append($"N{featureNumber} "); - sb.Append($"G52 X{fmt.FormatCoord(call.Offset.X)} Y{fmt.FormatCoord(call.Offset.Y)}"); + sb.Append($"G52 X{fmt.FormatCoord(center.X)} Y{fmt.FormatCoord(center.Y)}"); w.WriteLine(sb.ToString()); + if (!string.IsNullOrEmpty(partName)) + w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}")); + w.WriteLine($"M98 P{postSubNum}"); // Cancel the local shift (manual §1.52). w.WriteLine("G52 X0 Y0"); - if (!isLastFeature) + if (isLastFeature) + return; + + if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue) + w.WriteLine($"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)"); + else w.WriteLine("M47"); } } diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs index 98c2e70e..f862b538 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text; using OpenNest.CNC; @@ -17,11 +18,11 @@ public sealed class CincinnatiPartSubprogramWriter private readonly CincinnatiPostConfig _config; private readonly CincinnatiFeatureWriter _featureWriter; private readonly CoordinateFormatter _fmt; - private readonly Dictionary _holeSubprograms; + private readonly IReadOnlyDictionary _holeSubprograms; public CincinnatiPartSubprogramWriter( CincinnatiPostConfig config, - Dictionary holeSubprograms = null + IReadOnlyDictionary holeSubprograms = null ) { _config = config; @@ -32,7 +33,7 @@ public sealed class CincinnatiPartSubprogramWriter /// /// Writes a complete part sub-program for the given normalized program. - /// The program coordinates must already be normalized to origin (0,0). + /// The program must be in absolute mode with coordinates normalized to origin (0,0). /// public void Write( TextWriter w, @@ -64,7 +65,7 @@ public sealed class CincinnatiPartSubprogramWriter if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { CincinnatiHoleCallWriter.Write( - w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms + w, holeCall, Vector.Zero, i, isLastFeature, _config, _fmt, _holeSubprograms ); continue; } @@ -98,20 +99,7 @@ public sealed class CincinnatiPartSubprogramWriter /// last motion endpoint (the contour return point). This ensures the feature /// writer knows the true pierce location and preserves the first contour segment. /// - internal static void EnsureLeadingRapid(Program pgm) - { - if (pgm.Codes.Count == 0 || pgm.Codes[0] is RapidMove) - return; - - for (var i = pgm.Codes.Count - 1; i >= 0; i--) - { - if (pgm.Codes[i] is Motion lastMotion) - { - pgm.Codes.Insert(0, new RapidMove(lastMotion.EndPoint)); - return; - } - } - } + internal static void EnsureLeadingRapid(Program pgm) => FeatureUtils.InsertLeadingRapid(pgm.Codes); /// /// Creates a sub-program key for matching parts to their sub-programs. @@ -165,65 +153,85 @@ public sealed class CincinnatiPartSubprogramWriter } /// - /// Scans all parts across all plates and builds a nest-level registry of unique - /// hole sub-programs. Deduplicates by comparing sub-program code content. + /// 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 + /// program reference, because hole call ids are only unique within one drawing's + /// program; identical hole geometry shares one sub-program. Entries are absolute-mode + /// copies, so the caller's (incremental) hole programs are never modified. /// internal static ( - Dictionary modelToPostMapping, + Dictionary mapping, List<(int subNum, Program program)> entries - ) BuildHoleRegistry(IEnumerable plates, int startNumber) + ) BuildHoleRegistry( + IEnumerable plates, + IEnumerable partSubprograms, + int startNumber + ) { - var mapping = new Dictionary(); + var mapping = new Dictionary(ReferenceEqualityComparer.Instance); var entries = new List<(int, Program)>(); var contentIndex = new Dictionary(); var nextSubNum = startNumber; + void Register(Program owner) + { + foreach (var code in owner.Codes) + { + if (code is not SubProgramCall { Program: not null } call) + continue; + if (mapping.ContainsKey(call.Program)) + continue; + + var absolute = FeatureUtils.ToAbsoluteCopy(call.Program); + EnsureLeadingRapid(absolute); + + var canonical = ProgramToCanonical(absolute); + if (!contentIndex.TryGetValue(canonical, out var subNum)) + { + subNum = nextSubNum++; + contentIndex[canonical] = subNum; + entries.Add((subNum, absolute)); + } + + mapping[call.Program] = subNum; + } + } + foreach (var plate in plates) { foreach (var part in plate.Parts) { - if (part.BaseDrawing.IsCutOff) - continue; - foreach (var code in part.Program.Codes) - { - if (code is not SubProgramCall call) - continue; - if (mapping.ContainsKey(call.Id)) - continue; - - var canonical = ProgramToCanonical(call.Program); - if (contentIndex.TryGetValue(canonical, out var existingNum)) - { - mapping[call.Id] = existingNum; - } - else - { - var subNum = nextSubNum++; - mapping[call.Id] = subNum; - contentIndex[canonical] = subNum; - entries.Add((subNum, call.Program)); - } - } + if (!part.BaseDrawing.IsCutOff) + Register(part.Program); } } + if (partSubprograms != null) + { + foreach (var program in partSubprograms) + Register(program); + } + return (mapping, entries); } private static string ProgramToCanonical(Program pgm) { var sb = new StringBuilder(); - sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I"); foreach (var code in pgm.Codes) { if (code is LinearMove lm) - sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}"); + sb.Append( + CultureInfo.InvariantCulture, + $"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}" + ); else if (code is ArcMove am) sb.Append( + CultureInfo.InvariantCulture, $"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}" ); else if (code is RapidMove rm) - sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}"); + sb.Append(CultureInfo.InvariantCulture, $"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}"); } return sb.ToString(); } diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index b337015e..08f1e884 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -118,6 +118,7 @@ namespace OpenNest.Posts.Cincinnati var holeStartNumber = Config.PartSubprogramStart + (subprogramEntries?.Count ?? 0); var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry( plates, + subprogramEntries?.Select(e => e.program), holeStartNumber ); @@ -216,9 +217,9 @@ namespace OpenNest.Posts.Cincinnati ) : 100.0; + // Entries are absolute-mode copies with a leading rapid already ensured. foreach (var (subNum, pgm) in holeEntries) { - CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm); holeSubWriter.Write( writer, pgm, diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index 872b456b..9ffa8a11 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -17,12 +17,12 @@ public sealed class CincinnatiSheetWriter private readonly ProgramVariableManager _vars; private readonly CoordinateFormatter _fmt; private readonly CincinnatiFeatureWriter _featureWriter; - private readonly Dictionary _holeSubprograms; + private readonly IReadOnlyDictionary _holeSubprograms; public CincinnatiSheetWriter( CincinnatiPostConfig config, ProgramVariableManager vars, - Dictionary holeSubprograms = null + IReadOnlyDictionary holeSubprograms = null ) { _config = config; @@ -89,16 +89,9 @@ public sealed class CincinnatiSheetWriter w.WriteLine($"G89 P{cutLibrary}"); w.WriteLine("GOTO1( Goto Feature )"); - // 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last - var nonCutoffParts = plate - .Parts.Where(p => !p.BaseDrawing.IsCutOff) - .OrderBy(p => p.Bottom) - .ThenBy(p => p.Left) - .ToList(); - - var cutoffParts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToList(); - - var allParts = nonCutoffParts.Concat(cutoffParts).ToList(); + // 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(); // 4. Emit parts if (partSubprograms != null) @@ -185,7 +178,16 @@ public sealed class CincinnatiSheetWriter if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { CincinnatiHoleCallWriter.Write( - w, holeCall, featureIndex, isLastFeature, _config, _fmt, _holeSubprograms + w, + holeCall, + part.Location, + featureIndex, + isLastFeature, + _config, + _fmt, + _holeSubprograms, + isNewPart && f == 0 ? partName : null, + isSafetyHeadraise && f == 0 ); featureIndex++; lastPartName = partName; @@ -304,7 +306,16 @@ public sealed class CincinnatiSheetWriter if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { CincinnatiHoleCallWriter.Write( - w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms + w, + holeCall, + part.Location, + i, + isLastFeature, + _config, + _fmt, + _holeSubprograms, + isFirstFeatureOfPart ? partName : null, + isSafetyHeadraise ); lastPartName = partName; continue; diff --git a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs index 29718b8e..2f20de54 100644 --- a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs +++ b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs @@ -83,29 +83,41 @@ public static class FeatureUtils public static List<(List codes, bool isEtch)> SplitAndClassify(Part part) { part.Program.Mode = Mode.Absolute; - var codes = part.Program.Codes; - - // If no leading rapid, the first contour segment would be lost because - // the feature writer pierces at the first motion endpoint. Insert a - // synthetic rapid at the contour's return point to preserve closure. - if (codes.Count > 0 && codes[0] is not RapidMove) - { - for (var i = codes.Count - 1; i >= 0; i--) - { - if (codes[i] is Motion lastMotion) - { - var withRapid = new List(codes.Count + 1); - withRapid.Add(new RapidMove(lastMotion.EndPoint)); - withRapid.AddRange(codes); - codes = withRapid; - break; - } - } - } + var codes = new List(part.Program.Codes); + InsertLeadingRapid(codes); return ClassifyAndOrder(SplitByRapids(codes)); } + /// + /// 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 + /// rapid at the contour's return point (the last motion endpoint) just before it. + /// Hole calls ahead of it are self-contained features and stay in place: a program + /// that opens with a hole call followed by a rapid needs no synthetic rapid, which + /// would otherwise post an empty pierce. + /// + public static void InsertLeadingRapid(List codes) + { + var firstMotion = codes.FindIndex(code => code is Motion); + if (firstMotion < 0 || codes[firstMotion] is RapidMove) + return; + + var lastMotion = (Motion)codes.FindLast(code => code is Motion); + codes.Insert(firstMotion, new RapidMove(lastMotion.EndPoint)); + } + + /// + /// Returns an absolute-mode copy of , leaving the + /// caller's program (and its mode) untouched. + /// + public static Program ToAbsoluteCopy(Program program) + { + var copy = (Program)program.Clone(); + copy.Mode = Mode.Absolute; + return copy; + } + /// /// Returns true if any non-rapid move in the feature has LayerType.Scribe. /// diff --git a/docs/cincinnati-post-output.md b/docs/cincinnati-post-output.md index 0633e9fc..10c79f44 100644 --- a/docs/cincinnati-post-output.md +++ b/docs/cincinnati-post-output.md @@ -32,15 +32,20 @@ A generated file contains, in order: 3. **Sheet sub-programs** (`CincinnatiSheetWriter.Write`), one per unique plate layout. A sheet sub-program contains the cutting sequence for a whole plate, either with features inlined or with `M98` calls into part - sub-programs. + sub-programs. Parts, cut-offs included, post in plate order, which is the + cut sequence set by manual, cut-first or automatic sequencing; the post + never reorders them. 4. **Part sub-programs** (`CincinnatiPartSubprogramWriter.Write`), one per unique `(drawing, rotation)` pair, only emitted when `Config.UsePartSubprograms` is enabled. 5. **Hole sub-programs** (`CincinnatiPartSubprogramWriter.Write` reused with a - `"HOLE"` label), one per unique hole geometry keyed by radius and lead-in - normal angle. + `"HOLE"` label), one per unique posted hole toolpath. Calls are matched to + sub-programs by their program reference, never by call id: lead-in planning + numbers holes 1, 2, ... within each drawing, so ids collide across drawings. + Hole programs are stored incremental; the post writes absolute-mode copies + (everything posts under `G90`) and leaves the nest's programs unchanged. Sub-program bodies start with a `:` label and end with `M99`. @@ -110,11 +115,18 @@ feature's end to that pierce. ``` G52 X Y ; shift local origin to hole center (§1.52) +( PART: ) ; only when the hole is the part's first feature M98 P ; call the shared hole sub-program (§3.98) G52 X0 Y0 ; restore the original coordinate system (§1.52) M47 ; head raise unless this is the last feature (§3.47) ``` +`` is the hole center in the calling program's frame: the part location +plus the call offset in a sheet sub-program, or the call offset alone inside a +part sub-program (whose frame is already the part's). A part that opens with a +hole call takes its safety head raise on that call's `M47`, as an ordinary +first feature would. + G52 specifies the new origin in the current work coordinate system and — per §1.52 — "does not move the cutting nozzle". The hole sub-program is written in hole-local coordinates (origin at the hole center, produced by