feat: add material library resolution, assist gas support, and UI fixes

- Add MaterialLibraryResolver for Cincinnati post processor to resolve
  G89 library files from material/thickness/gas configuration
- Add Nest.AssistGas property with serialization support in nest format
- Add etch library support with separate gas configuration
- Fix CutOff tests to match AwayFromOrigin default cut direction
- Fix plate info label not updating after ResizePlateToFitParts
- Add cutoff and remnants toolbar button icons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 22:00:57 -04:00
parent 072915abf2
commit b970629a59
15 changed files with 327 additions and 29 deletions

View File

@@ -36,6 +36,8 @@ namespace OpenNest
public string Notes { get; set; }
public string AssistGas { get; set; } = "";
public Units Units { get; set; }
public DateTime DateCreated { get; set; }

View File

@@ -23,6 +23,7 @@ namespace OpenNest.IO
public string DateCreated { get; init; } = "";
public string DateLastModified { get; init; } = "";
public string Notes { get; init; } = "";
public string AssistGas { get; init; } = "";
public PlateDefaultsDto PlateDefaults { get; init; } = new();
public List<DrawingDto> Drawings { get; init; } = new();
public List<PlateDto> Plates { get; init; } = new();

View File

@@ -160,6 +160,7 @@ namespace OpenNest.IO
nest.DateCreated = DateTime.Parse(dto.DateCreated);
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
nest.Notes = dto.Notes;
nest.AssistGas = dto.AssistGas ?? "";
// Plate defaults
var pd = dto.PlateDefaults;

View File

@@ -77,6 +77,7 @@ namespace OpenNest.IO
DateCreated = nest.DateCreated.ToString("o"),
DateLastModified = nest.DateLastModified.ToString("o"),
Notes = nest.Notes ?? "",
AssistGas = nest.AssistGas ?? "",
PlateDefaults = BuildPlateDefaultsDto(),
Drawings = BuildDrawingDtos(),
Plates = BuildPlateDtos()

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace OpenNest.Posts.Cincinnati
{
/// <summary>
@@ -153,16 +155,29 @@ namespace OpenNest.Posts.Cincinnati
public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
/// <summary>
/// Gets or sets the default G89 library file path.
/// Default: empty string
/// Gets or sets the default assist gas when Nest.AssistGas is empty.
/// Default: "O2"
/// </summary>
public string DefaultLibraryFile { get; set; } = "";
public string DefaultAssistGas { get; set; } = "O2";
/// <summary>
/// Gets or sets whether to repeat G89 before each feature.
/// Default: true
/// Gets or sets the gas used for etch operations.
/// Independent of the cutting assist gas — etch typically requires a specific gas.
/// Default: "N2"
/// </summary>
public bool RepeatG89BeforeEachFeature { get; set; } = true;
public string DefaultEtchGas { get; set; } = "N2";
/// <summary>
/// Gets or sets the material-to-library mapping for cut operations.
/// Each entry maps (material, thickness, gas) to a G89 library file.
/// </summary>
public List<MaterialLibraryEntry> MaterialLibraries { get; set; } = new();
/// <summary>
/// Gets or sets the gas-to-library mapping for etch operations.
/// Each entry maps a gas type to a G89 etch library file.
/// </summary>
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
/// <summary>
/// Gets or sets whether to use exact stop mode (G61).
@@ -272,4 +287,18 @@ namespace OpenNest.Posts.Cincinnati
/// </summary>
public int SheetLengthVariable { get; set; } = 111;
}
public class MaterialLibraryEntry
{
public string Material { get; set; } = "";
public double Thickness { get; set; }
public string Gas { get; set; } = "";
public string Library { get; set; } = "";
}
public class EtchLibraryEntry
{
public string Gas { get; set; } = "";
public string Library { get; set; } = "";
}
}

View File

