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.
This commit is contained in:
aj
2026-09-28 18:22:36 -04:00
parent 32586149c6
commit c219ec4e66
10 changed files with 520 additions and 51 deletions
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>
/// 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.
/// </summary>
[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";
/// <summary>Skippable global subroutine used to cancel comp / park between features.</summary>
[DisplayName("Layer: cancel (L0)")]
[Description("Called before each contour and at the end to cancel compensation and park.")]
@@ -16,7 +16,9 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// the configured table envelope. Emits the TF5200 skippable-macro program
/// structure of the machine sample (see <see cref="CIFiberProgramWriter"/>).
/// </summary>
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");
}
/// <summary>
/// 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 <see cref="Post(Nest, string)"/> for that.
/// </summary>
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);
}
/// <summary>
/// Writes the program(s) for the nest. With "One program per sheet" and more
/// than one sheet, <c>JOB.cnc</c> becomes <c>JOB-1.cnc</c>, <c>JOB-2.cnc</c>, ...;
/// otherwise the single program goes to <paramref name="outputFile"/>. All
/// programs are generated before any file is written, so a validation
/// failure leaves no partial output.
/// </summary>
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<byte[]>();
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<string> 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<TextWriter> write)
{
using var ms = new MemoryStream();
using (var writer = new StreamWriter(ms, new UTF8Encoding(false), 1024, leaveOpen: true))
{
write(writer);
}
return ms.ToArray();
}
}
}
@@ -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&lt;n&gt;: /L "L0" V.E.R3=&lt;n&gt; 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
/// %
/// </code>
@@ -57,14 +58,66 @@ namespace OpenNest.Posts.CincinnatiCIFiber
_fmt = new CIFiberFormatter(config.PostedAccuracy);
}
/// <summary>The plates that are posted: every plate with parts, in nest order.</summary>
public static IReadOnlyList<Plate> PostedSheets(Nest nest) =>
nest?.Plates.Where(p => p.Parts.Count > 0).ToList()
?? throw new ArgumentNullException(nameof(nest));
/// <summary>
/// Writes every posted sheet into one program, with a pallet change
/// between sheets and after the last.
/// </summary>
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);
/// <summary>
/// Writes one sheet as a complete program. Contour labels restart at 1;
/// <paramref name="sheetNumber"/> only labels the sheet comment.
/// </summary>
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);
}
/// <summary>
/// Throws if any sheet exceeds the table, or if a single program would hold
/// sheets of different sizes (its header carries only one size).
/// </summary>
public void Validate(IReadOnlyList<Plate> 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<Plate> 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
@@ -7,6 +7,8 @@
"SkipScribe": true,
"MaxTableX": 160.25,
"MaxTableY": 81.25,
"OneProgramPerSheet": true,
"PalletChangeCode": "M50",
"LayerCancel": "L0",
"LayerInteriorLeadin": "L2",
"LayerExteriorLeadin": "L4",