fix: detect winding direction for correct part spacing offset

PolygonHelper.ExtractPerimeterPolygon always used OffsetSide.Right
assuming CCW winding, but DXF imports can produce CW winding. This
caused the spacing polygon to shrink inward instead of expanding
outward, making parts overlap during nesting.

Now detects winding direction via polygon signed area and selects
the correct OffsetSide accordingly.

Also adds save_nest MCP tool and a BOM-to-nest builder utility
(tools/NestBuilder) for batch-creating nest files from Excel BOMs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:57:23 -04:00
parent aeeb2e4074
commit 072915abf2
5 changed files with 299 additions and 2 deletions
+41
View File
@@ -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()
{