@@ -68,7 +68,18 @@ namespace OpenNest.Posts.Cincinnati
.Where(p => p.Parts.Count > 0)
.ToList();
// 3. Build part sub-program registry (if enabled)
// 3. Resolve gas and library files
var resolver = new MaterialLibraryResolver(Config);
var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
// Resolve cut library from first plate for preamble
var firstPlate = plates.FirstOrDefault();
var initialCutLibrary = firstPlate != null
? resolver.ResolveCutLibrary(firstPlate.Material?.Name ?? "", firstPlate.Thickness, gas)
: "";
// 4. Build part sub-program registry (if enabled)
Dictionary<(int, long), int> partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null;
@@ -100,21 +111,21 @@ namespace OpenNest.Posts.Cincinnati
}
}
// 4. Create writers
// 5. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
// 5. Build material description from first plate
var material = plates.FirstOrDefault()?.Material;
// 6. Build material description from first plate
var material = firstPlate?.Material;
var materialDesc = material != null
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
: "";
// 6. Write to stream
// 7. Write to stream
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
// Main program
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count);
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count, initialCutLibrary);
// Variable declaration subprogram
preamble.WriteVariableDeclaration(writer, vars);
@@ -122,17 +133,18 @@ namespace OpenNest.Posts.Cincinnati
// Sheet subprograms
for (var i = 0; i < plates.Count; i++)
{
var plate = plates[i];
var sheetIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i;
sheetWriter.Write(writer, plates[i], nest.Name ?? "NEST", sheetIndex, subNumber,
partSubprograms);
var cutLibrary = resolver.ResolveCutLibrary(plate.Material?.Name ?? "", plate.Thickness, gas);
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber,
cutLibrary, etchLibrary, partSubprograms);
}
// Part sub-programs (if enabled)
if (subprogramEntries != null)
{
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
var firstPlate = plates.FirstOrDefault();
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
@@ -141,7 +153,7 @@ namespace OpenNest.Posts.Cincinnati
foreach (var (subNum, name, pgm) in subprogramEntries)
{
partSubWriter.Write(writer, pgm, name, subNum,
Config.DefaultLibraryFile ?? "", sheetDiagonal);
initialCutLibrary, etchLibrary, sheetDiagonal);
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Posts.Cincinnati;
public sealed class MaterialLibraryResolver
{
private const double ThicknessTolerance = 0.001;
private readonly List<MaterialLibraryEntry> _materialLibraries;
private readonly List<EtchLibraryEntry> _etchLibraries;
public MaterialLibraryResolver(CincinnatiPostConfig config)
{
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
}
public string ResolveCutLibrary(string materialName, double thickness, string gas)
{
var entry = _materialLibraries.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase));
return entry?.Library ?? "";
}
public string ResolveEtchLibrary(string gas)
{
var entry = _etchLibraries.FirstOrDefault(e =>
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase));
return entry?.Library ?? "";
}
public static string ResolveGas(Nest nest, CincinnatiPostConfig config)
{
return !string.IsNullOrEmpty(nest.AssistGas) ? nest.AssistGas : config.DefaultAssistGas;
}
}

View File

