feat(io): workbook part-quantity reader and cut-only import coverage
PartQuantityReader parses a Parts worksheet (Part Name / Qty Required) with exact-name matching and strict rejection of invalid, fractional, negative, or duplicate quantities. Cut-only import drops case-insensitive ETCH/SCRIBE mark layers before geometry so bend detection cannot regenerate them. Tests cover both.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using CSMath;
|
||||
using OpenNest.CNC;
|
||||
using CadLayer = ACadSharp.Tables.Layer;
|
||||
using CadLine = ACadSharp.Entities.Line;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class CutOnlyImportTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ETCH")]
|
||||
[InlineData("Scribe")]
|
||||
[InlineData("sCrIbE")]
|
||||
public void CutOnlyImportDropsMarksBeforeGeometryAndDoesNotRegenerateBends(string layer)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"opennest-cut-only-{Guid.NewGuid()}.dxf");
|
||||
try
|
||||
{
|
||||
var doc = new CadDocument();
|
||||
var points = new[]
|
||||
{
|
||||
new XYZ(0, 0, 0),
|
||||
new XYZ(10, 0, 0),
|
||||
new XYZ(10, 10, 0),
|
||||
new XYZ(0, 10, 0),
|
||||
};
|
||||
for (var i = 0; i < points.Length; i++)
|
||||
doc.Entities.Add(new CadLine(points[i], points[(i + 1) % points.Length]));
|
||||
// Marks outside the perimeter must not affect nesting bounds or become cut paths.
|
||||
doc.Entities.Add(
|
||||
new CadLine(new XYZ(-5, 5, 0), new XYZ(15, 5, 0)) { Layer = new CadLayer(layer) }
|
||||
);
|
||||
doc.Entities.Add(
|
||||
new CadLine(new XYZ(0, 5, 0), new XYZ(10, 5, 0))
|
||||
{
|
||||
Layer = new CadLayer("BEND"),
|
||||
LineType = new ACadSharp.Tables.LineType("CENTER"),
|
||||
}
|
||||
);
|
||||
DxfWriter.Write(path, doc, false);
|
||||
var drawing = CadImporter.ImportDrawing(
|
||||
path,
|
||||
new CadImportOptions { DetectBends = false, Quantity = 3 }
|
||||
);
|
||||
Assert.Empty(drawing.Bends);
|
||||
Assert.Equal(4, drawing.SourceEntities.Count);
|
||||
Assert.Equal(3, drawing.Quantity.Required);
|
||||
Assert.Equal(100, drawing.Area, 6);
|
||||
Assert.DoesNotContain(
|
||||
drawing.Program.Codes.OfType<LinearMove>(),
|
||||
m => m.Layer == LayerType.Scribe
|
||||
);
|
||||
var job = new NestJob(
|
||||
new[] { DrawingJobMapper.FromDrawing("part", drawing, 3) },
|
||||
new[] { new NestPlateStock("sheet", new OpenNest.Geometry.Size(30, 30), 1, 0.3) }
|
||||
);
|
||||
var result = new FixedStrategyNestingEngine("Strip").Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(3, Assert.Single(result.Fulfillment).Placed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using ClosedXML.Excel;
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class PartQuantityReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsExactNamesAndQuantitiesIncludingZeroDemand()
|
||||
{
|
||||
WithWorkbook(
|
||||
sheet =>
|
||||
{
|
||||
sheet.Cell(2, 1).Value = "Part 01";
|
||||
sheet.Cell(2, 2).Value = 58;
|
||||
sheet.Cell(3, 1).Value = "Skeleton";
|
||||
sheet.Cell(3, 2).Value = 0;
|
||||
},
|
||||
path =>
|
||||
{
|
||||
var result = PartQuantityReader.Read(path);
|
||||
Assert.Equal(58, result["Part 01"]);
|
||||
Assert.Equal(0, result["Skeleton"]);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.5")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("")]
|
||||
[InlineData("2147483648")]
|
||||
public void RejectsInvalidQuantityInsteadOfDefaultingOrTruncating(string value)
|
||||
{
|
||||
WithWorkbook(
|
||||
sheet =>
|
||||
{
|
||||
sheet.Cell(2, 1).Value = "Part";
|
||||
sheet.Cell(2, 2).Value = value;
|
||||
},
|
||||
path => Assert.Throws<InvalidDataException>(() => PartQuantityReader.Read(path))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void RejectsDuplicatePartsOrMissingQuantityHeader(bool duplicate)
|
||||
{
|
||||
WithWorkbook(
|
||||
sheet =>
|
||||
{
|
||||
sheet.Cell(2, 1).Value = "Part";
|
||||
sheet.Cell(2, 2).Value = 1;
|
||||
if (duplicate)
|
||||
{
|
||||
sheet.Cell(3, 1).Value = "Part";
|
||||
sheet.Cell(3, 2).Value = 2;
|
||||
}
|
||||
else
|
||||
sheet.Cell(1, 2).Value = "Unrecognized";
|
||||
},
|
||||
path => Assert.Throws<InvalidDataException>(() => PartQuantityReader.Read(path))
|
||||
);
|
||||
}
|
||||
|
||||
private static void WithWorkbook(Action<IXLWorksheet> prepare, Action<string> check)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"opennest-quantities-{Guid.NewGuid()}.xlsx");
|
||||
try
|
||||
{
|
||||
using (var workbook = new XLWorkbook())
|
||||
{
|
||||
var sheet = workbook.AddWorksheet("Parts");
|
||||
sheet.Cell(1, 1).Value = "Part Name";
|
||||
sheet.Cell(1, 2).Value = "Qty Required";
|
||||
prepare(sheet);
|
||||
workbook.SaveAs(path);
|
||||
}
|
||||
check(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>Strict demand reader for Parts sheets with Part Name and Qty Required columns.</summary>
|
||||
public static class PartQuantityReader
|
||||
{
|
||||
public static IReadOnlyDictionary<string, int> Read(string path)
|
||||
{
|
||||
using var workbook = new XLWorkbook(path);
|
||||
if (!workbook.TryGetWorksheet("Parts", out var sheet))
|
||||
throw new InvalidDataException("Workbook must contain a Parts worksheet.");
|
||||
var nameColumn = FindColumn(sheet, "Part Name");
|
||||
var quantityColumn = FindColumn(sheet, "Qty Required");
|
||||
var quantities = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
for (var row = 2; row <= lastRow; row++)
|
||||
{
|
||||
var nameCell = sheet.Cell(row, nameColumn);
|
||||
var quantityCell = sheet.Cell(row, quantityColumn);
|
||||
if (nameCell.IsEmpty() && quantityCell.IsEmpty())
|
||||
continue;
|
||||
var name = nameCell.GetString();
|
||||
// Do not truncate fractional quantities, infer a default, or silently normalize identifiers.
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(name)
|
||||
|| !double.TryParse(
|
||||
quantityCell.GetString(),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var number
|
||||
)
|
||||
|| !double.IsFinite(number)
|
||||
|| number < 0
|
||||
|| number > int.MaxValue
|
||||
|| number != System.Math.Truncate(number)
|
||||
)
|
||||
throw new InvalidDataException(
|
||||
$"Invalid part name or nonnegative integer quantity at Parts row {row}."
|
||||
);
|
||||
if (!quantities.TryAdd(name, (int)number))
|
||||
throw new InvalidDataException($"Duplicate part name at Parts row {row}: {name}");
|
||||
}
|
||||
if (!quantities.Values.Any(q => q > 0))
|
||||
throw new InvalidDataException("Workbook contains no positive part demand.");
|
||||
return quantities;
|
||||
}
|
||||
|
||||
private static int FindColumn(IXLWorksheet sheet, string name)
|
||||
{
|
||||
var matches = sheet.Row(1).CellsUsed().Where(c => c.GetString() == name).ToList();
|
||||
if (matches.Count != 1)
|
||||
throw new InvalidDataException($"Parts must contain exactly one '{name}' column.");
|
||||
return matches[0].Address.ColumnNumber;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user