Files
OpenNest/docs/superpowers/plans/2026-03-17-shape-library.md
AJ Isaacs 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

32 KiB

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<Entity>) helper that converts geometry to a Drawing via ConvertGeometry.ToProgram(). Each subclass implements GetDrawing() by building a List<Entity> 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

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);
        }
    }
}
  • 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

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
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);
        }
    }
}
  • 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

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.

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);
        }
    }
}
  • 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.

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
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);
        }
    }
}
  • 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

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).

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);
        }
    }
}
  • 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

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).

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);
        }
    }
}
  • 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

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.

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);
        }
    }
}
  • 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.

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<OpenNest.CNC.LinearMove>()
            .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.

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<Entity>();
            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

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
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);
        }
    }
}
  • 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

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
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);
        }
    }
}
  • 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

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).

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<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 -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:

// 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