@@ -0,0 +1,160 @@
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class MaterialLibraryResolverTests
{
private static CincinnatiPostConfig ConfigWithLibraries() => new()
{
DefaultAssistGas = "O2",
DefaultEtchGas = "N2",
MaterialLibraries = new()
{
new MaterialLibraryEntry { Material = "Mild Steel", Thickness = 0.250, Gas = "O2", Library = "MS250O2.lib" },
new MaterialLibraryEntry { Material = "Mild Steel", Thickness = 0.250, Gas = "N2", Library = "MS250N2.lib" },
new MaterialLibraryEntry { Material = "Aluminum", Thickness = 0.125, Gas = "N2", Library = "AL125N2.lib" },
new MaterialLibraryEntry { Material = "Stainless Steel", Thickness = 0.375, Gas = "AIR", Library = "SS375AIR.lib" }
},
EtchLibraries = new()
{
new EtchLibraryEntry { Gas = "N2", Library = "EtchN2.lib" },
new EtchLibraryEntry { Gas = "O2", Library = "EtchO2.lib" },
new EtchLibraryEntry { Gas = "AIR", Library = "EtchAIR.lib" }
}
};
[Fact]
public void ResolveCutLibrary_ExactMatch()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
Assert.Equal("MS250O2.lib", result);
}
[Fact]
public void ResolveCutLibrary_CaseInsensitiveMaterial()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("mild steel", 0.250, "O2");
Assert.Equal("MS250O2.lib", result);
}
[Fact]
public void ResolveCutLibrary_CaseInsensitiveGas()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "o2");
Assert.Equal("MS250O2.lib", result);
}
[Fact]
public void ResolveCutLibrary_ThicknessWithinTolerance()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Mild Steel", 0.2505, "O2");
Assert.Equal("MS250O2.lib", result);
}
[Fact]
public void ResolveCutLibrary_ThicknessOutsideTolerance_ReturnsEmpty()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Mild Steel", 0.260, "O2");
Assert.Equal("", result);
}
[Fact]
public void ResolveCutLibrary_NoMatch_ReturnsEmpty()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Titanium", 0.250, "O2");
Assert.Equal("", result);
}
[Fact]
public void ResolveCutLibrary_WrongGas_ReturnsEmpty()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "AIR");
Assert.Equal("", result);
}
[Fact]
public void ResolveCutLibrary_DifferentGasSameMaterial()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var o2 = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
var n2 = resolver.ResolveCutLibrary("Mild Steel", 0.250, "N2");
Assert.Equal("MS250O2.lib", o2);
Assert.Equal("MS250N2.lib", n2);
}
[Fact]
public void ResolveCutLibrary_EmptyList_ReturnsEmpty()
{
var config = new CincinnatiPostConfig { MaterialLibraries = new() };
var resolver = new MaterialLibraryResolver(config);
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
Assert.Equal("", result);
}
[Fact]
public void ResolveEtchLibrary_ExactMatch()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveEtchLibrary("N2");
Assert.Equal("EtchN2.lib", result);
}
[Fact]
public void ResolveEtchLibrary_CaseInsensitive()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveEtchLibrary("n2");
Assert.Equal("EtchN2.lib", result);
}
[Fact]
public void ResolveEtchLibrary_NoMatch_ReturnsEmpty()
{
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
var result = resolver.ResolveEtchLibrary("Argon");
Assert.Equal("", result);
}
[Fact]
public void ResolveEtchLibrary_EmptyList_ReturnsEmpty()
{
var config = new CincinnatiPostConfig { EtchLibraries = new() };
var resolver = new MaterialLibraryResolver(config);
var result = resolver.ResolveEtchLibrary("N2");
Assert.Equal("", result);
}
[Fact]
public void ResolveGas_UsesNestAssistGas_WhenSet()
{
var nest = new Nest("Test") { AssistGas = "N2" };
var config = new CincinnatiPostConfig { DefaultAssistGas = "O2" };
var result = MaterialLibraryResolver.ResolveGas(nest, config);
Assert.Equal("N2", result);
}
[Fact]
public void ResolveGas_FallsBackToConfig_WhenNestEmpty()
{
var nest = new Nest("Test") { AssistGas = "" };
var config = new CincinnatiPostConfig { DefaultAssistGas = "O2" };
var result = MaterialLibraryResolver.ResolveGas(nest, config);
Assert.Equal("O2", result);
}
[Fact]
public void ResolveGas_FallsBackToConfig_WhenNestNull()
{
var nest = new Nest("Test");
var config = new CincinnatiPostConfig { DefaultAssistGas = "AIR" };
var result = MaterialLibraryResolver.ResolveGas(nest, config);
Assert.Equal("AIR", result);
}
}

View File

