diff --git a/docs/superpowers/plans/2026-03-17-shape-library.md b/docs/superpowers/plans/2026-03-17-shape-library.md new file mode 100644 index 0000000..16153f8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-shape-library.md @@ -0,0 +1,1182 @@ +# Shape Library Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a parametric shape library in OpenNest.Core with an abstract `ShapeDefinition` base class and 10 concrete shape classes that each produce a `Drawing`. + +**Architecture:** Each shape is a class in `OpenNest.Core/Shapes/` (namespace `OpenNest.Shapes`) inheriting from `ShapeDefinition`. The base class provides a `CreateDrawing(List)` helper that converts geometry to a `Drawing` via `ConvertGeometry.ToProgram()`. Each subclass implements `GetDrawing()` by building a `List` from `Line`, `Arc`, and `Circle` primitives. + +**Tech Stack:** .NET 8, xUnit, OpenNest.Geometry (Line, Arc, Circle, Vector), OpenNest.Converters (ConvertGeometry) + +**Spec:** `docs/superpowers/specs/2026-03-17-shape-library-design.md` + +--- + +## Chunk 1: Base Class + Simple Shapes (Rectangle, Circle, Ring) + +### Task 1: ShapeDefinition Base Class + +**Files:** +- Create: `OpenNest.Core/Shapes/ShapeDefinition.cs` + +- [ ] **Step 1: Create ShapeDefinition.cs** + +```csharp +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 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); + } + } +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +feat(core): add ShapeDefinition base class +``` + +--- + +### Task 2: RectangleShape + +**Files:** +- Create: `OpenNest.Core/Shapes/RectangleShape.cs` +- Create: `OpenNest.Tests/Shapes/RectangleShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RectangleShapeTests"` +Expected: FAIL — `RectangleShape` does not exist + +- [ ] **Step 3: Implement RectangleShape** + +```csharp +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 + { + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RectangleShapeTests"` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add RectangleShape +``` + +--- + +### Task 3: CircleShape + +**Files:** +- Create: `OpenNest.Core/Shapes/CircleShape.cs` +- Create: `OpenNest.Tests/Shapes/CircleShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~CircleShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement CircleShape** + +The `Circle` entity constructor takes `(x, y, radius)`. `Diameter` is the user-facing parameter, so divide by 2. + +```csharp +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 + { + new Circle(0, 0, Diameter / 2.0) + }; + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~CircleShapeTests"` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add CircleShape +``` + +--- + +### Task 4: RingShape + +**Files:** +- Create: `OpenNest.Core/Shapes/RingShape.cs` +- Create: `OpenNest.Tests/Shapes/RingShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +The ring is two concentric circles. `ConvertGeometry.ToProgram()` identifies the larger circle as the perimeter and the smaller as a cutout. The bounding box should match the outer diameter. + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RingShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement RingShape** + +```csharp +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 + { + new Circle(0, 0, OuterDiameter / 2.0), + new Circle(0, 0, InnerDiameter / 2.0) + }; + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RingShapeTests"` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add RingShape +``` + +--- + +## Chunk 2: Triangle Shapes + Trapezoid + Octagon + +### Task 5: RightTriangleShape + +**Files:** +- Create: `OpenNest.Core/Shapes/RightTriangleShape.cs` +- Create: `OpenNest.Tests/Shapes/RightTriangleShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RightTriangleShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement RightTriangleShape** + +Right angle at origin (0,0). Vertices: (0,0), (Width,0), (0,Height). + +```csharp +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 + { + new Line(0, 0, Width, 0), + new Line(Width, 0, 0, Height), + new Line(0, Height, 0, 0) + }; + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RightTriangleShapeTests"` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add RightTriangleShape +``` + +--- + +### Task 6: IsoscelesTriangleShape + +**Files:** +- Create: `OpenNest.Core/Shapes/IsoscelesTriangleShape.cs` +- Create: `OpenNest.Tests/Shapes/IsoscelesTriangleShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~IsoscelesTriangleShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement IsoscelesTriangleShape** + +Base along the bottom from (0,0) to (Base,0). Apex at (Base/2, Height). + +```csharp +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 + { + new Line(0, 0, Base, 0), + new Line(Base, 0, midX, Height), + new Line(midX, Height, 0, 0) + }; + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~IsoscelesTriangleShapeTests"` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add IsoscelesTriangleShape +``` + +--- + +### Task 7: TrapezoidShape + +**Files:** +- Create: `OpenNest.Core/Shapes/TrapezoidShape.cs` +- Create: `OpenNest.Tests/Shapes/TrapezoidShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~TrapezoidShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement TrapezoidShape** + +Bottom edge from (0,0) to (BottomWidth,0). Top edge centered above, offset by `(BottomWidth - TopWidth) / 2`. + +```csharp +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 + { + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~TrapezoidShapeTests"` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add TrapezoidShape +``` + +--- + +### Task 8: OctagonShape + +**Files:** +- Create: `OpenNest.Core/Shapes/OctagonShape.cs` +- Create: `OpenNest.Tests/Shapes/OctagonShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +`Width` is the flat-to-flat distance. The octagon is centered at (Width/2, Width/2) so all geometry is in positive X/Y. + +```csharp +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.5°) ≈ 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() + .Count(); + Assert.Equal(8, moves); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~OctagonShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement OctagonShape** + +Regular octagon with flat-to-flat distance = `Width`. The circumscribed radius (center to corner) = `Width / (2 * cos(π/8))`. Generate 8 vertices at 45° intervals, starting at 22.5° so flats are horizontal/vertical. Center at `(Width/2, Width/2)` to keep geometry in positive quadrant. + +```csharp +using System; +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(); + for (var i = 0; i < 8; i++) + { + var next = (i + 1) % 8; + entities.Add(new Line(vertices[i], vertices[next])); + } + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~OctagonShapeTests"` +Expected: 2 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add OctagonShape +``` + +--- + +## Chunk 3: Compound Shapes (L, T, Rounded Rectangle) + +### Task 9: LShape + +**Files:** +- Create: `OpenNest.Core/Shapes/LShape.cs` +- Create: `OpenNest.Tests/Shapes/LShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~LShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement LShape** + +L-shape: full width along the bottom, a vertical leg on the left side. `LegWidth` controls how wide the vertical leg is. `LegHeight` controls how tall the horizontal base portion is. When `LegWidth`/`LegHeight` are 0, they default to half the overall dimension. + +``` + ┌──────┐ LegWidth + │ │ + │ │ Height + │ │ + │ ├──────────┐ + │ │ │ LegHeight + └──────┴──────────┘ + Width +``` + +```csharp +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 + { + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~LShapeTests"` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add LShape +``` + +--- + +### Task 10: TShape + +**Files:** +- Create: `OpenNest.Core/Shapes/TShape.cs` +- Create: `OpenNest.Tests/Shapes/TShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~TShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement TShape** + +T-shape: full-width top bar, centered stem below. The T is oriented with the bar at the top. + +``` + ┌──────────────────┐ + │ Bar (Width) │ BarHeight + └───┬────────┬─────┘ + │ Stem │ Height - BarHeight + │ │ + └────────┘ + StemWidth +``` + +```csharp +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 + { + 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); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~TShapeTests"` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add TShape +``` + +--- + +### Task 11: RoundedRectangleShape + +**Files:** +- Create: `OpenNest.Core/Shapes/RoundedRectangleShape.cs` +- Create: `OpenNest.Tests/Shapes/RoundedRectangleShapeTests.cs` + +- [ ] **Step 1: Write failing tests** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RoundedRectangleShapeTests"` +Expected: FAIL + +- [ ] **Step 3: Implement RoundedRectangleShape** + +Four straight edges with 90° arcs at each corner. Arc constructor: `Arc(center, radius, startAngle, endAngle, reversed)`. Angles are in radians. Arcs go CCW (not reversed). The shape chains: bottom edge → bottom-right arc → right edge → top-right arc → top edge → top-left arc → left edge → bottom-left arc. + +When `Radius` is 0, skip arcs and produce a plain rectangle (all lines). + +```csharp +using System; +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(); + + 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 -90° to 0° + 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 0° to 90° + 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 90° to 180° + 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 180° to 270° + entities.Add(new Arc(r, r, r, + Angle.ToRadians(180), Angle.ToRadians(270))); + } + + return CreateDrawing(entities); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~RoundedRectangleShapeTests"` +Expected: 3 passed + +- [ ] **Step 5: Commit** + +``` +feat(core): add RoundedRectangleShape +``` + +--- + +## Chunk 4: MCP Refactoring + Final Build Verification + +### Task 12: Refactor MCP InputTools to Use Shape Library + +**Files:** +- Modify: `OpenNest.Mcp/Tools/InputTools.cs` + +- [ ] **Step 1: Replace private shape methods with shape library calls** + +Replace the body of the `CreateDrawing` MCP tool method and remove the four private methods (`CreateRectangle`, `CreateCircle`, `CreateLShape`, `CreateTShape`). The MCP tool maps its flat parameters to shape class properties. + +In `InputTools.cs`, replace lines 100-203 (from `switch (shape.ToLower())` through the end of `CreateTShape`) with: + +```csharp +// Inside CreateDrawing method, replace the switch block: +ShapeDefinition shapeDef; + +switch (shape.ToLower()) +{ + case "rectangle": + shapeDef = new RectangleShape { Name = name, Width = width, Height = height }; + break; + + case "circle": + shapeDef = new CircleShape { Name = name, Diameter = radius * 2 }; + break; + + case "l_shape": + shapeDef = new LShape { Name = name, Width = width, Height = height }; + break; + + case "t_shape": + 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'"; + var pgm = ParseGcode(gcode); + if (pgm == null) + return "Error: failed to parse G-code"; + 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 = shapeDef.GetDrawing(); +_session.Drawings.Add(drawing); + +var bbox = drawing.Program.BoundingBox(); +return $"Created drawing '{name}': bbox={bbox.Width:F2} x {bbox.Length:F2}"; +``` + +Remove the four private methods: `CreateRectangle`, `CreateCircle`, `CreateLShape`, `CreateTShape`. Keep `ParseGcode`. + +Add `using OpenNest.Shapes;` to the top of the file. Remove `using OpenNest.Converters;` and `using OpenNest.Geometry;` if no longer used (check — `ParseGcode` uses `OpenNest.IO` types only, and the `gcode` case creates a `Drawing` directly). + +- [ ] **Step 2: Build the MCP project** + +Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +``` +refactor(mcp): use shape library in InputTools +``` + +--- + +### Task 13: Full Solution Build + All Tests + +- [ ] **Step 1: Build entire solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj` +Expected: All tests pass (existing + new shape tests) + +- [ ] **Step 3: Commit if any fixups were needed** + +``` +fix(core): address build/test issues in shape library +```