Compare commits

...

17 Commits

Author SHA1 Message Date
aj dde07fc256 merge: resolve polylabel conflicts, keep remote version with hole support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:02:30 -04:00
aj 788996abcf docs: add screenshots to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:58:50 -04:00
aj 224fbde19a feat: add polylabel algorithm for part label positioning and README
Use pole-of-inaccessibility (polylabel) to place part labels at the
visual center of shapes instead of the first path vertex. Labels now
stay correctly positioned regardless of part rotation or shape.

Also adds project README and MIT license.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:52:48 -04:00
aj a0865405e2 refactor(mcp): use shape library in InputTools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:10:13 -04:00
aj 6e5471271d feat(core): add RoundedRectangleShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:07:22 -04:00
aj 0651f185e3 feat(core): add OctagonShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:06:21 -04:00
aj 33377291a6 feat(core): add TShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:06:08 -04:00
aj dfd5a15274 feat(core): add TrapezoidShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:04:15 -04:00
aj 09a7608bcb feat(core): add IsoscelesTriangleShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:03:14 -04:00
aj 92d2d6d2bc feat(core): add RightTriangleShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:02:18 -04:00
aj 641734ba70 feat(core): add RingShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:00:11 -04:00
aj 5d0de4a1b1 feat(core): add CircleShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:59:26 -04:00
aj f92d09a863 feat(core): add RectangleShape
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:58:37 -04:00
aj 5670ae79bf feat(core): add ShapeDefinition base class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:57:54 -04:00
aj aedbbbe0a6 docs: add shape library implementation plan
13-task TDD plan covering ShapeDefinition base class, 10 shape classes
(Rectangle, Circle, Ring, RightTriangle, IsoscelesTriangle, Trapezoid,
Octagon, L, T, RoundedRectangle), and MCP InputTools refactoring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:55:54 -04:00
aj 5d9d48ebfc docs: address spec review feedback for shape library
Add null safety in CreateDrawing helper, default Name from class name,
clarify diameter-vs-radius conventions, octagon width definition,
arc direction for rounded rectangles, and MCP parameter mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:49:34 -04:00
aj eb6cb94893 docs: add shape library design spec
Defines a parametric shape library with abstract ShapeDefinition base
class and concrete subclasses for common CNC shapes (Tier 1+2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:46:44 -04:00
28 changed files with 2092 additions and 75 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AJ Isaacs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20
View File
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class CircleShape : ShapeDefinition
{
public double Diameter { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Circle(0, 0, Diameter / 2.0)
};
return CreateDrawing(entities);
}
}
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class IsoscelesTriangleShape : ShapeDefinition
{
public double Base { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var midX = Base / 2.0;
var entities = new List<Entity>
{
new Line(0, 0, Base, 0),
new Line(Base, 0, midX, Height),
new Line(midX, Height, 0, 0)
};
return CreateDrawing(entities);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class LShape : ShapeDefinition
{
public double Width { get; set; }
public double Height { get; set; }
public double LegWidth { get; set; }
public double LegHeight { get; set; }
public override Drawing GetDrawing()
{
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
var lh = LegHeight > 0 ? LegHeight : Height / 2.0;
var entities = new List<Entity>
{
new Line(0, 0, Width, 0),
new Line(Width, 0, Width, lh),
new Line(Width, lh, lw, lh),
new Line(lw, lh, lw, Height),
new Line(lw, Height, 0, Height),
new Line(0, Height, 0, 0)
};
return CreateDrawing(entities);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class OctagonShape : ShapeDefinition
{
public double Width { get; set; }
public override Drawing GetDrawing()
{
var center = Width / 2.0;
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
var vertices = new Vector[8];
for (var i = 0; i < 8; i++)
{
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
vertices[i] = new Vector(
center + circumRadius * System.Math.Cos(angle),
center + circumRadius * System.Math.Sin(angle));
}
var entities = new List<Entity>();
for (var i = 0; i < 8; i++)
{
var next = (i + 1) % 8;
entities.Add(new Line(vertices[i], vertices[next]));
}
return CreateDrawing(entities);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class RectangleShape : ShapeDefinition
{
public double Width { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Line(0, 0, Width, 0),
new Line(Width, 0, Width, Height),
new Line(Width, Height, 0, Height),
new Line(0, Height, 0, 0)
};
return CreateDrawing(entities);
}
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class RightTriangleShape : ShapeDefinition
{
public double Width { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Line(0, 0, Width, 0),
new Line(Width, 0, 0, Height),
new Line(0, Height, 0, 0)
};
return CreateDrawing(entities);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class RingShape : ShapeDefinition
{
public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Circle(0, 0, OuterDiameter / 2.0),
new Circle(0, 0, InnerDiameter / 2.0)
};
return CreateDrawing(entities);
}
}
}
@@ -0,0 +1,59 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Shapes
{
public class RoundedRectangleShape : ShapeDefinition
{
public double Width { get; set; }
public double Height { get; set; }
public double Radius { get; set; }
public override Drawing GetDrawing()
{
var r = Radius;
var entities = new List<Entity>();
if (r <= 0)
{
entities.Add(new Line(0, 0, Width, 0));
entities.Add(new Line(Width, 0, Width, Height));
entities.Add(new Line(Width, Height, 0, Height));
entities.Add(new Line(0, Height, 0, 0));
}
else
{
// Bottom edge (left to right, above bottom-left arc to bottom-right arc)
entities.Add(new Line(r, 0, Width - r, 0));
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg
entities.Add(new Arc(Width - r, r, r,
Angle.ToRadians(270), Angle.ToRadians(360)));
// Right edge
entities.Add(new Line(Width, r, Width, Height - r));
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg
entities.Add(new Arc(Width - r, Height - r, r,
Angle.ToRadians(0), Angle.ToRadians(90)));
// Top edge (right to left)
entities.Add(new Line(Width - r, Height, r, Height));
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg
entities.Add(new Arc(r, Height - r, r,
Angle.ToRadians(90), Angle.ToRadians(180)));
// Left edge
entities.Add(new Line(0, Height - r, 0, r));
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg
entities.Add(new Arc(r, r, r,
Angle.ToRadians(180), Angle.ToRadians(270)));
}
return CreateDrawing(entities);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public abstract class ShapeDefinition
{
public string Name { get; set; }
protected ShapeDefinition()
{
var typeName = GetType().Name;
Name = typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public abstract Drawing GetDrawing();
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
if (pgm == null)
throw new InvalidOperationException(
$"Failed to create program for shape '{Name}'. Check that parameters produce valid geometry.");
return new Drawing(Name, pgm);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class TShape : ShapeDefinition
{
public double Width { get; set; }
public double Height { get; set; }
public double StemWidth { get; set; }
public double BarHeight { get; set; }
public override Drawing GetDrawing()
{
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
var bh = BarHeight > 0 ? BarHeight : Height / 3.0;
var stemLeft = (Width - sw) / 2.0;
var stemRight = stemLeft + sw;
var stemTop = Height - bh;
var entities = new List<Entity>
{
new Line(stemLeft, 0, stemRight, 0),
new Line(stemRight, 0, stemRight, stemTop),
new Line(stemRight, stemTop, Width, stemTop),
new Line(Width, stemTop, Width, Height),
new Line(Width, Height, 0, Height),
new Line(0, Height, 0, stemTop),
new Line(0, stemTop, stemLeft, stemTop),
new Line(stemLeft, stemTop, stemLeft, 0)
};
return CreateDrawing(entities);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Shapes
{
public class TrapezoidShape : ShapeDefinition
{
public double TopWidth { get; set; }
public double BottomWidth { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var offset = (BottomWidth - TopWidth) / 2.0;
var entities = new List<Entity>
{
new Line(0, 0, BottomWidth, 0),
new Line(BottomWidth, 0, offset + TopWidth, Height),
new Line(offset + TopWidth, Height, offset, Height),
new Line(offset, Height, 0, 0)
};
return CreateDrawing(entities);
}
}
}
+13 -75
View File
@@ -1,4 +1,3 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Text;
@@ -6,6 +5,7 @@ using ModelContextProtocol.Server;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
using CncProgram = OpenNest.CNC.Program;
namespace OpenNest.Mcp.Tools
@@ -98,110 +98,48 @@ namespace OpenNest.Mcp.Tools
[Description("Radius for circle shape")] double radius = 5,
[Description("G-code string (only used when shape is 'gcode')")] string gcode = null)
{
CncProgram pgm;
ShapeDefinition shapeDef;
switch (shape.ToLower())
{
case "rectangle":
pgm = CreateRectangle(width, height);
shapeDef = new RectangleShape { Name = name, Width = width, Height = height };
break;
case "circle":
pgm = CreateCircle(radius);
shapeDef = new CircleShape { Name = name, Diameter = radius * 2 };
break;
case "l_shape":
pgm = CreateLShape(width, height);
shapeDef = new LShape { Name = name, Width = width, Height = height };
break;
case "t_shape":
pgm = CreateTShape(width, height);
shapeDef = new TShape { Name = name, Width = width, Height = height };
break;
case "gcode":
if (string.IsNullOrWhiteSpace(gcode))
return "Error: gcode parameter is required when shape is 'gcode'";
pgm = ParseGcode(gcode);
var pgm = ParseGcode(gcode);
if (pgm == null)
return "Error: failed to parse G-code";
break;
var gcodeDrawing = new Drawing(name, pgm);
_session.Drawings.Add(gcodeDrawing);
var gcodeBbox = pgm.BoundingBox();
return $"Created drawing '{name}': bbox={gcodeBbox.Width:F2} x {gcodeBbox.Length:F2}";
default:
return $"Error: unknown shape '{shape}'. Use: rectangle, circle, l_shape, t_shape, gcode";
}
var drawing = new Drawing(name, pgm);
var drawing = shapeDef.GetDrawing();
_session.Drawings.Add(drawing);
var bbox = pgm.BoundingBox();
var bbox = drawing.Program.BoundingBox();
return $"Created drawing '{name}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
}
private static CncProgram CreateRectangle(double width, double height)
{
var entities = new List<Entity>
{
new Line(0, 0, width, 0),
new Line(width, 0, width, height),
new Line(width, height, 0, height),
new Line(0, height, 0, 0)
};
return ConvertGeometry.ToProgram(entities);
}
private static CncProgram CreateCircle(double radius)
{
var entities = new List<Entity>
{
new Circle(0, 0, radius)
};
return ConvertGeometry.ToProgram(entities);
}
private static CncProgram CreateLShape(double width, double height)
{
var hw = width / 2;
var hh = height / 2;
var entities = new List<Entity>
{
new Line(0, 0, width, 0),
new Line(width, 0, width, hh),
new Line(width, hh, hw, hh),
new Line(hw, hh, hw, height),
new Line(hw, height, 0, height),
new Line(0, height, 0, 0)
};
return ConvertGeometry.ToProgram(entities);
}
private static CncProgram CreateTShape(double width, double height)
{
var stemWidth = width / 3;
var topHeight = height / 3;
var stemLeft = (width - stemWidth) / 2;
var stemRight = stemLeft + stemWidth;
var stemBottom = 0.0;
var stemTop = height - topHeight;
var entities = new List<Entity>
{
new Line(stemLeft, stemBottom, stemRight, stemBottom),
new Line(stemRight, stemBottom, stemRight, stemTop),
new Line(stemRight, stemTop, width, stemTop),
new Line(width, stemTop, width, height),
new Line(width, height, 0, height),
new Line(0, height, 0, stemTop),
new Line(0, stemTop, stemLeft, stemTop),
new Line(stemLeft, stemTop, stemLeft, stemBottom)
};
return ConvertGeometry.ToProgram(entities);
}
private static CncProgram ParseGcode(string gcode)
{
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
+26
View File
@@ -0,0 +1,26 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class CircleShapeTests
{
[Fact]
public void GetDrawing_ReturnsDrawingWithCorrectBoundingBox()
{
var shape = new CircleShape { Diameter = 10 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(10, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_DefaultName_IsCircle()
{
var shape = new CircleShape { Diameter = 10 };
var drawing = shape.GetDrawing();
Assert.Equal("Circle", drawing.Name);
}
}
@@ -0,0 +1,26 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class IsoscelesTriangleShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new IsoscelesTriangleShape { Base = 10, Height = 8 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(8, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaIsHalfBaseTimesHeight()
{
var shape = new IsoscelesTriangleShape { Base = 10, Height = 8 };
var drawing = shape.GetDrawing();
Assert.Equal(40, drawing.Area, 0.5);
}
}
+40
View File
@@ -0,0 +1,40 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class LShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new LShape { Width = 10, Height = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(20, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_DefaultLegDimensions()
{
var shape = new LShape { Width = 10, Height = 20 };
var drawing = shape.GetDrawing();
// Default legs: LegWidth = Width/2 = 5, LegHeight = Height/2 = 10
// Area = Width*Height - (Width - LegWidth) * (Height - LegHeight)
// Area = 10*20 - 5*10 = 150
Assert.Equal(150, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_CustomLegDimensions()
{
var shape = new LShape { Width = 10, Height = 20, LegWidth = 3, LegHeight = 5 };
var drawing = shape.GetDrawing();
// Area = Width*Height - (Width - LegWidth) * (Height - LegHeight)
// Area = 10*20 - 7*15 = 200 - 105 = 95
Assert.Equal(95, drawing.Area, 0.5);
}
}
@@ -0,0 +1,34 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class OctagonShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Fact]
public void GetDrawing_HasEightEdges()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
// An octagon program should have 8 linear moves (one per edge)
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(8, moves);
}
}
@@ -0,0 +1,35 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class RectangleShapeTests
{
[Fact]
public void GetDrawing_ReturnsDrawingWithCorrectBoundingBox()
{
var shape = new RectangleShape { Width = 10, Height = 5 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(10, bbox.Width, 0.01);
Assert.Equal(5, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_DefaultName_IsRectangle()
{
var shape = new RectangleShape { Width = 10, Height = 5 };
var drawing = shape.GetDrawing();
Assert.Equal("Rectangle", drawing.Name);
}
[Fact]
public void GetDrawing_CustomName_IsUsed()
{
var shape = new RectangleShape { Name = "Plate1", Width = 10, Height = 5 };
var drawing = shape.GetDrawing();
Assert.Equal("Plate1", drawing.Name);
}
}
@@ -0,0 +1,26 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class RightTriangleShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new RightTriangleShape { Width = 12, Height = 8 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(12, bbox.Width, 0.01);
Assert.Equal(8, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaIsHalfWidthTimesHeight()
{
var shape = new RightTriangleShape { Width = 12, Height = 8 };
var drawing = shape.GetDrawing();
Assert.Equal(48, drawing.Area, 0.5);
}
}
+37
View File
@@ -0,0 +1,37 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class RingShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesOuterDiameter()
{
var shape = new RingShape { OuterDiameter = 20, InnerDiameter = 10 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(20, bbox.Width, 0.01);
Assert.Equal(20, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaExcludesInnerHole()
{
var shape = new RingShape { OuterDiameter = 20, InnerDiameter = 10 };
var drawing = shape.GetDrawing();
// Area = pi * (10^2 - 5^2) = pi * 75
var expectedArea = System.Math.PI * 75;
Assert.Equal(expectedArea, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_DefaultName_IsRing()
{
var shape = new RingShape { OuterDiameter = 20, InnerDiameter = 10 };
var drawing = shape.GetDrawing();
Assert.Equal("Ring", drawing.Name);
}
}
@@ -0,0 +1,38 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class RoundedRectangleShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 2 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(20, bbox.Width, 0.1);
Assert.Equal(10, bbox.Length, 0.1);
}
[Fact]
public void GetDrawing_AreaIsLessThanFullRectangle()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 2 };
var drawing = shape.GetDrawing();
// Area should be less than 20*10=200 because corners are rounded
// Area = W*H - (4 - pi) * r^2 = 200 - (4 - pi) * 4 ~ 196.57
Assert.True(drawing.Area < 200);
Assert.True(drawing.Area > 190);
}
[Fact]
public void GetDrawing_ZeroRadius_MatchesRectangleArea()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 0 };
var drawing = shape.GetDrawing();
Assert.Equal(200, drawing.Area, 0.5);
}
}
+40
View File
@@ -0,0 +1,40 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class TShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new TShape { Width = 12, Height = 18 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(12, bbox.Width, 0.01);
Assert.Equal(18, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_DefaultStemAndBarDimensions()
{
var shape = new TShape { Width = 12, Height = 18 };
var drawing = shape.GetDrawing();
// Default: StemWidth = Width/3 = 4, BarHeight = Height/3 = 6
// Area = Width * BarHeight + StemWidth * (Height - BarHeight)
// Area = 12 * 6 + 4 * 12 = 72 + 48 = 120
Assert.Equal(120, drawing.Area, 0.5);
}
[Fact]
public void GetDrawing_CustomStemAndBarDimensions()
{
var shape = new TShape { Width = 12, Height = 18, StemWidth = 6, BarHeight = 4 };
var drawing = shape.GetDrawing();
// Area = Width * BarHeight + StemWidth * (Height - BarHeight)
// Area = 12 * 4 + 6 * 14 = 48 + 84 = 132
Assert.Equal(132, drawing.Area, 0.5);
}
}
@@ -0,0 +1,27 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class TrapezoidShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new TrapezoidShape { BottomWidth = 20, TopWidth = 10, Height = 8 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
Assert.Equal(20, bbox.Width, 0.01);
Assert.Equal(8, bbox.Length, 0.01);
}
[Fact]
public void GetDrawing_AreaIsCorrect()
{
var shape = new TrapezoidShape { BottomWidth = 20, TopWidth = 10, Height = 8 };
var drawing = shape.GetDrawing();
// Area = (top + bottom) / 2 * height = (10 + 20) / 2 * 8 = 120
Assert.Equal(120, drawing.Area, 0.5);
}
}
+102
View File
@@ -0,0 +1,102 @@
# OpenNest
A Windows desktop app for CNC nesting — imports DXF drawings, arranges parts on plates and exports layouts as DXF or G-code for cutting.
![OpenNest - parts nested on a 36x36 plate](screenshots/screenshot-nest-1.png)
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
## Features
- **DXF Import/Export** — Load part drawings from DXF files and export completed nest layouts
- **Multiple Fill Strategies** — Grid-based linear fill, NFP (No Fit Polygon) pair fitting, and rectangle bin packing
- **Part Rotation** — Automatically tries different rotation angles to find better fits
- **Gravity Compaction** — After placing parts, pushes them together to close gaps
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
- **G-code Output** — Post-process nested layouts to G-code for CNC cutting machines
- **Built-in Shapes** — Create basic geometric parts (circles, rectangles, triangles, etc.) without needing a DXF file
- **Interactive Editing** — Zoom, pan, select, clone, and manually arrange parts on the plate view
- **Lead-in/Lead-out & Tabs** — Configure cutting parameters like approach paths and holding tabs
![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png)
## Prerequisites
- **Windows 10 or later**
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
## Getting Started
### Build
```bash
git clone https://github.com/ajisaacs/OpenNest.git
cd OpenNest
dotnet build OpenNest.sln
```
### Run
```bash
dotnet run --project OpenNest/OpenNest.csproj
```
Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
### Quick Walkthrough
1. **Create a nest** — File > New Nest
2. **Add drawings** — Import DXF files or create built-in shapes (rectangles, circles, etc.). DXF drawings should be 1:1 scale CAD files.
3. **Set up a plate** — Define the plate size and material
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
<!-- TODO: Add screenshots for each step -->
## Project Structure
```
OpenNest.sln
├── OpenNest/ # WinForms desktop application (UI)
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
├── OpenNest.Console/ # Command-line interface for batch nesting
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
├── OpenNest.Training/ # ML training data collection
├── OpenNest.Mcp/ # MCP server for AI tool integration
└── OpenNest.Tests/ # Unit tests
```
For most users, only the first four matter:
| Project | What it does |
|---------|-------------|
| **OpenNest** | The app you run. WinForms UI with plate viewer, drawing list, and dialogs. |
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
| **OpenNest.IO** | Reads and writes files — DXF (via ACadSharp), G-code, and the `.nest` ZIP format. |
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Ctrl+F` | Fill the area around the cursor with the selected drawing |
| `F` | Zoom to fit the plate view |
## Supported Formats
| Format | Import | Export |
|--------|--------|--------|
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
| DWG (AutoCAD Drawing) | Yes | No |
| G-code | No | Yes (via post-processors) |
| `.nest` (ZIP-based project file) | Yes | Yes |
## Status
OpenNest is under active development. The core nesting workflows function, but there's plenty of room for improvement in packing efficiency, UI polish, and format support. Contributions and feedback are welcome.
## License
This project is licensed under the [MIT License](LICENSE).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
# Shape Library Design Spec
## Overview
A parametric shape library for OpenNest that provides reusable, self-describing shape classes for generating `Drawing` objects. Each shape is its own class with typed parameters, inheriting from an abstract `ShapeDefinition` base class. Inspired by PEP's WINSHAPE library.
## Location
- Project: `OpenNest.Core`
- Folder: `Shapes/`
- Namespace: `OpenNest.Shapes`
## Architecture
### Base Class — `ShapeDefinition`
Abstract base class that all shapes inherit from. `Name` defaults to the shape type name (e.g. `"Rectangle"`) but can be overridden.
```csharp
public abstract class ShapeDefinition
{
public string Name { get; set; }
protected ShapeDefinition()
{
// Default name to the concrete class name, stripping "Shape" suffix
var typeName = GetType().Name;
Name = typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public abstract Drawing GetDrawing();
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
if (pgm == null)
throw new InvalidOperationException(
$"Failed to create program for shape '{Name}'. Check that parameters produce valid geometry.");
return new Drawing(Name, pgm);
}
}
```
- `Name`: The name assigned to the resulting `Drawing`. Defaults to the shape class name without the "Shape" suffix. Never null.
- `GetDrawing()`: Each shape implements this to build its geometry and return a `Drawing`.
- `CreateDrawing()`: Shared helper that converts a list of geometry entities into a `Drawing` via `ConvertGeometry.ToProgram()`. Throws `InvalidOperationException` if the geometry is degenerate (prevents null `Program` from reaching `Drawing.UpdateArea()`).
### Shape Classes
#### Tier 1 — Basics (extracted from MCP InputTools)
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RectangleShape` | `Width`, `Height` | Axis-aligned rectangle from origin |
| `CircleShape` | `Diameter` | Circle centered at origin. Implementation divides by 2 for the `Circle` entity's radius parameter. |
| `LShape` | `Width`, `Height`, `LegWidth`?, `LegHeight`? | L-shaped profile. `LegWidth` defaults to `Width/2`, `LegHeight` defaults to `Height/2`. |
| `TShape` | `Width`, `Height`, `StemWidth`?, `BarHeight`? | T-shaped profile. `StemWidth` defaults to `Width/3`, `BarHeight` defaults to `Height/3`. |
#### Tier 2 — Common CNC shapes
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RingShape` | `OuterDiameter`, `InnerDiameter` | Annular ring (two concentric circles). Both converted to radius internally. |
| `RightTriangleShape` | `Width`, `Height` | Right triangle with the right angle at origin |
| `IsoscelesTriangleShape` | `Base`, `Height` | Isosceles triangle centered on base |
| `TrapezoidShape` | `TopWidth`, `BottomWidth`, `Height` | Trapezoid with bottom edge centered under top |
| `OctagonShape` | `Width` | Regular octagon where `Width` is the flat-to-flat distance |
| `RoundedRectangleShape` | `Width`, `Height`, `Radius` | Rectangle with 90-degree CCW arc corners |
### File Structure
```
OpenNest.Core/
Shapes/
ShapeDefinition.cs
CircleShape.cs
RectangleShape.cs
RingShape.cs
RightTriangleShape.cs
IsoscelesTriangleShape.cs
TrapezoidShape.cs
OctagonShape.cs
RoundedRectangleShape.cs
LShape.cs
TShape.cs
```
### Geometry Construction
Each shape builds a `List<Entity>` (using `Line`, `Arc`, `Circle` from `OpenNest.Geometry`) and passes it to the base `CreateDrawing()` helper. Shapes are constructed at the origin (0,0) with positive X/Y extents.
- **Lines** for straight edges — endpoints must chain end-to-end for `ShapeBuilder` to detect closed shapes.
- **Arcs** for rounded corners (`RoundedRectangleShape`). Arcs use CCW direction (not reversed) with angles in radians.
- **Circles** for `CircleShape` and `RingShape` outer/inner boundaries.
### MCP Integration
`InputTools.CreateDrawing` in `OpenNest.Mcp` will be refactored to instantiate the appropriate `ShapeDefinition` subclass and call `GetDrawing()`, replacing the existing private `CreateRectangle`, `CreateCircle`, `CreateLShape`, `CreateTShape` methods. The MCP tool's existing flat parameter names (`radius`, `width`, `height`) are mapped to the shape class properties at the MCP layer. The `gcode` case remains as-is.
New Tier 2 shapes can be exposed via the MCP tool by extending the `shape` parameter's accepted values and mapping to the new shape classes, with additional MCP parameters as needed.
## Future Expansion
- Additional shapes (Tier 3): Single-D, Parallelogram, House, Stair, Rectangle with chamfer(s), Ring segment, Slot rectangle
- UI shape picker with per-shape parameter editors
- Shape discovery via reflection or static registry
- `LShape`/`TShape` additional sub-dimension parameters for full parametric control
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB