Files
OpenNest/OpenNest/Forms/BomImportForm.cs
AJ Isaacs d84becdaee fix: add bend detection and etch lines to BOM import path
BOM import was skipping BendDetectorRegistry.AutoDetect and
Bend.UpdateEtchEntities, so parts imported via BOM had no etch
or bend lines. Now matches the CadConverterForm import behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:19:17 -04:00

563 lines
20 KiB
C#

using OpenNest.Bending;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bending;
using OpenNest.IO.Bom;
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public partial class BomImportForm : Form
{
private List<BomPartRow> _parts;
private Dictionary<string, GroupSettings> _groupSettings;
private bool _suppressRegroup;
private Nest.PlateSettings _templateDefaults;
public Form MdiParentForm { get; set; }
public BomImportForm()
{
InitializeComponent();
_parts = new List<BomPartRow>();
_groupSettings = new Dictionary<string, GroupSettings>();
_templateDefaults = LoadTemplateDefaults();
ApplyTemplateDefaults();
}
private Nest.PlateSettings LoadTemplateDefaults()
{
var templatePath = Properties.Settings.Default.NestTemplatePath;
if (File.Exists(templatePath))
{
try
{
var nest = new NestReader(templatePath).Read();
return nest.PlateDefaults;
}
catch { }
}
// Fallback defaults matching CreateDefaultNest
return new Nest.PlateSettings
{
Size = new Geometry.Size(100, 100),
Quadrant = 1,
PartSpacing = 1,
EdgeSpacing = new Spacing(1, 1, 1, 1),
};
}
private void ApplyTemplateDefaults()
{
txtPlateWidth.Text = _templateDefaults.Size.Width.ToString("0.####");
txtPlateLength.Text = _templateDefaults.Size.Length.ToString("0.####");
}
#region File Browsing
private void BrowseBom_Click(object sender, EventArgs e)
{
using var dlg = new OpenFileDialog
{
Title = "Select BOM File",
Filter = "Excel Files|*.xlsx|All Files|*.*",
FilterIndex = 1,
};
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
txtBomFile.Text = dlg.FileName;
if (string.IsNullOrWhiteSpace(txtDxfFolder.Text))
txtDxfFolder.Text = Path.GetDirectoryName(dlg.FileName);
if (string.IsNullOrWhiteSpace(txtJobName.Text))
{
var name = Path.GetFileNameWithoutExtension(dlg.FileName);
if (name.EndsWith(" BOM", StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - 4).TrimEnd();
txtJobName.Text = name;
}
}
private void BrowseDxf_Click(object sender, EventArgs e)
{
using var dlg = new FolderBrowserDialog
{
Description = "Select DXF Folder",
SelectedPath = txtDxfFolder.Text,
};
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
txtDxfFolder.Text = dlg.SelectedPath;
}
#endregion
#region Analyze
private void Analyze_Click(object sender, EventArgs e)
{
if (!File.Exists(txtBomFile.Text))
{
MessageBox.Show("BOM file does not exist.", "Validation Error",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
List<BomItem> items;
using (var reader = new BomReader(txtBomFile.Text))
items = reader.GetItems();
var analysis = BomAnalyzer.Analyze(items, txtDxfFolder.Text);
BuildPartRows(items, analysis);
PopulatePartsGrid();
RebuildGroups();
UpdateSummary();
btnCreateNests.Enabled = true;
tabControl.SelectedTab = tabParts;
}
catch (Exception ex)
{
MessageBox.Show($"Error reading BOM: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void BuildPartRows(List<BomItem> items, BomAnalysis analysis)
{
var matchedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in analysis.Groups)
foreach (var part in group.Parts)
if (part.DxfPath != null)
matchedPaths[part.Item.FileName ?? ""] = part.DxfPath;
_parts = new List<BomPartRow>();
foreach (var item in items)
{
var row = new BomPartRow
{
ItemNum = item.ItemNum,
FileName = item.FileName,
Qty = item.Qty,
Description = item.Description,
Material = item.Material,
Thickness = item.Thickness,
};
if (string.IsNullOrWhiteSpace(item.FileName))
{
row.Status = "Skipped";
row.IsEditable = false;
}
else
{
var lookupName = item.FileName;
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
{
row.DxfPath = dxfPath;
row.Status = "Matched";
row.IsEditable = true;
}
else
{
row.Status = "No DXF";
row.IsEditable = false;
}
}
_parts.Add(row);
}
_groupSettings.Clear();
}
#endregion
#region Parts Tab
private void PopulatePartsGrid()
{
_suppressRegroup = true;
var table = new DataTable();
table.Columns.Add("Item #", typeof(string));
table.Columns.Add("File Name", typeof(string));
table.Columns.Add("Qty", typeof(string));
table.Columns.Add("Description", typeof(string));
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Thickness", typeof(string));
table.Columns.Add("Status", typeof(string));
foreach (var part in _parts)
{
table.Rows.Add(
part.ItemNum?.ToString() ?? "",
part.FileName ?? "",
part.Qty?.ToString() ?? "",
part.Description ?? "",
part.Material ?? "",
part.Thickness?.ToString("0.####") ?? "",
part.Status
);
}
dgvParts.DataSource = table;
// Make non-editable columns read-only
foreach (DataGridViewColumn col in dgvParts.Columns)
{
if (col.Name != "Material" && col.Name != "Thickness")
col.ReadOnly = true;
}
// Style rows by status
for (var i = 0; i < _parts.Count; i++)
{
if (!_parts[i].IsEditable)
{
dgvParts.Rows[i].ReadOnly = true;
dgvParts.Rows[i].DefaultCellStyle.ForeColor = Color.Gray;
}
}
dgvParts.CellValueChanged -= DgvParts_CellValueChanged;
dgvParts.CellValueChanged += DgvParts_CellValueChanged;
_suppressRegroup = false;
}
private void DgvParts_CellValueChanged(object sender, DataGridViewCellEventArgs e)
{
if (_suppressRegroup || e.RowIndex < 0)
return;
var colName = dgvParts.Columns[e.ColumnIndex].Name;
if (colName != "Material" && colName != "Thickness")
return;
var part = _parts[e.RowIndex];
if (!part.IsEditable)
return;
if (colName == "Material")
part.Material = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
if (colName == "Thickness")
{
var text = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
part.Thickness = double.TryParse(text, out var t) ? t : (double?)null;
}
RebuildGroups();
UpdateSummary();
}
#endregion
#region Groups Tab
private void RebuildGroups()
{
// Save existing settings before rebuilding
SaveGroupSettings();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w) ? w : _templateDefaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var l) ? l : _templateDefaults.Size.Length;
var groups = _parts
.Where(p => p.IsEditable
&& !string.IsNullOrWhiteSpace(p.Material)
&& p.Thickness.HasValue)
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value
})
.OrderBy(g => g.First().Material)
.ThenBy(g => g.Key.Thickness)
.ToList();
var table = new DataTable();
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Thickness", typeof(double));
table.Columns.Add("Parts", typeof(int));
table.Columns.Add("Total Qty", typeof(int));
table.Columns.Add("Plate Width", typeof(double));
table.Columns.Add("Plate Length", typeof(double));
table.Columns.Add("Part Spacing", typeof(double));
table.Columns.Add("Edge Left", typeof(double));
table.Columns.Add("Edge Bottom", typeof(double));
table.Columns.Add("Edge Right", typeof(double));
table.Columns.Add("Edge Top", typeof(double));
foreach (var group in groups)
{
var material = group.First().Material;
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var existing = _groupSettings.TryGetValue(key, out var gs);
table.Rows.Add(
material,
thickness,
group.Count(),
group.Sum(p => p.Qty ?? 0),
existing ? gs.PlateWidth : defaultWidth,
existing ? gs.PlateLength : defaultLength,
existing ? gs.PartSpacing : _templateDefaults.PartSpacing,
existing ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left,
existing ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom,
existing ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right,
existing ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top
);
}
dgvGroups.DataSource = table;
// Material, Thickness, Parts, Total Qty are read-only
if (dgvGroups.Columns.Count > 0)
{
dgvGroups.Columns["Material"].ReadOnly = true;
dgvGroups.Columns["Thickness"].ReadOnly = true;
dgvGroups.Columns["Parts"].ReadOnly = true;
dgvGroups.Columns["Total Qty"].ReadOnly = true;
}
btnCreateNests.Enabled = table.Rows.Count > 0;
}
private void SaveGroupSettings()
{
if (dgvGroups.DataSource is not DataTable table)
return;
_groupSettings.Clear();
foreach (DataRow row in table.Rows)
{
var material = row["Material"]?.ToString() ?? "";
var thickness = row["Thickness"] is double t ? t : 0;
var key = GroupKey(material, thickness);
_groupSettings[key] = new GroupSettings
{
PlateWidth = row["Plate Width"] is double pw ? pw : _templateDefaults.Size.Width,
PlateLength = row["Plate Length"] is double pl ? pl : _templateDefaults.Size.Length,
PartSpacing = row["Part Spacing"] is double ps ? ps : _templateDefaults.PartSpacing,
EdgeLeft = row["Edge Left"] is double el ? el : _templateDefaults.EdgeSpacing.Left,
EdgeBottom = row["Edge Bottom"] is double eb ? eb : _templateDefaults.EdgeSpacing.Bottom,
EdgeRight = row["Edge Right"] is double er ? er : _templateDefaults.EdgeSpacing.Right,
EdgeTop = row["Edge Top"] is double et ? et : _templateDefaults.EdgeSpacing.Top,
};
}
}
private static string GroupKey(string material, double thickness)
=> $"{material?.ToUpperInvariant()}|{thickness}";
#endregion
#region Summary
private void UpdateSummary()
{
var skipped = _parts.Count(p => p.Status == "Skipped");
var noDxf = _parts.Count(p => p.Status == "No DXF");
var matched = _parts.Count(p => p.Status == "Matched");
var summaryParts = new List<string>();
if (skipped > 0)
summaryParts.Add($"{skipped} skipped (no file name)");
if (noDxf > 0)
summaryParts.Add($"{noDxf} no DXF found");
lblSummary.Text = summaryParts.Count > 0
? string.Join(", ", summaryParts)
: $"{matched} parts matched";
}
#endregion
#region Create Nests
private void CreateNests_Click(object sender, EventArgs e)
{
if (_parts == null || _parts.Count == 0)
return;
// Save latest group edits
SaveGroupSettings();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw) ? dw : _templateDefaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl) ? dl : _templateDefaults.Size.Length;
var groups = _parts
.Where(p => p.IsEditable
&& !string.IsNullOrWhiteSpace(p.Material)
&& p.Thickness.HasValue
&& !string.IsNullOrWhiteSpace(p.DxfPath))
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value
})
.ToList();
if (groups.Count == 0)
{
MessageBox.Show("No groups with matched DXF files to create nests from.", "Nothing to Create",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var jobName = txtJobName.Text.Trim();
var nestsCreated = 0;
var importErrors = new List<string>();
foreach (var group in groups)
{
var material = group.First().Material;
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var hasSettings = _groupSettings.TryGetValue(key, out var gs);
var plateWidth = hasSettings ? gs.PlateWidth : defaultWidth;
var plateLength = hasSettings ? gs.PlateLength : defaultLength;
var partSpacing = hasSettings ? gs.PartSpacing : _templateDefaults.PartSpacing;
var edgeLeft = hasSettings ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left;
var edgeBottom = hasSettings ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom;
var edgeRight = hasSettings ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right;
var edgeTop = hasSettings ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top;
var nestName = $"{jobName} - {thickness:0.###} {material}";
var nest = new Nest(nestName);
nest.DateCreated = DateTime.Now;
nest.DateLastModified = DateTime.Now;
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
nest.Thickness = thickness;
nest.Material = new Material(material);
nest.PlateDefaults.Quadrant = _templateDefaults.Quadrant;
nest.PlateDefaults.PartSpacing = partSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(edgeLeft, edgeBottom, edgeRight, edgeTop);
foreach (var part in group)
{
if (!File.Exists(part.DxfPath))
{
importErrors.Add($"{part.FileName}: DXF file not found");
continue;
}
try
{
var result = Dxf.Import(part.DxfPath);
var bends = new List<Bend>();
if (result.Document != null)
bends = BendDetectorRegistry.AutoDetect(result.Document);
Bend.UpdateEtchEntities(result.Entities, bends);
var drawingName = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(drawingName);
drawing.Color = Drawing.GetNextColor();
drawing.Source.Path = part.DxfPath;
drawing.Quantity.Required = part.Qty ?? 1;
drawing.Material = new Material(material);
if (bends.Count > 0)
drawing.Bends.AddRange(bends);
var normalized = ShapeProfile.NormalizeEntities(result.Entities);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm.Codes.Count > 0 && pgm[0].Type == CodeType.RapidMove)
{
var rapid = (RapidMove)pgm[0];
drawing.Source.Offset = rapid.EndPoint;
pgm.Offset(-rapid.EndPoint);
}
drawing.Program = pgm;
nest.Drawings.Add(drawing);
}
catch (Exception ex)
{
importErrors.Add($"{part.FileName}: {ex.Message}");
}
}
if (nest.Drawings.Count == 0)
continue;
nest.CreatePlate();
var editForm = new EditNestForm(nest);
editForm.MdiParent = MdiParentForm;
editForm.Show();
editForm.PlateView.ZoomToFit();
nestsCreated++;
}
var summary = $"{nestsCreated} nest{(nestsCreated != 1 ? "s" : "")} created.";
if (importErrors.Count > 0)
summary += $"\n\n{importErrors.Count} import error(s):\n" + string.Join("\n", importErrors);
MessageBox.Show(summary, "Import Complete", MessageBoxButtons.OK,
importErrors.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
Close();
}
#endregion
private void BtnClose_Click(object sender, EventArgs e)
{
Close();
}
}
internal class BomPartRow
{
public int? ItemNum { get; set; }
public string FileName { get; set; }
public int? Qty { get; set; }
public string Description { get; set; }
public string Material { get; set; }
public double? Thickness { get; set; }
public string DxfPath { get; set; }
public string Status { get; set; }
public bool IsEditable { get; set; }
}
internal class GroupSettings
{
public double PlateWidth { get; set; }
public double PlateLength { get; set; }
public double PartSpacing { get; set; }
public double EdgeLeft { get; set; }
public double EdgeBottom { get; set; }
public double EdgeRight { get; set; }
public double EdgeTop { get; set; }
}
}