@@ -154,10 +154,10 @@ public class CutOffTests
// Plate(100, 50) = Width=100, Length=50. Vertical cut runs along Y (Width axis).
// BoundingBox Y extent = Size.Width = 100. With 2" overtravel = 102.
// Default TowardOrigin: RapidMove to far end (102), LinearMove to near end (0).
var rapidMoves = cutoff.Drawing.Program.Codes.OfType<RapidMove>().ToList();
Assert.Single(rapidMoves);
Assert.Equal(102.0, rapidMoves[0].EndPoint.Y, 5);
// Default AwayFromOrigin: RapidMove to near end (0), LinearMove to far end (102).
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().ToList();
Assert.Single(linearMoves);
Assert.Equal(102.0, linearMoves[0].EndPoint.Y, 5);
}
[Fact]
@@ -171,11 +171,10 @@ public class CutOffTests
};
cutoff.Regenerate(plate, settings);
// AwayFromOrigin: RapidMove to near end (StartLimit=20), LinearMove to far end (100).
var rapidMoves = cutoff.Drawing.Program.Codes.OfType<RapidMove>().ToList();
Assert.Single(rapidMoves);
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().ToList();
Assert.Single(linearMoves);
Assert.Equal(20.0, linearMoves[0].EndPoint.Y, 5);
Assert.Equal(20.0, rapidMoves[0].EndPoint.Y, 5);
}
[Fact]
@@ -189,9 +188,10 @@ public class CutOffTests
};
cutoff.Regenerate(plate, settings);
var rapidMoves = cutoff.Drawing.Program.Codes.OfType<RapidMove>().ToList();
Assert.Single(rapidMoves);
Assert.Equal(80.0, rapidMoves[0].EndPoint.Y, 5);
// AwayFromOrigin: RapidMove to near end (0), LinearMove to far end (EndLimit=80).
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().ToList();
Assert.Single(linearMoves);
Assert.Equal(80.0, linearMoves[0].EndPoint.Y, 5);
}
[Fact]

View File

@@ -458,6 +458,7 @@ namespace OpenNest.Forms
PlateView.ZoomToPlate();
PlateView.Refresh();
UpdatePlateList();
UpdatePlateHeader();
}
public void SelectAllParts()

View File

@@ -149,6 +149,8 @@
engineComboBox = new System.Windows.Forms.ToolStripComboBox();
btnAutoNest = new System.Windows.Forms.ToolStripButton();
btnShowRemnants = new System.Windows.Forms.ToolStripButton();
toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator();
btnCutOff = new System.Windows.Forms.ToolStripButton();
pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
menuStrip1.SuspendLayout();
@@ -917,7 +919,7 @@
// toolStrip1
//
toolStrip1.AutoSize = false;
toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants });
toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants, toolStripSeparator5, btnCutOff });
toolStrip1.Location = new System.Drawing.Point(0, 24);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new System.Drawing.Size(1281, 40);
@@ -1044,12 +1046,31 @@
//
// btnShowRemnants
//
btnShowRemnants.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
btnShowRemnants.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
btnShowRemnants.Image = Properties.Resources.remnants;
btnShowRemnants.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None;
btnShowRemnants.Name = "btnShowRemnants";
btnShowRemnants.Size = new System.Drawing.Size(64, 37);
btnShowRemnants.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
btnShowRemnants.Size = new System.Drawing.Size(38, 37);
btnShowRemnants.Text = "Remnants";
btnShowRemnants.Click += ShowRemnants_Click;
//
// toolStripSeparator5
//
toolStripSeparator5.Name = "toolStripSeparator5";
toolStripSeparator5.Size = new System.Drawing.Size(6, 40);
//
// btnCutOff
//
btnCutOff.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
btnCutOff.Image = Properties.Resources.cutoff;
btnCutOff.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None;
btnCutOff.Name = "btnCutOff";
btnCutOff.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
btnCutOff.Size = new System.Drawing.Size(38, 37);
btnCutOff.Text = "Sheet Cut-Off";
btnCutOff.Click += CutOff_Click;
//
// pEPToolStripMenuItem
//
pEPToolStripMenuItem.Name = "pEPToolStripMenuItem";
@@ -1213,6 +1234,8 @@
private System.Windows.Forms.ToolStripComboBox engineComboBox;
private System.Windows.Forms.ToolStripButton btnAutoNest;
private System.Windows.Forms.ToolStripButton btnShowRemnants;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator5;
private System.Windows.Forms.ToolStripButton btnCutOff;
private System.Windows.Forms.ToolStripMenuItem mnuPlateCutOff;
}
}

View File

@@ -80,6 +80,16 @@ namespace OpenNest.Properties {
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap cutoff {
get {
object obj = ResourceManager.GetObject("cutoff", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
@@ -160,6 +170,16 @@ namespace OpenNest.Properties {
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap remnants {
get {
object obj = ResourceManager.GetObject("remnants", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>

View File

@@ -175,4 +175,10 @@
<data name="zoom_out" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\zoom_out.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="cutoff" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\cutoff.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="remnants" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\remnants.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB