diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs index d754449..e803912 100644 --- a/OpenNest.Engine/BestFit/PolygonHelper.cs +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -23,9 +23,17 @@ namespace OpenNest.Engine.BestFit return new PolygonExtractionResult(null, Vector.Zero); // Inflate by half-spacing if spacing is non-zero. - // OffsetSide.Right = outward for CCW perimeters (standard for outer contours). + // Detect winding direction to choose the correct outward offset side. + var outwardSide = OffsetSide.Right; + if (halfSpacing > 0) + { + var testPoly = perimeter.ToPolygon(); + if (testPoly.Vertices.Count >= 3 && testPoly.RotationDirection() == RotationType.CW) + outwardSide = OffsetSide.Left; + } + var inflated = halfSpacing > 0 - ? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter) + ? (perimeter.OffsetEntity(halfSpacing, outwardSide) as Shape ?? perimeter) : perimeter; // Convert to polygon with circumscribed arcs for tight nesting. diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs index 729c08c..f5877ea 100644 --- a/OpenNest.Mcp/Tools/InputTools.cs +++ b/OpenNest.Mcp/Tools/InputTools.cs @@ -57,6 +57,35 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } + [McpServerTool(Name = "save_nest")] + [Description("Save the current session (all drawings and plates) to a .nest file.")] + public string SaveNest( + [Description("Absolute path for the output .nest file")] string path, + [Description("Name for the nest (optional)")] string name = null) + { + var nest = new Nest(); + nest.Name = name ?? Path.GetFileNameWithoutExtension(path); + + foreach (var drawing in _session.AllDrawings()) + nest.Drawings.Add(drawing); + + foreach (var plate in _session.AllPlates()) + nest.Plates.Add(plate); + + if (nest.Drawings.Count == 0) + return "Error: no drawings in session to save"; + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var writer = new NestWriter(nest); + if (!writer.Write(path)) + return "Error: failed to write nest file"; + + return $"Saved nest to {path}\n Drawings: {nest.Drawings.Count}\n Plates: {nest.Plates.Count}"; + } + [McpServerTool(Name = "import_dxf")] [Description("Import a DXF file as a new drawing. Returns drawing name and bounding box.")] public string ImportDxf( diff --git a/OpenNest.Tests/PolygonHelperTests.cs b/OpenNest.Tests/PolygonHelperTests.cs index 494f368..0efb0f7 100644 --- a/OpenNest.Tests/PolygonHelperTests.cs +++ b/OpenNest.Tests/PolygonHelperTests.cs @@ -36,6 +36,47 @@ public class PolygonHelperTests $"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}"); } + [Fact] + public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCWWinding() + { + // CW winding (standard CNC convention): (0,0)→(0,10)→(10,10)→(10,0)→(0,0) + var drawing = TestHelpers.MakeSquareDrawing(10); + var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1); + + noSpacing.Polygon.UpdateBounds(); + withSpacing.Polygon.UpdateBounds(); + + Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width, + $"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}"); + Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length, + $"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}"); + } + + [Fact] + public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCCWWinding() + { + // CCW winding: (0,0)→(10,0)→(10,10)→(0,10)→(0,0) + var pgm = new CNC.Program(); + pgm.Codes.Add(new CNC.RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 0))); + var drawing = new Drawing("ccw-square", pgm); + + var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1); + + noSpacing.Polygon.UpdateBounds(); + withSpacing.Polygon.UpdateBounds(); + + Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width, + $"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}"); + Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length, + $"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}"); + } + [Fact] public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing() { diff --git a/tools/NestBuilder/NestBuilder.csproj b/tools/NestBuilder/NestBuilder.csproj new file mode 100644 index 0000000..af203d1 --- /dev/null +++ b/tools/NestBuilder/NestBuilder.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0-windows + + + + + + diff --git a/tools/NestBuilder/Program.cs b/tools/NestBuilder/Program.cs new file mode 100644 index 0000000..08c1d99 --- /dev/null +++ b/tools/NestBuilder/Program.cs @@ -0,0 +1,209 @@ +using System; +using System.Drawing; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.Converters; +using OpenNest.Geometry; +using Size = OpenNest.Geometry.Size; +using OpenNest.IO; + +var partColors = new Color[] +{ + Color.FromArgb(205, 92, 92), // Indian Red + Color.FromArgb(148, 103, 189), // Medium Purple + Color.FromArgb(75, 180, 175), // Teal + Color.FromArgb(210, 190, 75), // Goldenrod + Color.FromArgb(190, 85, 175), // Orchid + Color.FromArgb(185, 115, 85), // Sienna + Color.FromArgb(120, 100, 190), // Slate Blue + Color.FromArgb(200, 100, 140), // Rose + Color.FromArgb(80, 175, 155), // Sea Green + Color.FromArgb(195, 160, 85), // Dark Khaki + Color.FromArgb(175, 95, 160), // Plum + Color.FromArgb(215, 130, 130), // Light Coral +}; + +var templateDir = @"C:\Users\AJ\Desktop\Projects\OpenNest\docs\Templates"; +var outputDir = @"C:\Users\AJ\Desktop\Projects\OpenNest\docs\Templates\Nests"; +Directory.CreateDirectory(outputDir); + +// BOM: (fileName, qty, thickness, material) +var bom = new (string File, int Qty, double Thickness, string Material)[] +{ + ("PT01", 2, 0.250, "304SS"), + ("PT02", 5, 0.625, "304SS"), + ("PT03", 4, 0.250, "304SS"), + ("PT04", 4, 0.250, "304SS"), + ("PT05", 1, 0.250, "304SS"), + ("PT06", 21, 0.375, "304SS"), + ("PT07", 2, 0.250, "304SS"), + ("PT08", 22, 0.250, "304SS"), + ("PT11", 2, 0.1875, "304SS"), + ("PT12", 2, 0.1875, "304SS"), + ("PT13", 6, 0.1875, "304SS"), + ("PT15", 6, 0.1875, "304SS"), + ("PT16", 6, 0.1875, "304SS"), + ("PT18", 3, 0.250, "304SS"), + ("PT19", 3, 0.1875, "304SS"), + ("PT20", 2, 0.1196, "304SS"), + ("PT21", 6, 0.1196, "304SS"), + ("PT22", 2, 0.1196, "304SS"), + ("PT23", 1, 0.0598, "304SS"), + ("PT24", 1, 0.0598, "304SS"), + ("PT26", 4, 0.250, "304SS"), + ("PT27", 2, 0.250, "304SS"), + ("PT28", 4, 0.250, "304SS"), + ("PT29", 6, 0.250, "304SS"), + ("PT33", 2, 0.250, "304SS"), + ("PT34", 4, 0.250, "304SS"), + ("PT35", 3, 0.1875, "304SS"), + ("PT36", 4, 0.1875, "304SS"), + ("PT37", 4, 0.1875, "304SS"), + ("PT38", 4, 0.1875, "304SS"), + ("PT39", 2, 0.0598, "304SS"), + ("PT40", 4, 0.0598, "304SS"), + ("PT41", 1, 0.1875, "304SS"), + ("PT43", 1, 0.0598, "304SS"), + ("PT44", 1, 0.0598, "304SS"), + ("PT45", 1, 0.250, "304SS"), + ("PT46", 2, 0.250, "304SS"), + ("PT47", 4, 0.250, "304SS"), + ("PT48", 1, 0.250, "304SS"), + ("PT49", 1, 0.750, "PCS"), + ("PT50", 2, 0.375, "PCS"), + ("PT51", 1, 0.250, "304SS"), + ("PT52", 1, 0.1875, "304SS"), + ("PT53", 1, 0.1875, "304SS"), + ("PT54", 2, 0.250, "304SS"), + ("PT55", 1, 0.1196, "304SS"), + ("PT56", 1, 0.1196, "304SS"), + ("PT57", 1, 0.0598, "304SS"), + ("PT58", 1, 0.0598, "304SS"), +}; + +// Group by material + thickness +var groups = bom.GroupBy(b => (b.Material, b.Thickness)).OrderBy(g => g.Key.Material).ThenBy(g => g.Key.Thickness); + +foreach (var group in groups) +{ + var material = group.Key.Material; + var thickness = group.Key.Thickness; + var thicknessLabel = thickness switch + { + 0.0598 => "16GA", + 0.1196 => "11GA", + 0.1875 => "3-16", + 0.250 => "1-4", + 0.375 => "3-8", + 0.625 => "5-8", + 0.750 => "3-4", + _ => thickness.ToString("F4") + }; + + var nestName = $"4526 A14 - {material} {thicknessLabel}"; + Console.WriteLine($"\n=== {nestName} ==="); + + var nest = new Nest(); + nest.Name = nestName; + nest.PlateDefaults.Thickness = thickness; + nest.PlateDefaults.Material = new Material { Name = material }; + nest.PlateDefaults.PartSpacing = 0.125; + nest.PlateDefaults.EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25); + + // Import DXFs for this group + var importer = new DxfImporter(); + var colorIndex = 0; + double maxMinDim = 0; + double maxMaxDim = 0; + + foreach (var item in group) + { + var dxfPath = Path.Combine(templateDir, $"4526 A14 {item.File}.dxf"); + if (!File.Exists(dxfPath)) + { + Console.WriteLine($" WARNING: {dxfPath} not found, skipping"); + continue; + } + + if (!importer.GetGeometry(dxfPath, out var geometry) || geometry.Count == 0) + { + Console.WriteLine($" WARNING: no geometry in {item.File}, skipping"); + continue; + } + + var pgm = ConvertGeometry.ToProgram(geometry); + if (pgm == null) + { + Console.WriteLine($" WARNING: failed to convert {item.File}, skipping"); + continue; + } + + var drawing = new Drawing(item.File, pgm); + drawing.Quantity.Required = item.Qty; + drawing.Material = new Material { Name = material }; + drawing.Color = partColors[colorIndex % partColors.Length]; + colorIndex++; + nest.Drawings.Add(drawing); + + var bbox = pgm.BoundingBox(); + var minDim = System.Math.Min(bbox.Width, bbox.Length); + var maxDim = System.Math.Max(bbox.Width, bbox.Length); + maxMinDim = System.Math.Max(maxMinDim, minDim); + maxMaxDim = System.Math.Max(maxMaxDim, maxDim); + + Console.WriteLine($" {item.File}: {bbox.Width:F2} x {bbox.Length:F2}, qty={item.Qty}"); + } + + // Choose plate size based on largest part dimensions + // Size(width, length) — width is the short side, length is the long side + // Standard sizes: 48x96, 48x120, 60x120, 60x144, 72x144, 96x120 + double plateW, plateL; + if (maxMinDim <= 47.5 && maxMaxDim <= 95.5) + { + plateW = 48; plateL = 96; + } + else if (maxMinDim <= 47.5 && maxMaxDim <= 119.5) + { + plateW = 48; plateL = 120; + } + else if (maxMinDim <= 59.5 && maxMaxDim <= 119.5) + { + plateW = 60; plateL = 120; + } + else if (maxMinDim <= 59.5 && maxMaxDim <= 143.5) + { + plateW = 60; plateL = 144; + } + else if (maxMinDim <= 71.5 && maxMaxDim <= 143.5) + { + plateW = 72; plateL = 144; + } + else if (maxMinDim <= 95.5 && maxMaxDim <= 119.5) + { + plateW = 96; plateL = 120; + } + else + { + // Fallback: round up to nearest 12" + plateW = System.Math.Ceiling((maxMinDim + 1) / 12.0) * 12; + plateL = System.Math.Ceiling((maxMaxDim + 1) / 12.0) * 12; + } + + // Create one empty plate via PlateDefaults so it inherits settings + nest.PlateDefaults.Size = new Size(plateW, plateL); + var plate = nest.CreatePlate(); + plate.Quantity = 1; + + Console.WriteLine($" Plate size: {plateW} x {plateL} (W x L)"); + Console.WriteLine($" Drawings: {nest.Drawings.Count}"); + + var outputPath = Path.Combine(outputDir, $"{nestName}.nest"); + var writer = new NestWriter(nest); + if (writer.Write(outputPath)) + Console.WriteLine($" Saved: {outputPath}"); + else + Console.WriteLine($" ERROR: failed to save {outputPath}"); +} + +Console.WriteLine("\nDone!");