From c219ec4e6639f85a5316c8fc3dc3cb7ad92ee194 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 17:26:40 -0400 Subject: [PATCH] feat(posts): CI Fiber one program per sheet, configurable pallet change Multi-sheet nests previously went into one program with a single header size and one M50 at the end, so sheet 2 would cut into sheet 1's skeleton. New Sheets settings: - One program per sheet (default on): JOB.cnc -> JOB-1.cnc, JOB-2.cnc, each a full program with its own size and pallet change (CL-series batch rule, EM-423 7.4). Single-sheet nests keep the chosen name. - Off: one program, with /L "L0" + pallet change between sheets; mixed sheet sizes are rejected. - Pallet change code (default M50, unconfirmed for multi-sheet CI Fiber runs; documented as a release blocker). All sheets are validated and rendered before any file is written. IMultiFilePostProcessor lets the desktop app confirm overwrites of every target file and list what was saved, and the console print each file. --- OpenNest.Console/Program.cs | 7 +- OpenNest.Core/IMultiFilePostProcessor.cs | 17 ++ .../CIFiberPostProcessorTests.cs | 203 ++++++++++++++++++ .../PostSettings/PostSettingsLayoutTests.cs | 8 +- OpenNest/Forms/MainForm.cs | 61 ++++++ .../CIFiberPostConfig.cs | 41 +++- .../CIFiberPostProcessor.cs | 101 +++++++-- .../CIFiberProgramWriter.cs | 93 ++++++-- .../OpenNest.Posts.CincinnatiCIFiber.json | 2 + docs/cincinnati-ci-fiber-post-output.md | 38 ++-- 10 files changed, 520 insertions(+), 51 deletions(-) create mode 100644 OpenNest.Core/IMultiFilePostProcessor.cs diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 9438410..aecc222 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -669,8 +669,13 @@ static class NestConsole ); } + var outputFiles = post is IMultiFilePostProcessor multiFile + ? multiFile.GetOutputFiles(nest, outputFile) + : new[] { outputFile }; + post.Post(nest, outputFile); - Console.WriteLine($"Post: {post.Name} -> {outputFile}"); + foreach (var file in outputFiles) + Console.WriteLine($"Post: {post.Name} -> {file}"); } static void PrintUsage() diff --git a/OpenNest.Core/IMultiFilePostProcessor.cs b/OpenNest.Core/IMultiFilePostProcessor.cs new file mode 100644 index 0000000..4f7e7e9 --- /dev/null +++ b/OpenNest.Core/IMultiFilePostProcessor.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace OpenNest +{ + /// + /// A post-processor whose + /// can write more than one file (for example one program per sheet). + /// + public interface IMultiFilePostProcessor : IPostProcessor + { + /// + /// The files will write + /// for this nest and chosen path, in order, with the current settings. + /// + IReadOnlyList GetOutputFiles(Nest nest, string outputFile); + } +} diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs index 41454c1..9c3eb43 100644 --- a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs +++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs @@ -342,4 +342,207 @@ public class CIFiberPostProcessorTests Assert.Equal(120.0, config.MaxTableX); Assert.Equal("SSN", config.DefaultMaterialCode); } + + private static Nest MakeMultiSheetNest(params (double Width, double Length)[] extraSheets) + { + var nest = MakeSquareWithHoleNest(); + var drawing = nest.Plates[0].Parts[0].BaseDrawing; + foreach (var (width, length) in extraSheets) + { + var plate = new Plate(width, length); + plate.Parts.Add(new Part(drawing, new Vector(3, 4))); + nest.Plates.Add(plate); + } + return nest; + } + + private static string NewTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), $"cifiber-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + return dir; + } + + [Fact] + public void Post_OneProgramPerSheet_WritesNumberedFiles_EachAFullProgram() + { + var nest = MakeMultiSheetNest((48, 96)); + nest.Plates.Add(new Plate(60, 120)); // empty sheet: not posted + var dir = NewTempDir(); + try + { + var chosen = Path.Combine(dir, "JOB.cnc"); + var post = new CIFiberPostProcessor(MakeConfig()); + + var expected = post.GetOutputFiles(nest, chosen); + post.Post(nest, chosen); + + Assert.Equal( + new[] { Path.Combine(dir, "JOB-1.cnc"), Path.Combine(dir, "JOB-2.cnc") }, + expected + ); + Assert.Equal( + expected.OrderBy(f => f), + Directory.GetFiles(dir).OrderBy(f => f) + ); + + var first = Lines(File.ReadAllText(expected[0])); + var second = Lines(File.ReadAllText(expected[1])); + + Assert.Contains("V.E.X_SIZE = 120.000", first); + Assert.Contains("V.E.X_SIZE = 96.000", second); + Assert.Contains("V.E.Y_SIZE = 48.000", second); + Assert.Contains("( Sheet number - 1 )", first); + Assert.Contains("( Sheet number - 2 )", second); + + foreach (var program in new[] { first, second }) + { + // Restart labels start over in every program. + Assert.Contains("N1:", program); + Assert.DoesNotContain("N3:", program); + Assert.Single(program, "M50"); + Assert.Equal( + new[] { "L PROGRAMEND.NC", "M50", "M30", "%", "" }, + program.TakeLast(5) + ); + } + Assert.Contains("G0X3Y3.5", second); // hole pierce at the sheet-2 part location + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Post_SingleProgram_ChangesPalletBetweenSheets() + { + var nest = MakeMultiSheetNest((60, 120), (60, 120)); + var config = MakeConfig(); + config.OneProgramPerSheet = false; + var dir = NewTempDir(); + try + { + var chosen = Path.Combine(dir, "JOB.cnc"); + var post = new CIFiberPostProcessor(config); + + Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen)); + post.Post(nest, chosen); + Assert.Equal(new[] { chosen }, Directory.GetFiles(dir)); + + var lines = Lines(File.ReadAllText(chosen)); + Assert.Equal(3, lines.Count(l => l == "M50")); + Assert.Single(lines, "M30"); + + var sheet2 = lines.IndexOf("( Sheet number - 2 )"); + Assert.Equal( + new[] { "( PART END )", "/L \"L0\"", "M50", "( Sheet number - 2 )" }, + lines.Skip(sheet2 - 3).Take(4) + ); + Assert.True(lines.IndexOf("( Sheet number - 3 )") > sheet2); + + // Restart labels continue across sheets within one program. + Assert.Contains("N6:", lines); + Assert.Contains("V.E.R3=6", lines); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Post_SingleProgram_RejectsMixedSheetSizes_WritingNothing() + { + var nest = MakeMultiSheetNest((48, 96)); + var config = MakeConfig(); + config.OneProgramPerSheet = false; + var dir = NewTempDir(); + try + { + var ex = Assert.Throws(() => + new CIFiberPostProcessor(config).Post(nest, Path.Combine(dir, "JOB.cnc")) + ); + Assert.Contains("One program per sheet", ex.Message); + Assert.Empty(Directory.GetFiles(dir)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Post_OneProgramPerSheet_OversizeSheet_WritesNoFiles() + { + var nest = MakeMultiSheetNest((60, 200)); // second sheet 200 long > 160.25 table + var dir = NewTempDir(); + try + { + var ex = Assert.Throws(() => + new CIFiberPostProcessor(MakeConfig()).Post(nest, Path.Combine(dir, "JOB.cnc")) + ); + Assert.Contains("exceeds maximum table X", ex.Message); + Assert.Empty(Directory.GetFiles(dir)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Post_OneProgramPerSheet_SingleSheet_UsesChosenName() + { + var nest = MakeSquareWithHoleNest(); + var dir = NewTempDir(); + try + { + var chosen = Path.Combine(dir, "JOB.cnc"); + var post = new CIFiberPostProcessor(MakeConfig()); + + Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen)); + post.Post(nest, chosen); + + Assert.Equal(Post(nest), File.ReadAllText(chosen)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Post_PalletChangeCode_IsConfigurable() + { + var custom = MakeConfig(); + custom.PalletChangeCode = " M51 "; + var lines = Lines(Post(MakeSquareWithHoleNest(), custom)); + Assert.Equal(new[] { "L PROGRAMEND.NC", "M51", "M30", "%", "" }, lines.TakeLast(5)); + Assert.DoesNotContain("M50", lines); + + var none = MakeConfig(); + none.PalletChangeCode = ""; + lines = Lines(Post(MakeSquareWithHoleNest(), none)); + Assert.Equal(new[] { "L PROGRAMEND.NC", "M30", "%", "" }, lines.TakeLast(4)); + } + + [Fact] + public void PostToStream_MultiSheetPerSheetMode_Throws() + { + var nest = MakeMultiSheetNest((60, 120)); + var ex = Assert.Throws(() => Post(nest)); + Assert.Contains("file path", ex.Message); + } + + [Fact] + public void SavedConfigWithoutSheetSettings_DefaultsToPerSheetAndM50() + { + var config = System.Text.Json.JsonSerializer.Deserialize( + """{ "MaxTableX": 120.0 }""" + )!; + + Assert.True(config.OneProgramPerSheet); + Assert.Equal("M50", config.PalletChangeCode); + } } diff --git a/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs b/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs index 670287d..d129e40 100644 --- a/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs +++ b/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs @@ -17,7 +17,7 @@ public class PostSettingsLayoutTests Assert.NotNull(sections); Assert.Equal( - new[] { "Machine", "Material", "Program output", "Macros" }, + new[] { "Machine", "Sheets", "Material", "Program output", "Macros" }, sections.Select(s => s.Name) ); Assert.All(sections, s => Assert.NotEmpty(s.Description)); @@ -40,7 +40,11 @@ public class PostSettingsLayoutTests Assert.Equal("Maximum table X", machine[1].Label); Assert.Contains("0 disables", machine[1].Description); - var materials = sections[1].Fields[0]; + var sheets = sections[1].Fields; + Assert.Equal(new[] { "OneProgramPerSheet", "PalletChangeCode" }, sheets.Select(f => f.Name)); + Assert.Equal(PostSettingKind.Boolean, sheets[0].Kind); + + var materials = sections[2].Fields[0]; Assert.Equal(PostSettingKind.StringMap, materials.Kind); Assert.Equal("Material name", materials.KeyHeader); Assert.Equal("Machine code", materials.ValueHeader); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 46da9c4..58028e9 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -1447,10 +1447,71 @@ namespace OpenNest.Forms if (dialog.ShowDialog() == DialogResult.OK) { var path = dialog.FileName; + + if (postProcessor is IMultiFilePostProcessor multiFile) + { + var files = multiFile.GetOutputFiles(activeForm.Nest, path); + if (!ConfirmOverwrite(files, path)) + return; + + try + { + postProcessor.Post(activeForm.Nest, path); + } + catch (Exception ex) + when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) + { + MessageBox.Show( + this, + ex.Message, + postProcessor.Name, + MessageBoxButtons.OK, + MessageBoxIcon.Warning + ); + return; + } + + if (files.Count > 1) + MessageBox.Show( + this, + $"Saved {files.Count} programs, one per sheet:\n\n" + + string.Join("\n", files.Select(Path.GetFileName)) + + $"\n\nin {Path.GetDirectoryName(path)}", + postProcessor.Name, + MessageBoxButtons.OK, + MessageBoxIcon.Information + ); + return; + } + postProcessor.Post(activeForm.Nest, path); } } + /// + /// The save dialog only checks the chosen name; ask before replacing any + /// other existing file a multi-file post will write. + /// + private bool ConfirmOverwrite(IReadOnlyList files, string chosenPath) + { + var existing = files + .Where(f => File.Exists(f) && !string.Equals(f, chosenPath, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (existing.Count == 0) + return true; + + var answer = MessageBox.Show( + this, + "These files already exist and will be replaced:\n\n" + + string.Join("\n", existing.Select(Path.GetFileName)) + + "\n\nReplace them?", + "Confirm Save", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning + ); + return answer == DialogResult.Yes; + } + private void CalculateNestCutTime_Click(object sender, EventArgs e) { if (activeForm == null) diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs index 452acfe..1a4586a 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs @@ -16,23 +16,29 @@ namespace OpenNest.Posts.CincinnatiCIFiber Description = "Which CI Fiber table this configuration drives and the largest sheet it accepts." )] [PostSettingsSection( - MaterialSection, + SheetsSection, 1, + Description = "How a nest with more than one sheet is written, and the code that swaps pallets." + )] + [PostSettingsSection( + MaterialSection, + 2, Description = "Maps OpenNest material names to the controller's V.E.MATERIAL code." )] [PostSettingsSection( OutputSection, - 2, + 3, Description = "Number format, units, and what the posted program includes." )] [PostSettingsSection( MacrosSection, - 3, + 4, Description = "Controller subroutines called around each contour and at program start and end. They must exist on the machine." )] public class CIFiberPostConfig { private const string MachineSection = "Machine"; + private const string SheetsSection = "Sheets"; private const string MaterialSection = "Material"; private const string OutputSection = "Program output"; private const string MacrosSection = "Macros"; @@ -91,6 +97,35 @@ namespace OpenNest.Posts.CincinnatiCIFiber [PostSetting(MachineSection, 2, Minimum = 0, Maximum = 10000, DecimalPlaces = 3)] public double MaxTableY { get; set; } = 81.25; + /// + /// Write each sheet as its own program (NAME-1.cnc, NAME-2.cnc, ...), the + /// Cincinnati convention for batch runs. When false, every sheet goes into + /// one program with a pallet change between sheets. A nest with a single + /// sheet always posts to the chosen file name. + /// + [DisplayName("One program per sheet")] + [Description( + "Save each sheet as its own program: JOB.cnc becomes JOB-1.cnc, JOB-2.cnc, ... " + + "Clear to put every sheet in one program, with a pallet change between sheets. " + + "A single-sheet nest always saves to the chosen name." + )] + [PostSetting(SheetsSection, 0)] + public bool OneProgramPerSheet { get; set; } = true; + + /// + /// Line written to swap pallets: after the last sheet, and between sheets + /// in a single program. M50 comes from the machine sample and the CL-series + /// manual (EM-423 §3.50); it is unconfirmed for multi-sheet CI Fiber runs. + /// Blank writes no pallet change. + /// + [DisplayName("Pallet change code")] + [Description( + "Written after each sheet to swap pallets. M50 matches the machine sample; " + + "confirm it for multi-sheet runs. Blank writes no pallet change." + )] + [PostSetting(SheetsSection, 1)] + public string PalletChangeCode { get; set; } = "M50"; + /// Skippable global subroutine used to cancel comp / park between features. [DisplayName("Layer: cancel (L0)")] [Description("Called before each contour and at the end to cancel compensation and park.")] diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs index 99e4d28..bf6bf8b 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs @@ -16,7 +16,9 @@ namespace OpenNest.Posts.CincinnatiCIFiber /// the configured table envelope. Emits the TF5200 skippable-macro program /// structure of the machine sample (see ). /// - public sealed class CIFiberPostProcessor : IConfigurablePostProcessor + public sealed class CIFiberPostProcessor + : IConfigurablePostProcessor, + IMultiFilePostProcessor { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -70,6 +72,11 @@ namespace OpenNest.Posts.CincinnatiCIFiber return Path.Combine(dir, name + ".json"); } + /// + /// Writes every sheet into one program. A stream holds a single program, + /// so this throws when "One program per sheet" is on and the nest has more + /// than one sheet; use for that. + /// public void Post(Nest nest, Stream outputStream) { if (nest == null) @@ -77,26 +84,86 @@ namespace OpenNest.Posts.CincinnatiCIFiber if (outputStream == null) throw new ArgumentNullException(nameof(outputStream)); - // CRLF file, ASCII (UTF-8 without BOM), matching the machine sample. - var encoding = new UTF8Encoding(false); - using var writer = new StreamWriter( - outputStream, - encoding, - 1024, - leaveOpen: true - ); - new CIFiberProgramWriter(Config).Write(nest, writer); - writer.Flush(); + if (SplitsSheets(nest)) + throw new InvalidOperationException( + "\"One program per sheet\" writes several files; post this nest to a file path." + ); + + var program = Render(w => new CIFiberProgramWriter(Config).Write(nest, w)); + outputStream.Write(program, 0, program.Length); } + /// + /// Writes the program(s) for the nest. With "One program per sheet" and more + /// than one sheet, JOB.cnc becomes JOB-1.cnc, JOB-2.cnc, ...; + /// otherwise the single program goes to . All + /// programs are generated before any file is written, so a validation + /// failure leaves no partial output. + /// public void Post(Nest nest, string outputFile) { - using var fs = new FileStream( - outputFile, - FileMode.Create, - FileAccess.Write - ); - Post(nest, fs); + if (nest == null) + throw new ArgumentNullException(nameof(nest)); + if (string.IsNullOrEmpty(outputFile)) + throw new ArgumentNullException(nameof(outputFile)); + + var files = GetOutputFiles(nest, outputFile); + var programs = new List(); + + if (files.Count == 1) + { + programs.Add(Render(w => new CIFiberProgramWriter(Config).Write(nest, w))); + } + else + { + var sheets = CIFiberProgramWriter.PostedSheets(nest); + var writer = new CIFiberProgramWriter(Config); + writer.Validate(sheets, singleProgram: false); + for (var i = 0; i < sheets.Count; i++) + { + var sheetNumber = i + 1; + var sheet = sheets[i]; + programs.Add(Render(w => writer.WriteSheet(nest, sheet, sheetNumber, w))); + } + } + + for (var i = 0; i < files.Count; i++) + File.WriteAllBytes(files[i], programs[i]); + } + + public IReadOnlyList GetOutputFiles(Nest nest, string outputFile) + { + if (nest == null) + throw new ArgumentNullException(nameof(nest)); + if (string.IsNullOrEmpty(outputFile)) + throw new ArgumentNullException(nameof(outputFile)); + + if (!SplitsSheets(nest)) + return new[] { outputFile }; + + var dir = Path.GetDirectoryName(outputFile) ?? ""; + var name = Path.GetFileNameWithoutExtension(outputFile); + var ext = Path.GetExtension(outputFile); + var count = CIFiberProgramWriter.PostedSheets(nest).Count; + + return Enumerable + .Range(1, count) + .Select(n => Path.Combine(dir, $"{name}-{n}{ext}")) + .ToList(); + } + + private bool SplitsSheets(Nest nest) => + Config.OneProgramPerSheet && CIFiberProgramWriter.PostedSheets(nest).Count > 1; + + // CRLF file, ASCII (UTF-8 without BOM), matching the machine sample. + private static byte[] Render(Action write) + { + using var ms = new MemoryStream(); + using (var writer = new StreamWriter(ms, new UTF8Encoding(false), 1024, leaveOpen: true)) + { + write(writer); + } + return ms.ToArray(); } } } diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs index 64c680e..a51120f 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs @@ -26,6 +26,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber /// $GOTO NP3: /// N0: /// ( Sheet number - 1 ) + /// ... (single-program mode only: /L "L0", pallet change, next sheet) /// ( Part #k ) ( PART:... ) V.E.R4=k /// N<n>: /L "L0" V.E.R3=<n> G0X..Y.. /// /L "L2" + G41 | /L "L4" + G42 @@ -36,7 +37,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber /// ( PART END ) /// /L "L0" /// L PROGRAMEND.NC - /// M50 + /// M50 (configurable pallet change) /// M30 /// % /// @@ -57,14 +58,66 @@ namespace OpenNest.Posts.CincinnatiCIFiber _fmt = new CIFiberFormatter(config.PostedAccuracy); } + /// The plates that are posted: every plate with parts, in nest order. + public static IReadOnlyList PostedSheets(Nest nest) => + nest?.Plates.Where(p => p.Parts.Count > 0).ToList() + ?? throw new ArgumentNullException(nameof(nest)); + + /// + /// Writes every posted sheet into one program, with a pallet change + /// between sheets and after the last. + /// public void Write(Nest nest, TextWriter w) { if (nest == null) throw new ArgumentNullException(nameof(nest)); - var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList(); + Write(nest, PostedSheets(nest), 1, w); + } - WriteHeader(nest, w); + /// + /// Writes one sheet as a complete program. Contour labels restart at 1; + /// only labels the sheet comment. + /// + public void WriteSheet(Nest nest, Plate sheet, int sheetNumber, TextWriter w) + { + if (nest == null) + throw new ArgumentNullException(nameof(nest)); + if (sheet == null) + throw new ArgumentNullException(nameof(sheet)); + + Write(nest, new[] { sheet }, sheetNumber, w); + } + + /// + /// Throws if any sheet exceeds the table, or if a single program would hold + /// sheets of different sizes (its header carries only one size). + /// + public void Validate(IReadOnlyList sheets, bool singleProgram) + { + foreach (var sheet in sheets) + _config.ValidateTableSize(sheet.Size.Length, sheet.Size.Width); + + if (!singleProgram || sheets.Count < 2) + return; + + var first = sheets[0].Size; + if (sheets.Any(p => !SameSize(p.Size, first))) + throw new InvalidOperationException( + "The sheets are different sizes, but one program has a single sheet " + + "size in its header. Turn on \"One program per sheet\" to post them." + ); + } + + private static bool SameSize(Size a, Size b) => + System.Math.Abs(a.Length - b.Length) < 1e-6 && System.Math.Abs(a.Width - b.Width) < 1e-6; + + private void Write(Nest nest, IReadOnlyList sheets, int firstSheetNumber, TextWriter w) + { + // Check every sheet before writing anything. + Validate(sheets, singleProgram: true); + + WriteHeader(nest, sheets.Count > 0 ? sheets[0] : nest.Plates.FirstOrDefault(), w); CIFiberFormatter.Line(w, "G90"); CIFiberFormatter.Line(w, $"L {_config.ProgramStartMacro}"); @@ -73,19 +126,23 @@ namespace OpenNest.Posts.CincinnatiCIFiber CIFiberFormatter.Line(w, "N0:"); var contourNumber = 0; - for (var s = 0; s < plates.Count; s++) + for (var s = 0; s < sheets.Count; s++) { - var plate = plates[s]; - _config.ValidateTableSize(plate.Size.Length, plate.Size.Width); + if (s > 0) + { + // Park, then swap pallets before the next sheet. + CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel)); + WritePalletChange(w); + } - CIFiberFormatter.Line(w, $"( Sheet number - {s + 1} )"); - contourNumber = WriteSheet(plate, w, contourNumber); + CIFiberFormatter.Line(w, $"( Sheet number - {firstSheetNumber + s} )"); + contourNumber = WriteSheet(sheets[s], w, contourNumber); } WriteTail(w); } - private void WriteHeader(Nest nest, TextWriter w) + private void WriteHeader(Nest nest, Plate firstSheet, TextWriter w) { CIFiberFormatter.Line(w, $"( {nest.Name ?? ""} )"); CIFiberFormatter.Line(w, $"( CONFIGURATION - {_config.ConfigurationName} )"); @@ -106,12 +163,10 @@ namespace OpenNest.Posts.CincinnatiCIFiber CIFiberFormatter.Line(w, $"V.E.MATERIAL = \"{code}\""); CIFiberFormatter.Line(w, $"V.E.THICKNESS = {_fmt.Fixed(nest.Thickness)}"); - // Multi-plate nests run one sheet after another in this program; - // the header size describes the first sheet (the machine sample - // carries a single sheet). - var firstPlate = nest.Plates.FirstOrDefault(); - var xSize = firstPlate?.Size.Length ?? 0.0; - var ySize = firstPlate?.Size.Width ?? 0.0; + // A single program holding several sheets still carries only the + // first sheet's size (the machine sample has one sheet). + var xSize = firstSheet?.Size.Length ?? 0.0; + var ySize = firstSheet?.Size.Width ?? 0.0; CIFiberFormatter.Line(w, $"V.E.X_SIZE = {_fmt.Fixed(xSize)}"); CIFiberFormatter.Line(w, $"V.E.Y_SIZE = {_fmt.Fixed(ySize)}"); @@ -128,11 +183,17 @@ namespace OpenNest.Posts.CincinnatiCIFiber { CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel)); CIFiberFormatter.Line(w, $"L {_config.ProgramEndMacro}"); - CIFiberFormatter.Line(w, "M50"); + WritePalletChange(w); CIFiberFormatter.Line(w, "M30"); CIFiberFormatter.Line(w, "%"); } + private void WritePalletChange(TextWriter w) + { + if (!string.IsNullOrWhiteSpace(_config.PalletChangeCode)) + CIFiberFormatter.Line(w, _config.PalletChangeCode.Trim()); + } + private int WriteSheet(Plate plate, TextWriter w, int contourNumber) { // Cut-offs run last: severing the sheet first would free the diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json index 56077bf..5770b6b 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json @@ -7,6 +7,8 @@ "SkipScribe": true, "MaxTableX": 160.25, "MaxTableY": 81.25, + "OneProgramPerSheet": true, + "PalletChangeCode": "M50", "LayerCancel": "L0", "LayerInteriorLeadin": "L2", "LayerExteriorLeadin": "L4", diff --git a/docs/cincinnati-ci-fiber-post-output.md b/docs/cincinnati-ci-fiber-post-output.md index 710618c..12cfb48 100644 --- a/docs/cincinnati-ci-fiber-post-output.md +++ b/docs/cincinnati-ci-fiber-post-output.md @@ -19,11 +19,13 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API: `V.E.X_SIZE`, `V.E.Y_SIZE`, and `V.E.UNIT`. Sheet weight is omitted by default. - Startup: `G90`, `L PROGRAMSTART.NC`, then `P3=V.E.R3`, `$GOTO NP3:`, and `N0:`. - Parts follow plate order. `V.E.R4` identifies the part within its sheet; - numbered contour labels and `V.E.R3` continue across sheets for restart lookup. + numbered contour labels and `V.E.R3` count up through the program for restart + lookup (they restart at 1 in each per-sheet program). - Each contour: `/L "L0"`, restart number, rapid to pierce, then interior `/L "L2"` with `G41` or exterior `/L "L4"` with `G42`; linear lead-in, `/L "L6"`, cutting moves, and `/L "ZHSOFF"`. -- Tail: `/L "L0"`, `L PROGRAMEND.NC`, `M50`, `M30`, and `%`. +- Tail: `/L "L0"`, `L PROGRAMEND.NC`, the pallet-change code (`M50`), `M30`, + and `%`. - Motion endpoints are sheet-absolute XY. Arc `I`/`J` are offsets from the arc start, matching the G162 convention; the post does not explicitly emit G162. - Sheet cut-offs post after every part on their sheet. Each segment is an open @@ -42,9 +44,9 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API: See [CIFiberPostConfig](../Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs) for macro names, material mappings, unit codes, precision, and table limits. The post is named for the machine family; table size belongs in configuration. -In the desktop app these settings open in a sectioned editor (Machine, Material, -Program output, Macros) with a material-code table; values are validated and -saved only when OK is pressed. +In the desktop app these settings open in a sectioned editor (Machine, Sheets, +Material, Program output, Macros) with a material-code table; values are +validated and saved only when OK is pressed. - Assign linear lead-ins before posting, including circular holes. The writer rejects missing or arc-first lead-ins. Its conservative rule cites the G238 @@ -56,19 +58,31 @@ saved only when OK is pressed. - `InchUnitCode` defaults to `1`; `MetricUnitCode` defaults to `0` but remains unconfirmed. Table limits compare directly with nest dimensions, without unit conversion. Do not assume changing the unit code establishes metric support. -- Multiple nonempty plates are written sequentially, but dimensional header - values come only from the first plate and the pallet-change tail occurs once. - This is not proof of a qualified multi-sheet machine cycle. -- Validation can throw after output has started. Discard any output from a failed - post; it may be incomplete. Successful generation and tests do not establish +- Plates without parts are not posted. With **One program per sheet** (the + default) and more than one sheet, `JOB.cnc` is written as `JOB-1.cnc`, + `JOB-2.cnc`, ..., each a complete program with its own sheet size and pallet + change. This follows the CL-series batch rule (EM-423 §7.4: one sheet per + program; the operator sets repeats). A single-sheet nest keeps the chosen name. + Plate quantity is not written; repeat the program at the machine. +- With the option off, every sheet goes into one program: `/L "L0"` and the pallet + change separate sheets, and the header carries the first sheet's size. Sheets + of different sizes are rejected in this mode. Posting to a stream always + produces one program, so it rejects a multi-sheet nest in per-sheet mode. +- **Release blocker:** `PalletChangeCode` defaults to `M50`, taken from the + single-sheet machine sample and the CL-series manual (EM-423 §3.50). Neither the + pallet code nor the between-sheet sequence has been confirmed for multi-sheet CI + Fiber runs. Confirm both against a Cincinnati multi-sheet sample before release. +- All programs are generated and every sheet is validated before any file is + written, so a failed file post writes nothing. A failed stream post may leave + partial output; discard it. Successful generation and tests do not establish that a program is safe to run on a particular machine. ## Verification [Output-contract tests](../OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs) cover a square with a hole, coordinate transforms/formatting, lead-in rejection, -cut-off ordering and output, -table bounds, and suppression/scribe handling. Run: +cut-off ordering and output, table bounds, suppression/scribe handling, and +multi-sheet output in both modes. Run: ```sh dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~CincinnatiCIFiber