diff --git a/OpenNest.IO.Tests/CutOnlyImportTests.cs b/OpenNest.IO.Tests/CutOnlyImportTests.cs new file mode 100644 index 0000000..97dccbb --- /dev/null +++ b/OpenNest.IO.Tests/CutOnlyImportTests.cs @@ -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(), + 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); + } + } +} diff --git a/OpenNest.IO.Tests/PartQuantityReaderTests.cs b/OpenNest.IO.Tests/PartQuantityReaderTests.cs new file mode 100644 index 0000000..2c2296a --- /dev/null +++ b/OpenNest.IO.Tests/PartQuantityReaderTests.cs @@ -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(() => 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(() => PartQuantityReader.Read(path)) + ); + } + + private static void WithWorkbook(Action prepare, Action 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); + } + } +} diff --git a/OpenNest.IO/Bom/PartQuantityReader.cs b/OpenNest.IO/Bom/PartQuantityReader.cs new file mode 100644 index 0000000..ae99ad0 --- /dev/null +++ b/OpenNest.IO/Bom/PartQuantityReader.cs @@ -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; + +/// Strict demand reader for Parts sheets with Part Name and Qty Required columns. +public static class PartQuantityReader +{ + public static IReadOnlyDictionary 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(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; + } +}