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