diff --git a/docs/plans/2026-03-07-best-fit-viewer-design.md b/docs/plans/2026-03-07-best-fit-viewer-design.md deleted file mode 100644 index 7f27cdb..0000000 --- a/docs/plans/2026-03-07-best-fit-viewer-design.md +++ /dev/null @@ -1,378 +0,0 @@ -# Best-Fit Viewer Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a BestFitViewerForm that shows all pair candidates in a dense 5-column grid with metadata overlay, similar to PEP's best-fit viewer. - -**Architecture:** A modal `Form` with a scrollable `TableLayoutPanel` (5 columns). Each cell is a read-only `PlateView` with the pair's two parts placed on it. Metadata is painted as overlay text on each cell. Dropped candidates use a different background color. Invoked from Tools menu when a drawing and plate are available. - -**Tech Stack:** WinForms, `BestFitFinder` from `OpenNest.Engine.BestFit`, `PlateView` control. - ---- - -### Task 1: Extract BuildPairParts to a static helper - -`NestEngine.BuildPairParts` is private and contains the pair-building logic we need. Extract it to a public static method so both `NestEngine` and the new form can use it. - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs` - -**Step 1: Make BuildPairParts internal static** - -In `OpenNest.Engine/NestEngine.cs`, change the method signature from private instance to internal static. It doesn't use any instance state — only `BestFitResult` and `Drawing` parameters. - -Change: -```csharp -private List BuildPairParts(BestFitResult bestFit, Drawing drawing) -``` -To: -```csharp -internal static List BuildPairParts(BestFitResult bestFit, Drawing drawing) -``` - -**Step 2: Build and verify** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds with no errors. - -**Step 3: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs -git commit -m "refactor: make BuildPairParts internal static for reuse" -``` - ---- - -### Task 2: Create BestFitViewerForm - -**Files:** -- Create: `OpenNest/Forms/BestFitViewerForm.cs` -- Create: `OpenNest/Forms/BestFitViewerForm.Designer.cs` - -**Step 1: Create the Designer file** - -Create `OpenNest/Forms/BestFitViewerForm.Designer.cs` with a `TableLayoutPanel` (5 columns, auto-scroll, dock-fill) inside the form. Form should be sizable, start centered on parent, ~1200x800 default size, title "Best-Fit Viewer". - -```csharp -namespace OpenNest.Forms -{ - partial class BestFitViewerForm - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - components.Dispose(); - base.Dispose(disposing); - } - - private void InitializeComponent() - { - this.gridPanel = new System.Windows.Forms.TableLayoutPanel(); - this.SuspendLayout(); - // - // gridPanel - // - this.gridPanel.AutoScroll = true; - this.gridPanel.ColumnCount = 5; - this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); - this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); - this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); - this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); - this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); - this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill; - this.gridPanel.Location = new System.Drawing.Point(0, 0); - this.gridPanel.Name = "gridPanel"; - this.gridPanel.RowCount = 1; - this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); - this.gridPanel.Size = new System.Drawing.Size(1200, 800); - this.gridPanel.TabIndex = 0; - // - // BestFitViewerForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(1200, 800); - this.Controls.Add(this.gridPanel); - this.KeyPreview = true; - this.Name = "BestFitViewerForm"; - this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; - this.Text = "Best-Fit Viewer"; - this.ResumeLayout(false); - } - - private System.Windows.Forms.TableLayoutPanel gridPanel; - } -} -``` - -**Step 2: Create the code-behind file** - -Create `OpenNest/Forms/BestFitViewerForm.cs`. The constructor takes a `Drawing` and a `Plate`. It calls `BestFitFinder.FindBestFits()` to get all candidates, then for each result: -1. Creates a `PlateView` configured read-only (no pan/zoom/select/origin, no plate outline) -2. Sizes the PlateView's plate to the pair bounding box -3. Builds pair parts via `NestEngine.BuildPairParts()` and adds them to the plate -4. Sets background color based on `Keep` (kept = default, dropped = maroon) -5. Subscribes to `Paint` to overlay metadata text - -```csharp -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Windows.Forms; -using OpenNest.Controls; -using OpenNest.Engine.BestFit; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Forms -{ - public partial class BestFitViewerForm : Form - { - private static readonly Color KeptColor = Color.FromArgb(0, 0, 100); - private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0); - - public BestFitViewerForm(Drawing drawing, Plate plate) - { - InitializeComponent(); - PopulateGrid(drawing, plate); - } - - protected override bool ProcessCmdKey(ref Message msg, Keys keyData) - { - if (keyData == Keys.Escape) - { - Close(); - return true; - } - return base.ProcessCmdKey(ref msg, keyData); - } - - private void PopulateGrid(Drawing drawing, Plate plate) - { - var finder = new BestFitFinder(plate.Size.Width, plate.Size.Height); - var results = finder.FindBestFits(drawing, plate.PartSpacing); - - var rows = (int)System.Math.Ceiling(results.Count / 5.0); - gridPanel.RowCount = rows; - gridPanel.RowStyles.Clear(); - - for (var i = 0; i < rows; i++) - gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 200)); - - for (var i = 0; i < results.Count; i++) - { - var result = results[i]; - var view = CreateCellView(result, drawing); - gridPanel.Controls.Add(view, i % 5, i / 5); - } - } - - private PlateView CreateCellView(BestFitResult result, Drawing drawing) - { - var bgColor = result.Keep ? KeptColor : DroppedColor; - - var colorScheme = new ColorScheme - { - BackgroundColor = bgColor, - LayoutOutlineColor = bgColor, - LayoutFillColor = bgColor, - BoundingBoxColor = bgColor, - RapidColor = Color.DodgerBlue, - OriginColor = bgColor, - EdgeSpacingColor = bgColor - }; - - var view = new PlateView(colorScheme); - view.DrawOrigin = false; - view.DrawBounds = false; - view.AllowPan = false; - view.AllowSelect = false; - view.AllowZoom = false; - view.AllowDrop = false; - view.Dock = DockStyle.Fill; - view.Plate.Size = new Geometry.Size( - result.BoundingWidth, - result.BoundingHeight); - - var parts = NestEngine.BuildPairParts(result, drawing); - - foreach (var part in parts) - view.Plate.Parts.Add(part); - - view.Paint += (sender, e) => - { - PaintMetadata(e.Graphics, view, result); - }; - - view.HandleCreated += (sender, e) => - { - view.ZoomToFit(true); - }; - - return view; - } - - private void PaintMetadata(Graphics g, PlateView view, BestFitResult result) - { - var font = view.Font; - var brush = Brushes.White; - var y = 2f; - var lineHeight = font.GetHeight(g) + 1; - - var lines = new[] - { - string.Format("RotatedArea={0:F4}", result.RotatedArea), - string.Format("{0:F4}x{1:F4}={2:F4}", - result.BoundingWidth, result.BoundingHeight, result.RotatedArea), - string.Format("Why={0}", result.Keep ? "0" : result.Reason), - string.Format("Type={0} Test={1} Spacing={2}", - result.Candidate.StrategyType, - result.Candidate.TestNumber, - result.Candidate.Spacing), - string.Format("Util={0:P0} Rot={1:F1}°", - result.Utilization, - Angle.ToDegrees(result.OptimalRotation)) - }; - - foreach (var line in lines) - { - g.DrawString(line, font, brush, 2, y); - y += lineHeight; - } - } - } -} -``` - -**Step 3: Build and verify** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds. - -**Step 4: Commit** - -```bash -git add OpenNest/Forms/BestFitViewerForm.cs OpenNest/Forms/BestFitViewerForm.Designer.cs -git commit -m "feat: add BestFitViewerForm with pair candidate grid" -``` - ---- - -### Task 3: Add menu item to MainForm - -**Files:** -- Modify: `OpenNest/Forms/MainForm.Designer.cs` -- Modify: `OpenNest/Forms/MainForm.cs` - -**Step 1: Add the menu item field and wire it up in Designer** - -In `MainForm.Designer.cs`: - -1. Add field declaration near the other `mnuTools*` fields (~line 1198): -```csharp -private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer; -``` - -2. Add instantiation in `InitializeComponent()` near other mnuTools instantiations (~line 62): -```csharp -this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); -``` - -3. Add to the Tools menu `DropDownItems` array (after `mnuToolsMeasureArea`, ~line 413-420). Insert `mnuToolsBestFitViewer` before the `toolStripMenuItem14` separator: -```csharp -this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { -this.mnuToolsMeasureArea, -this.mnuToolsBestFitViewer, -this.mnuToolsAlign, -this.toolStripMenuItem14, -this.mnuSetOffsetIncrement, -this.mnuSetRotationIncrement, -this.toolStripMenuItem15, -this.mnuToolsOptions}); -``` - -4. Add menu item configuration after the `mnuToolsMeasureArea` block (~line 431): -```csharp -// -// mnuToolsBestFitViewer -// -this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; -this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); -this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; -this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click); -``` - -**Step 2: Add the click handler in MainForm.cs** - -Add a method to `MainForm.cs` that opens the form. It needs the active `EditNestForm` to get the current plate and a selected drawing. If no drawing is available from the selected plate's parts, show a message. - -```csharp -private void BestFitViewer_Click(object sender, EventArgs e) -{ - if (activeForm == null) - return; - - var plate = activeForm.PlateView.Plate; - var drawing = activeForm.Nest.Drawings.Count > 0 - ? activeForm.Nest.Drawings[0] - : null; - - if (drawing == null) - { - MessageBox.Show("No drawings available.", "Best-Fit Viewer", - MessageBoxButtons.OK, MessageBoxIcon.Information); - return; - } - - using (var form = new BestFitViewerForm(drawing, plate)) - { - form.ShowDialog(this); - } -} -``` - -**Step 3: Build and verify** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds. - -**Step 4: Commit** - -```bash -git add OpenNest/Forms/MainForm.Designer.cs OpenNest/Forms/MainForm.cs -git commit -m "feat: add Best-Fit Viewer menu item under Tools" -``` - ---- - -### Task 4: Manual smoke test - -**Step 1: Run the application** - -Run: `dotnet run --project OpenNest` - -**Step 2: Test the flow** - -1. Open or create a nest file -2. Import a DXF drawing -3. Go to Tools > Best-Fit Viewer -4. Verify the grid appears with pair candidates -5. Verify kept candidates have dark blue background -6. Verify dropped candidates have dark red/maroon background -7. Verify metadata text is readable on each cell -8. Verify ESC closes the dialog -9. Verify scroll works when many results exist - -**Step 3: Fix any visual issues** - -Adjust cell heights, font sizes, or zoom-to-fit timing if needed. - -**Step 4: Final commit** - -```bash -git add -A -git commit -m "fix: polish BestFitViewerForm layout and appearance" -``` diff --git a/docs/plans/2026-03-07-bestfit-pair-finding.md b/docs/plans/2026-03-07-bestfit-pair-finding.md deleted file mode 100644 index e897620..0000000 --- a/docs/plans/2026-03-07-bestfit-pair-finding.md +++ /dev/null @@ -1,963 +0,0 @@ -# Best-Fit Pair Finding Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a pair-finding engine that arranges two copies of a part in the tightest configuration, then tiles that pair across a plate. - -**Architecture:** Strategy pattern where `RotationSlideStrategy` instances (parameterized by angle) generate candidate pair configurations by sliding one part against another using existing raycast collision. A `PairEvaluator` scores candidates by bounding area, a `BestFitFilter` prunes bad fits, and a `TileEvaluator` simulates tiling the best pairs onto a plate. - -**Tech Stack:** .NET Framework 4.8, C# 7.3, OpenNest.Engine (class library referencing OpenNest.Core) - ---- - -## Important Context - -### Codebase Conventions -- **All angles are in radians** — use `Angle.ToRadians()`, `Angle.HalfPI`, `Angle.TwoPI` -- **Always use `var`** instead of explicit types -- **`OpenNest.Math` shadows `System.Math`** — use `System.Math` fully qualified -- **Legacy `.csproj`** — every new `.cs` file must be added to `OpenNest.Engine.csproj` `` items -- **No test project exists** — skip TDD steps, verify by building - -### Key Existing Types -- `Vector` (struct, `OpenNest.Geometry`) — 2D point, has `Rotate()`, `Offset()`, `DistanceTo()`, operators -- `Box` (class, `OpenNest.Geometry`) — AABB with `Left/Right/Top/Bottom/Width/Height`, `Contains()`, `Intersects()` -- `Part` (class, `OpenNest`) — wraps `Drawing` + `Program`, has `Location`, `Rotation`, `Rotate()`, `Offset()`, `Clone()`, `BoundingBox` -- `Drawing` (class, `OpenNest`) — has `Program`, `Area`, `Name` -- `Program` (class, `OpenNest.CNC`) — G-code program, has `BoundingBox()`, `Rotate()`, `Clone()` -- `Plate` (class, `OpenNest`) — has `Size` (Width/Height), `EdgeSpacing`, `PartSpacing`, `WorkArea()` -- `Shape` (class, `OpenNest.Geometry`) — closed contour, has `Intersects(Shape)`, `Area()`, `ToPolygon()`, `OffsetEntity()` -- `Polygon` (class, `OpenNest.Geometry`) — vertex list, has `FindBestRotation()`, `Rotate()`, `Offset()` -- `ConvexHull.Compute(IList)` — returns closed `Polygon` -- `BoundingRectangleResult` — `Angle`, `Width`, `Height`, `Area` from rotating calipers - -### Key Existing Methods (in `Helper`) -- `Helper.GetShapes(IEnumerable)` — builds `Shape` list from geometry entities -- `Helper.GetPartLines(Part, PushDirection)` — gets polygon edges facing a direction (uses chord tolerance 0.01) -- `Helper.DirectionalDistance(movingLines, stationaryLines, PushDirection)` — raycasts to find minimum contact distance -- `Helper.OppositeDirection(PushDirection)` — flips direction -- `ConvertProgram.ToGeometry(Program)` — converts CNC program to geometry entities - -### How Existing Push/Contact Works (in `FillLinear`) -``` -1. Create partA at position -2. Clone to partB, offset by bounding box dimension along axis -3. Get facing lines: movingLines = GetPartLines(partB, pushDir) -4. Get facing lines: stationaryLines = GetPartLines(partA, oppositeDir) -5. slideDistance = DirectionalDistance(movingLines, stationaryLines, pushDir) -6. copyDistance = bboxDim - slideDistance + spacing -``` -The best-fit system adapts this: part2 is rotated, offset perpendicular to the push axis, then pushed toward part1. - -### Hull Edge Angles (existing pattern in `NestEngine`) -``` -1. Convert part to polygon via ConvertProgram.ToGeometry → GetShapes → ToPolygonWithTolerance -2. Compute convex hull via ConvexHull.Compute(vertices) -3. Extract edge angles: atan2(dy, dx) for each hull edge -4. Deduplicate angles (within Tolerance.Epsilon) -``` - ---- - -## Task 1: PairCandidate Data Class - -**Files:** -- Create: `OpenNest.Engine/BestFit/PairCandidate.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` (add Compile entry) - -**Step 1: Create directory and file** - -```csharp -using OpenNest.Geometry; - -namespace OpenNest.Engine.BestFit -{ - public class PairCandidate - { - public Drawing Drawing { get; set; } - public double Part1Rotation { get; set; } - public double Part2Rotation { get; set; } - public Vector Part2Offset { get; set; } - public int StrategyType { get; set; } - public int TestNumber { get; set; } - public double Spacing { get; set; } - } -} -``` - -**Step 2: Add to .csproj** - -Add inside the `` that contains `` entries, before ``: -```xml - -``` - -**Step 3: Build to verify** - -Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` -Expected: Build succeeded - -**Step 4: Commit** - -``` -feat: add PairCandidate data class for best-fit pair finding -``` - ---- - -## Task 2: BestFitResult Data Class - -**Files:** -- Create: `OpenNest.Engine/BestFit/BestFitResult.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -namespace OpenNest.Engine.BestFit -{ - public class BestFitResult - { - public PairCandidate Candidate { get; set; } - public double RotatedArea { get; set; } - public double BoundingWidth { get; set; } - public double BoundingHeight { get; set; } - public double OptimalRotation { get; set; } - public bool Keep { get; set; } - public string Reason { get; set; } - public double TrueArea { get; set; } - - public double Utilization - { - get { return RotatedArea > 0 ? TrueArea / RotatedArea : 0; } - } - - public double LongestSide - { - get { return System.Math.Max(BoundingWidth, BoundingHeight); } - } - - public double ShortestSide - { - get { return System.Math.Min(BoundingWidth, BoundingHeight); } - } - } - - public enum BestFitSortField - { - Area, - LongestSide, - ShortestSide, - Type, - OriginalSequence, - Keep, - WhyKeepDrop - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build to verify** - -Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` - -**Step 4: Commit** - -``` -feat: add BestFitResult data class and BestFitSortField enum -``` - ---- - -## Task 3: IBestFitStrategy Interface - -**Files:** -- Create: `OpenNest.Engine/BestFit/IBestFitStrategy.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -using System.Collections.Generic; - -namespace OpenNest.Engine.BestFit -{ - public interface IBestFitStrategy - { - int Type { get; } - string Description { get; } - List GenerateCandidates(Drawing drawing, double spacing, double stepSize); - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build to verify** - -**Step 4: Commit** - -``` -feat: add IBestFitStrategy interface -``` - ---- - -## Task 4: RotationSlideStrategy - -This is the core algorithm. It generates pair candidates by: -1. Creating part1 at origin -2. Creating part2 with a specific rotation -3. For each push direction (Left, Down): - - For each perpendicular offset (stepping across the part): - - Place part2 far away along the push axis - - Use `DirectionalDistance` to find contact - - Record position as a candidate - -**Files:** -- Create: `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -using System; -using System.Collections.Generic; -using OpenNest.Converters; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine.BestFit -{ - public class RotationSlideStrategy : IBestFitStrategy - { - private const double ChordTolerance = 0.01; - - public RotationSlideStrategy(double part2Rotation, int type, string description) - { - Part2Rotation = part2Rotation; - Type = type; - Description = description; - } - - public double Part2Rotation { get; } - public int Type { get; } - public string Description { get; } - - public List GenerateCandidates(Drawing drawing, double spacing, double stepSize) - { - var candidates = new List(); - - var part1 = new Part(drawing); - var bbox1 = part1.Program.BoundingBox(); - part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); - part1.UpdateBounds(); - - var part2Template = new Part(drawing); - if (!Part2Rotation.IsEqualTo(0)) - part2Template.Rotate(Part2Rotation); - var bbox2 = part2Template.Program.BoundingBox(); - part2Template.Offset(-bbox2.Location.X, -bbox2.Location.Y); - part2Template.UpdateBounds(); - - var testNumber = 0; - - // Slide along horizontal axis (push left toward part1) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Left, candidates, ref testNumber); - - // Slide along vertical axis (push down toward part1) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Down, candidates, ref testNumber); - - return candidates; - } - - private void GenerateCandidatesForAxis( - Part part1, Part part2Template, Drawing drawing, - double spacing, double stepSize, PushDirection pushDir, - List candidates, ref int testNumber) - { - var bbox1 = part1.BoundingBox; - var bbox2 = part2Template.BoundingBox; - - // Determine perpendicular range based on push direction - double perpMin, perpMax, pushStartOffset; - bool isHorizontalPush = (pushDir == PushDirection.Left || pushDir == PushDirection.Right); - - if (isHorizontalPush) - { - // Pushing horizontally: perpendicular axis is Y - perpMin = -(bbox2.Height + spacing); - perpMax = bbox1.Height + bbox2.Height + spacing; - pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2; - } - else - { - // Pushing vertically: perpendicular axis is X - perpMin = -(bbox2.Width + spacing); - perpMax = bbox1.Width + bbox2.Width + spacing; - pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2; - } - - var part1Lines = Helper.GetOffsetPartLines(part1, spacing / 2); - var opposite = Helper.OppositeDirection(pushDir); - - for (var offset = perpMin; offset <= perpMax; offset += stepSize) - { - var part2 = (Part)part2Template.Clone(); - - if (isHorizontalPush) - part2.Offset(pushStartOffset, offset); - else - part2.Offset(offset, pushStartOffset); - - var movingLines = Helper.GetOffsetPartLines(part2, spacing / 2); - var slideDist = Helper.DirectionalDistance(movingLines, part1Lines, pushDir); - - if (slideDist >= double.MaxValue || slideDist < 0) - continue; - - // Move part2 to contact position - var contactOffset = GetPushVector(pushDir, slideDist); - var finalPosition = part2.Location + contactOffset; - - candidates.Add(new PairCandidate - { - Drawing = drawing, - Part1Rotation = 0, - Part2Rotation = Part2Rotation, - Part2Offset = finalPosition, - StrategyType = Type, - TestNumber = testNumber++, - Spacing = spacing - }); - } - } - - private static Vector GetPushVector(PushDirection direction, double distance) - { - switch (direction) - { - case PushDirection.Left: return new Vector(-distance, 0); - case PushDirection.Right: return new Vector(distance, 0); - case PushDirection.Down: return new Vector(0, -distance); - case PushDirection.Up: return new Vector(0, distance); - default: return Vector.Zero; - } - } - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build to verify** - -Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q` - -**Step 4: Commit** - -``` -feat: add RotationSlideStrategy with directional push contact algorithm -``` - ---- - -## Task 5: PairEvaluator - -Scores each candidate by computing the combined bounding box, finding the optimal rotation (via rotating calipers on the convex hull), and checking for overlaps. - -**Files:** -- Create: `OpenNest.Engine/BestFit/PairEvaluator.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -using System.Collections.Generic; -using System.Linq; -using OpenNest.Converters; -using OpenNest.Geometry; - -namespace OpenNest.Engine.BestFit -{ - public class PairEvaluator - { - private const double ChordTolerance = 0.01; - - public BestFitResult Evaluate(PairCandidate candidate) - { - var drawing = candidate.Drawing; - - // Build part1 at origin - var part1 = new Part(drawing); - var bbox1 = part1.Program.BoundingBox(); - part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); - part1.UpdateBounds(); - - // Build part2 with rotation and offset - var part2 = new Part(drawing); - if (!candidate.Part2Rotation.IsEqualTo(0)) - part2.Rotate(candidate.Part2Rotation); - var bbox2 = part2.Program.BoundingBox(); - part2.Offset(-bbox2.Location.X, -bbox2.Location.Y); - part2.Location = candidate.Part2Offset; - part2.UpdateBounds(); - - // Check overlap via shape intersection - var overlaps = CheckOverlap(part1, part2, candidate.Spacing); - - // Collect all polygon vertices for convex hull / optimal rotation - var allPoints = GetPartVertices(part1); - allPoints.AddRange(GetPartVertices(part2)); - - // Find optimal bounding rectangle via rotating calipers - double bestArea, bestWidth, bestHeight, bestRotation; - - if (allPoints.Count >= 3) - { - var hull = ConvexHull.Compute(allPoints); - var result = RotatingCalipers.MinimumBoundingRectangle(hull); - bestArea = result.Area; - bestWidth = result.Width; - bestHeight = result.Height; - bestRotation = result.Angle; - } - else - { - var combinedBox = ((IEnumerable)new[] { part1, part2 }).GetBoundingBox(); - bestArea = combinedBox.Area(); - bestWidth = combinedBox.Width; - bestHeight = combinedBox.Height; - bestRotation = 0; - } - - var trueArea = drawing.Area * 2; - - return new BestFitResult - { - Candidate = candidate, - RotatedArea = bestArea, - BoundingWidth = bestWidth, - BoundingHeight = bestHeight, - OptimalRotation = bestRotation, - TrueArea = trueArea, - Keep = !overlaps, - Reason = overlaps ? "Overlap detected" : "Valid" - }; - } - - private bool CheckOverlap(Part part1, Part part2, double spacing) - { - var shapes1 = GetPartShapes(part1); - var shapes2 = GetPartShapes(part2); - - for (var i = 0; i < shapes1.Count; i++) - { - for (var j = 0; j < shapes2.Count; j++) - { - List pts; - - if (shapes1[i].Intersects(shapes2[j], out pts)) - return true; - } - } - - return false; - } - - private List GetPartShapes(Part part) - { - var entities = ConvertProgram.ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); - shapes.ForEach(s => s.Offset(part.Location)); - return shapes; - } - - private List GetPartVertices(Part part) - { - var entities = ConvertProgram.ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); - var points = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(ChordTolerance); - polygon.Offset(part.Location); - - foreach (var vertex in polygon.Vertices) - points.Add(vertex); - } - - return points; - } - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build to verify** - -**Step 4: Commit** - -``` -feat: add PairEvaluator with overlap detection and optimal rotation -``` - ---- - -## Task 6: BestFitFilter - -**Files:** -- Create: `OpenNest.Engine/BestFit/BestFitFilter.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -using System.Collections.Generic; - -namespace OpenNest.Engine.BestFit -{ - public class BestFitFilter - { - public double MaxPlateWidth { get; set; } - public double MaxPlateHeight { get; set; } - public double MaxAspectRatio { get; set; } = 5.0; - public double MinUtilization { get; set; } = 0.3; - - public void Apply(List results) - { - foreach (var result in results) - { - if (!result.Keep) - continue; - - if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight)) - { - result.Keep = false; - result.Reason = "Exceeds plate dimensions"; - continue; - } - - var aspect = result.LongestSide / result.ShortestSide; - - if (aspect > MaxAspectRatio) - { - result.Keep = false; - result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio); - continue; - } - - if (result.Utilization < MinUtilization) - { - result.Keep = false; - result.Reason = string.Format("Utilization {0:P0} below minimum", result.Utilization); - continue; - } - - result.Reason = "Valid"; - } - } - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build to verify** - -**Step 4: Commit** - -``` -feat: add BestFitFilter with plate size, aspect ratio, and utilization rules -``` - ---- - -## Task 7: TileResult and TileEvaluator - -**Files:** -- Create: `OpenNest.Engine/BestFit/Tiling/TileResult.cs` -- Create: `OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create TileResult.cs** - -```csharp -using System.Collections.Generic; -using OpenNest.Geometry; - -namespace OpenNest.Engine.BestFit.Tiling -{ - public class TileResult - { - public BestFitResult BestFit { get; set; } - public int PairsNested { get; set; } - public int PartsNested { get; set; } - public int Rows { get; set; } - public int Columns { get; set; } - public double Utilization { get; set; } - public List Placements { get; set; } - public bool PairRotated { get; set; } - } - - public class PairPlacement - { - public Vector Position { get; set; } - public double PairRotation { get; set; } - } -} -``` - -**Step 2: Create TileEvaluator.cs** - -```csharp -using System; -using System.Collections.Generic; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine.BestFit.Tiling -{ - public class TileEvaluator - { - public TileResult Evaluate(BestFitResult bestFit, Plate plate) - { - var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right; - var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom; - - var result1 = TryTile(bestFit, plateWidth, plateHeight, false); - var result2 = TryTile(bestFit, plateWidth, plateHeight, true); - return result1.PartsNested >= result2.PartsNested ? result1 : result2; - } - - private TileResult TryTile(BestFitResult bestFit, double plateWidth, double plateHeight, bool rotatePair) - { - var pairWidth = rotatePair ? bestFit.BoundingHeight : bestFit.BoundingWidth; - var pairHeight = rotatePair ? bestFit.BoundingWidth : bestFit.BoundingHeight; - var spacing = bestFit.Candidate.Spacing; - - var cols = (int)System.Math.Floor((plateWidth + spacing) / (pairWidth + spacing)); - var rows = (int)System.Math.Floor((plateHeight + spacing) / (pairHeight + spacing)); - var pairsNested = cols * rows; - var partsNested = pairsNested * 2; - - var usedArea = partsNested * (bestFit.TrueArea / 2); - var plateArea = plateWidth * plateHeight; - - var placements = new List(); - - for (var row = 0; row < rows; row++) - { - for (var col = 0; col < cols; col++) - { - placements.Add(new PairPlacement - { - Position = new Vector( - col * (pairWidth + spacing), - row * (pairHeight + spacing)), - PairRotation = rotatePair ? Angle.HalfPI : 0 - }); - } - } - - return new TileResult - { - BestFit = bestFit, - PairsNested = pairsNested, - PartsNested = partsNested, - Rows = rows, - Columns = cols, - Utilization = plateArea > 0 ? usedArea / plateArea : 0, - Placements = placements, - PairRotated = rotatePair - }; - } - } -} -``` - -**Step 3: Add to .csproj** - -```xml - - -``` - -**Step 4: Build to verify** - -**Step 5: Commit** - -``` -feat: add TileEvaluator and TileResult for pair tiling on plates -``` - ---- - -## Task 8: BestFitFinder (Orchestrator) - -Computes hull edge angles from the drawing, builds `RotationSlideStrategy` instances for each angle in `{0, pi/2, pi, 3pi/2} + hull edges + hull edges + pi`, runs all strategies, evaluates, filters, and sorts. - -**Files:** -- Create: `OpenNest.Engine/BestFit/BestFitFinder.cs` -- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` - -**Step 1: Create file** - -```csharp -using System.Collections.Generic; -using System.Linq; -using OpenNest.Converters; -using OpenNest.Engine.BestFit.Tiling; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.Engine.BestFit -{ - public class BestFitFinder - { - private readonly PairEvaluator _evaluator; - private readonly BestFitFilter _filter; - - public BestFitFinder(double maxPlateWidth, double maxPlateHeight) - { - _evaluator = new PairEvaluator(); - _filter = new BestFitFilter - { - MaxPlateWidth = maxPlateWidth, - MaxPlateHeight = maxPlateHeight - }; - } - - public List FindBestFits( - Drawing drawing, - double spacing = 0.25, - double stepSize = 0.25, - BestFitSortField sortBy = BestFitSortField.Area) - { - var strategies = BuildStrategies(drawing); - - var allCandidates = new List(); - - foreach (var strategy in strategies) - allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize)); - - var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList(); - - _filter.Apply(results); - - results = SortResults(results, sortBy); - - for (var i = 0; i < results.Count; i++) - results[i].Candidate.TestNumber = i; - - return results; - } - - public List FindAndTile( - Drawing drawing, Plate plate, - double spacing = 0.25, double stepSize = 0.25, int topN = 10) - { - var bestFits = FindBestFits(drawing, spacing, stepSize); - var tileEvaluator = new TileEvaluator(); - - return bestFits - .Where(r => r.Keep) - .Take(topN) - .Select(r => tileEvaluator.Evaluate(r, plate)) - .OrderByDescending(t => t.PartsNested) - .ThenByDescending(t => t.Utilization) - .ToList(); - } - - private List BuildStrategies(Drawing drawing) - { - var angles = GetRotationAngles(drawing); - var strategies = new List(); - var type = 1; - - foreach (var angle in angles) - { - var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle)); - strategies.Add(new RotationSlideStrategy(angle, type++, desc)); - } - - return strategies; - } - - private List GetRotationAngles(Drawing drawing) - { - var angles = new List - { - 0, - Angle.HalfPI, - System.Math.PI, - Angle.HalfPI * 3 - }; - - // Add hull edge angles - var hullAngles = GetHullEdgeAngles(drawing); - - foreach (var hullAngle in hullAngles) - { - AddUniqueAngle(angles, hullAngle); - AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI)); - } - - return angles; - } - - private List GetHullEdgeAngles(Drawing drawing) - { - var entities = ConvertProgram.ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); - - var points = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(0.1); - points.AddRange(polygon.Vertices); - } - - if (points.Count < 3) - return new List(); - - var hull = ConvexHull.Compute(points); - var vertices = hull.Vertices; - var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; - var hullAngles = new List(); - - for (var i = 0; i < n; i++) - { - var next = (i + 1) % n; - var dx = vertices[next].X - vertices[i].X; - var dy = vertices[next].Y - vertices[i].Y; - - if (dx * dx + dy * dy < Tolerance.Epsilon) - continue; - - var angle = Angle.NormalizeRad(System.Math.Atan2(dy, dx)); - AddUniqueAngle(hullAngles, angle); - } - - return hullAngles; - } - - private static void AddUniqueAngle(List angles, double angle) - { - angle = Angle.NormalizeRad(angle); - - foreach (var existing in angles) - { - if (existing.IsEqualTo(angle)) - return; - } - - angles.Add(angle); - } - - private List SortResults(List results, BestFitSortField sortBy) - { - switch (sortBy) - { - case BestFitSortField.Area: - return results.OrderBy(r => r.RotatedArea).ToList(); - case BestFitSortField.LongestSide: - return results.OrderBy(r => r.LongestSide).ToList(); - case BestFitSortField.ShortestSide: - return results.OrderBy(r => r.ShortestSide).ToList(); - case BestFitSortField.Type: - return results.OrderBy(r => r.Candidate.StrategyType) - .ThenBy(r => r.Candidate.TestNumber).ToList(); - case BestFitSortField.OriginalSequence: - return results.OrderBy(r => r.Candidate.TestNumber).ToList(); - case BestFitSortField.Keep: - return results.OrderByDescending(r => r.Keep) - .ThenBy(r => r.RotatedArea).ToList(); - case BestFitSortField.WhyKeepDrop: - return results.OrderBy(r => r.Reason) - .ThenBy(r => r.RotatedArea).ToList(); - default: - return results; - } - } - } -} -``` - -**Step 2: Add to .csproj** - -```xml - -``` - -**Step 3: Build full solution to verify all references resolve** - -Run: `msbuild OpenNest.sln /p:Configuration=Debug /v:q` - -**Step 4: Commit** - -``` -feat: add BestFitFinder orchestrator with hull edge angle strategies -``` - ---- - -## Task 9: Final Integration Build and Smoke Test - -**Step 1: Clean build of entire solution** - -Run: `msbuild OpenNest.sln /t:Rebuild /p:Configuration=Debug /v:q` -Expected: Build succeeded, 0 errors - -**Step 2: Verify all new files are included** - -Check that all 8 new files appear in the build output by reviewing the .csproj has these entries: -```xml - - - - - - - - - -``` - -**Step 3: Final commit** - -If any build fixes were needed, commit them: -``` -fix: resolve build issues in best-fit pair finding engine -``` diff --git a/docs/plans/2026-03-07-gpu-bitmap-bestfit-design.md b/docs/plans/2026-03-07-gpu-bitmap-bestfit-design.md deleted file mode 100644 index eae94b8..0000000 --- a/docs/plans/2026-03-07-gpu-bitmap-bestfit-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# GPU Bitmap Best Fit Evaluation - -## Overview - -Add GPU-accelerated bitmap-based overlap testing to the best fit pair evaluation pipeline using ILGPU. Parts are rasterized to integer grids; overlap detection becomes cell comparison on the GPU. Runs alongside the existing geometry-based evaluator, selectable via flag. - -## Architecture - -New project `OpenNest.Gpu` (class library, `net8.0-windows`). References `OpenNest.Core` and `OpenNest.Engine`. NuGet: `ILGPU`, `ILGPU.Algorithms`. - -## Components - -### 1. `Polygon.ContainsPoint(Vector pt)` (Core) - -Ray-cast from point rightward past bounding box. Count edge intersections with polygon segments. Odd = inside, even = outside. - -### 2. `PartBitmap` (OpenNest.Gpu) - -- Rasterizes a `Drawing` to `int[]` grid -- Pipeline: `ConvertProgram.ToGeometry()` -> `Helper.GetShapes()` -> `Shape.ToPolygonWithTolerance()` -> `Polygon.ContainsPoint()` per cell center -- Dilates filled cells by `spacing / 2 / cellSize` pixels to bake in part spacing -- Default cell size: 0.05" -- Cached per drawing (rasterize once, reuse across all candidates) - -### 3. `IPairEvaluator` (Engine) - -```csharp -interface IPairEvaluator -{ - List EvaluateAll(List candidates); -} -``` - -- `PairEvaluator` — existing geometry path (CPU parallel) -- `GpuPairEvaluator` — bitmap path (GPU batch) - -### 4. `GpuPairEvaluator` (OpenNest.Gpu) - -- Constructor takes `Drawing`, `cellSize`, `spacing`. Rasterizes `PartBitmap` once. -- `EvaluateAll()` uploads bitmap + candidate params to GPU, one kernel per candidate -- Kernel: for each cell, transform to part2 space (rotation + offset), check overlap, track bounding extent -- Results: overlap count (0 = valid), bounding width/height from min/max occupied cells -- `IDisposable` — owns ILGPU `Context` + `Accelerator` - -### 5. `BestFitFinder` modification (Engine) - -- Constructor accepts optional `IPairEvaluator` -- Falls back to `PairEvaluator` if none provided -- Candidate generation (strategies, rotation angles, slide) unchanged -- Calls `IPairEvaluator.EvaluateAll(candidates)` instead of inline `Parallel.ForEach` - -### 6. Integration in `NestEngine` - -- `FillWithPairs()` creates finder with either evaluator based on `UseGpu` flag -- UI layer toggles the flag - -## Data Flow - -``` -Drawing -> PartBitmap (rasterize once, dilate for spacing) - | -Strategies -> PairCandidates[] (rotation angles x slide offsets) - | -GpuPairEvaluator.EvaluateAll(): - - Upload bitmap + candidate float4[] to GPU - - Kernel per candidate: overlap check + bounding box - - Download results - | -BestFitFilter -> sort -> BestFitResults -``` - -## Unchanged - -- `RotationSlideStrategy` and candidate generation -- `BestFitFilter`, `BestFitResult`, `TileEvaluator` -- `NestEngine.FillWithPairs()` flow (just swaps evaluator) diff --git a/docs/plans/2026-03-07-gpu-bitmap-bestfit-plan.md b/docs/plans/2026-03-07-gpu-bitmap-bestfit-plan.md deleted file mode 100644 index 636d375..0000000 --- a/docs/plans/2026-03-07-gpu-bitmap-bestfit-plan.md +++ /dev/null @@ -1,769 +0,0 @@ -# GPU Bitmap Best Fit Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add GPU-accelerated bitmap overlap testing to the best fit pair evaluator using ILGPU, alongside the existing geometry evaluator. - -**Architecture:** New `OpenNest.Gpu` project holds `PartBitmap` and `GpuPairEvaluator`. Engine gets an `IPairEvaluator` interface that both geometry and GPU paths implement. `BestFitFinder` accepts the interface; `NestEngine` selects which evaluator via a `UseGpu` flag. - -**Tech Stack:** .NET 8, ILGPU 1.5+, ILGPU.Algorithms - ---- - -### Task 1: Add `Polygon.ContainsPoint` to Core - -**Files:** -- Modify: `OpenNest.Core/Geometry/Polygon.cs:610` (before closing brace) - -**Step 1: Add ContainsPoint method** - -Insert before the closing `}` of the `Polygon` class (line 611): - -```csharp -public bool ContainsPoint(Vector pt) -{ - var n = IsClosed() ? Vertices.Count - 1 : Vertices.Count; - - if (n < 3) - return false; - - var inside = false; - - for (var i = 0, j = n - 1; i < n; j = i++) - { - var vi = Vertices[i]; - var vj = Vertices[j]; - - if ((vi.Y > pt.Y) != (vj.Y > pt.Y) && - pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X) - { - inside = !inside; - } - } - - return inside; -} -``` - -This is the standard even-odd ray casting algorithm. Casts a ray rightward from `pt`, toggles `inside` at each edge crossing. - -**Step 2: Build to verify** - -Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` -Expected: Build succeeded - -**Step 3: Commit** - -```bash -git add OpenNest.Core/Geometry/Polygon.cs -git commit -m "feat: add Polygon.ContainsPoint using ray casting" -``` - ---- - -### Task 2: Extract `IPairEvaluator` interface in Engine - -**Files:** -- Create: `OpenNest.Engine/BestFit/IPairEvaluator.cs` -- Modify: `OpenNest.Engine/BestFit/PairEvaluator.cs` - -**Step 1: Create the interface** - -```csharp -using System.Collections.Generic; - -namespace OpenNest.Engine.BestFit -{ - public interface IPairEvaluator - { - List EvaluateAll(List candidates); - } -} -``` - -**Step 2: Make `PairEvaluator` implement the interface** - -In `PairEvaluator.cs`, change the class declaration (line 9) to: - -```csharp -public class PairEvaluator : IPairEvaluator -``` - -Add the `EvaluateAll` method. This wraps the existing per-candidate `Evaluate` in a `Parallel.ForEach`, matching the current behavior in `BestFitFinder.FindBestFits()`: - -```csharp -public List EvaluateAll(List candidates) -{ - var resultBag = new System.Collections.Concurrent.ConcurrentBag(); - - System.Threading.Tasks.Parallel.ForEach(candidates, c => - { - resultBag.Add(Evaluate(c)); - }); - - return resultBag.ToList(); -} -``` - -Add `using System.Linq;` if not already present (it is — line 2). - -**Step 3: Update `BestFitFinder` to use `IPairEvaluator`** - -In `BestFitFinder.cs`: - -Change the field and constructor to accept an optional evaluator: - -```csharp -public class BestFitFinder -{ - private readonly IPairEvaluator _evaluator; - private readonly BestFitFilter _filter; - - public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null) - { - _evaluator = evaluator ?? new PairEvaluator(); - _filter = new BestFitFilter - { - MaxPlateWidth = maxPlateWidth, - MaxPlateHeight = maxPlateHeight - }; - } -``` - -Replace the evaluation `Parallel.ForEach` block in `FindBestFits()` (lines 44-52) with: - -```csharp -var results = _evaluator.EvaluateAll(allCandidates); -``` - -Remove the `ConcurrentBag` and the second `Parallel.ForEach` — those lines (44-52) are fully replaced by the single call above. - -**Step 4: Build to verify** - -Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` -Expected: Build succeeded - -**Step 5: Build full solution to verify nothing broke** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeded (NestEngine still creates BestFitFinder with 2 args — still valid) - -**Step 6: Commit** - -```bash -git add OpenNest.Engine/BestFit/IPairEvaluator.cs OpenNest.Engine/BestFit/PairEvaluator.cs OpenNest.Engine/BestFit/BestFitFinder.cs -git commit -m "refactor: extract IPairEvaluator interface from PairEvaluator" -``` - ---- - -### Task 3: Create `OpenNest.Gpu` project with `PartBitmap` - -**Files:** -- Create: `OpenNest.Gpu/OpenNest.Gpu.csproj` -- Create: `OpenNest.Gpu/PartBitmap.cs` -- Modify: `OpenNest.sln` (add project) - -**Step 1: Create project** - -```bash -cd "C:\Users\aisaacs\Desktop\Projects\OpenNest" -dotnet new classlib -n OpenNest.Gpu --framework net8.0-windows -rm OpenNest.Gpu/Class1.cs -dotnet sln OpenNest.sln add OpenNest.Gpu/OpenNest.Gpu.csproj -``` - -**Step 2: Edit csproj** - -Replace the generated `OpenNest.Gpu.csproj` with: - -```xml - - - net8.0-windows - OpenNest.Gpu - OpenNest.Gpu - - - - - - - - - - -``` - -**Step 3: Create `PartBitmap.cs`** - -```csharp -using System; -using System.Collections.Generic; -using System.Linq; -using OpenNest.Converters; -using OpenNest.Geometry; - -namespace OpenNest.Gpu -{ - public class PartBitmap - { - public int[] Cells { get; set; } - public int Width { get; set; } - public int Height { get; set; } - public double CellSize { get; set; } - public double OriginX { get; set; } - public double OriginY { get; set; } - - public static PartBitmap FromDrawing(Drawing drawing, double cellSize, double spacingDilation = 0) - { - var polygons = GetClosedPolygons(drawing); - - if (polygons.Count == 0) - return new PartBitmap { Cells = Array.Empty(), Width = 0, Height = 0, CellSize = cellSize }; - - var minX = double.MaxValue; - var minY = double.MaxValue; - var maxX = double.MinValue; - var maxY = double.MinValue; - - foreach (var poly in polygons) - { - poly.UpdateBounds(); - var bb = poly.BoundingBox; - if (bb.Left < minX) minX = bb.Left; - if (bb.Bottom < minY) minY = bb.Bottom; - if (bb.Right > maxX) maxX = bb.Right; - if (bb.Top > maxY) maxY = bb.Top; - } - - // Expand bounds by dilation amount - minX -= spacingDilation; - minY -= spacingDilation; - maxX += spacingDilation; - maxY += spacingDilation; - - var width = (int)System.Math.Ceiling((maxX - minX) / cellSize); - var height = (int)System.Math.Ceiling((maxY - minY) / cellSize); - - if (width <= 0 || height <= 0) - return new PartBitmap { Cells = Array.Empty(), Width = 0, Height = 0, CellSize = cellSize }; - - var cells = new int[width * height]; - var dilationCells = (int)System.Math.Ceiling(spacingDilation / cellSize); - - for (var y = 0; y < height; y++) - { - for (var x = 0; x < width; x++) - { - var px = minX + (x + 0.5) * cellSize; - var py = minY + (y + 0.5) * cellSize; - var pt = new Vector(px, py); - - foreach (var poly in polygons) - { - if (poly.ContainsPoint(pt)) - { - cells[y * width + x] = 1; - break; - } - } - } - } - - // Dilate: expand filled cells outward by dilationCells - if (dilationCells > 0) - Dilate(cells, width, height, dilationCells); - - return new PartBitmap - { - Cells = cells, - Width = width, - Height = height, - CellSize = cellSize, - OriginX = minX, - OriginY = minY - }; - } - - private static List GetClosedPolygons(Drawing drawing) - { - var entities = ConvertProgram.ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); - - var polygons = new List(); - - foreach (var shape in shapes) - { - if (!shape.IsClosed()) - continue; - - var polygon = shape.ToPolygonWithTolerance(0.05); - polygon.Close(); - polygons.Add(polygon); - } - - return polygons; - } - - private static void Dilate(int[] cells, int width, int height, int radius) - { - var source = (int[])cells.Clone(); - - for (var y = 0; y < height; y++) - { - for (var x = 0; x < width; x++) - { - if (source[y * width + x] != 1) - continue; - - for (var dy = -radius; dy <= radius; dy++) - { - for (var dx = -radius; dx <= radius; dx++) - { - var nx = x + dx; - var ny = y + dy; - - if (nx >= 0 && nx < width && ny >= 0 && ny < height) - cells[ny * width + nx] = 1; - } - } - } - } - } - } -} -``` - -**Step 4: Build** - -Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj` -Expected: Build succeeded (ILGPU NuGet restored) - -**Step 5: Commit** - -```bash -git add OpenNest.Gpu/ OpenNest.sln -git commit -m "feat: add OpenNest.Gpu project with PartBitmap rasterizer" -``` - ---- - -### Task 4: Implement `GpuPairEvaluator` with ILGPU kernel - -**Files:** -- Create: `OpenNest.Gpu/GpuPairEvaluator.cs` - -**Step 1: Create the evaluator** - -```csharp -using System; -using System.Collections.Generic; -using ILGPU; -using ILGPU.Runtime; -using OpenNest.Engine.BestFit; -using OpenNest.Geometry; - -namespace OpenNest.Gpu -{ - public class GpuPairEvaluator : IPairEvaluator, IDisposable - { - private readonly Context _context; - private readonly Accelerator _accelerator; - private readonly Drawing _drawing; - private readonly PartBitmap _bitmap; - private readonly double _spacing; - - public const double DefaultCellSize = 0.05; - - public GpuPairEvaluator(Drawing drawing, double spacing, double cellSize = DefaultCellSize) - { - _drawing = drawing; - _spacing = spacing; - _context = Context.CreateDefault(); - _accelerator = _context.GetPreferredDevice(preferCPU: false) - .CreateAccelerator(_context); - - var dilation = spacing / 2.0; - _bitmap = PartBitmap.FromDrawing(drawing, cellSize, dilation); - } - - public List EvaluateAll(List candidates) - { - if (_bitmap.Width == 0 || _bitmap.Height == 0 || candidates.Count == 0) - return new List(); - - var bitmapWidth = _bitmap.Width; - var bitmapHeight = _bitmap.Height; - var cellSize = (float)_bitmap.CellSize; - var candidateCount = candidates.Count; - - // Pack candidate parameters: offsetX, offsetY, rotation, unused - var candidateParams = new float[candidateCount * 4]; - - for (var i = 0; i < candidateCount; i++) - { - candidateParams[i * 4 + 0] = (float)candidates[i].Part2Offset.X; - candidateParams[i * 4 + 1] = (float)candidates[i].Part2Offset.Y; - candidateParams[i * 4 + 2] = (float)candidates[i].Part2Rotation; - candidateParams[i * 4 + 3] = 0f; - } - - // Results: overlapCount, minX, minY, maxX, maxY per candidate - var resultData = new int[candidateCount * 5]; - - // Initialize min to large, max to small - for (var i = 0; i < candidateCount; i++) - { - resultData[i * 5 + 0] = 0; // overlapCount - resultData[i * 5 + 1] = int.MaxValue; // minX - resultData[i * 5 + 2] = int.MaxValue; // minY - resultData[i * 5 + 3] = int.MinValue; // maxX - resultData[i * 5 + 4] = int.MinValue; // maxY - } - - using var gpuBitmap = _accelerator.Allocate1D(_bitmap.Cells); - using var gpuParams = _accelerator.Allocate1D(candidateParams); - using var gpuResults = _accelerator.Allocate1D(resultData); - - var kernel = _accelerator.LoadAutoGroupedStreamKernel< - Index1D, - ArrayView, - ArrayView, - ArrayView, - int, int, float, float, float>(EvaluateKernel); - - kernel( - candidateCount, - gpuBitmap.View, - gpuParams.View, - gpuResults.View, - bitmapWidth, - bitmapHeight, - cellSize, - (float)_bitmap.OriginX, - (float)_bitmap.OriginY); - - _accelerator.Synchronize(); - gpuResults.CopyToCPU(resultData); - - var trueArea = _drawing.Area * 2; - var results = new List(candidateCount); - - for (var i = 0; i < candidateCount; i++) - { - var overlapCount = resultData[i * 5 + 0]; - var minX = resultData[i * 5 + 1]; - var minY = resultData[i * 5 + 2]; - var maxX = resultData[i * 5 + 3]; - var maxY = resultData[i * 5 + 4]; - - var hasOverlap = overlapCount > 0; - var hasBounds = minX <= maxX && minY <= maxY; - - double boundingWidth = 0, boundingHeight = 0, area = 0; - - if (hasBounds) - { - boundingWidth = (maxX - minX + 1) * _bitmap.CellSize; - boundingHeight = (maxY - minY + 1) * _bitmap.CellSize; - area = boundingWidth * boundingHeight; - } - - results.Add(new BestFitResult - { - Candidate = candidates[i], - RotatedArea = area, - BoundingWidth = boundingWidth, - BoundingHeight = boundingHeight, - OptimalRotation = 0, - TrueArea = trueArea, - Keep = !hasOverlap && hasBounds, - Reason = hasOverlap ? "Overlap detected" : hasBounds ? "Valid" : "No bounds" - }); - } - - return results; - } - - private static void EvaluateKernel( - Index1D index, - ArrayView bitmap, - ArrayView candidateParams, - ArrayView results, - int bitmapWidth, int bitmapHeight, - float cellSize, float originX, float originY) - { - var paramIdx = index * 4; - var offsetX = candidateParams[paramIdx + 0]; - var offsetY = candidateParams[paramIdx + 1]; - var rotation = candidateParams[paramIdx + 2]; - - // Convert world offset to cell offset relative to bitmap origin - var offsetCellsX = (offsetX - originX) / cellSize; - var offsetCellsY = (offsetY - originY) / cellSize; - - var cosR = IntrinsicMath.Cos(rotation); - var sinR = IntrinsicMath.Sin(rotation); - - var halfW = bitmapWidth * 0.5f; - var halfH = bitmapHeight * 0.5f; - - var overlapCount = 0; - var minX = int.MaxValue; - var minY = int.MaxValue; - var maxX = int.MinValue; - var maxY = int.MinValue; - - for (var y = 0; y < bitmapHeight; y++) - { - for (var x = 0; x < bitmapWidth; x++) - { - var cell1 = bitmap[y * bitmapWidth + x]; - - // Transform (x,y) to part2 space: rotate around center then offset - var cx = x - halfW; - var cy = y - halfH; - var rx = cx * cosR - cy * sinR; - var ry = cx * sinR + cy * cosR; - var bx = (int)(rx + halfW + offsetCellsX - x); - var by = (int)(ry + halfH + offsetCellsY - y); - - // Lookup part2 bitmap cell at transformed position - var bx2 = x + bx; - var by2 = y + by; - var cell2 = 0; - - if (bx2 >= 0 && bx2 < bitmapWidth && by2 >= 0 && by2 < bitmapHeight) - cell2 = bitmap[by2 * bitmapWidth + bx2]; - - if (cell1 == 1 && cell2 == 1) - overlapCount++; - - if (cell1 == 1 || cell2 == 1) - { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - } - } - - var resultIdx = index * 5; - results[resultIdx + 0] = overlapCount; - results[resultIdx + 1] = minX; - results[resultIdx + 2] = minY; - results[resultIdx + 3] = maxX; - results[resultIdx + 4] = maxY; - } - - public void Dispose() - { - _accelerator?.Dispose(); - _context?.Dispose(); - } - } -} -``` - -Note: The kernel uses `IntrinsicMath.Cos`/`Sin` which ILGPU compiles to GPU intrinsics. The `int.MaxValue`/`int.MinValue` initialization for bounds tracking is done CPU-side before upload. - -**Step 2: Build** - -Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj` -Expected: Build succeeded - -**Step 3: Commit** - -```bash -git add OpenNest.Gpu/GpuPairEvaluator.cs -git commit -m "feat: add GpuPairEvaluator with ILGPU bitmap overlap kernel" -``` - ---- - -### Task 5: Wire GPU evaluator into `NestEngine` - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs` -- Modify: `OpenNest/OpenNest.csproj` (add reference to OpenNest.Gpu) - -**Step 1: Add `UseGpu` property to `NestEngine`** - -At the top of the `NestEngine` class (after the existing properties around line 23), add: - -```csharp -public bool UseGpu { get; set; } -``` - -**Step 2: Update `FillWithPairs` to use GPU evaluator when enabled** - -In `NestEngine.cs`, the `FillWithPairs(NestItem item, Box workArea)` method (line 268) creates a `BestFitFinder`. Change it to optionally pass a GPU evaluator. - -Add at the top of the file: - -```csharp -using OpenNest.Engine.BestFit; -``` - -(Already present — line 6.) - -Replace the `FillWithPairs(NestItem item, Box workArea)` method body. The key change is lines 270-271 where the finder is created: - -```csharp -private List FillWithPairs(NestItem item, Box workArea) -{ - IPairEvaluator evaluator = null; - - if (UseGpu) - { - try - { - evaluator = new Gpu.GpuPairEvaluator(item.Drawing, Plate.PartSpacing); - } - catch - { - // GPU not available, fall back to geometry - } - } - - var finder = new BestFitFinder(Plate.Size.Width, Plate.Size.Height, evaluator); - var bestFits = finder.FindBestFits(item.Drawing, Plate.PartSpacing, stepSize: 0.25); - - var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList(); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}"); - - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(int count, List parts)>(); - - System.Threading.Tasks.Parallel.For(0, keptResults.Count, i => - { - var result = keptResults[i]; - var pairParts = BuildPairParts(result, item.Drawing); - var angles = FindHullEdgeAngles(pairParts); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles); - - if (filled != null && filled.Count > 0) - resultBag.Add((filled.Count, filled)); - }); - - List best = null; - - foreach (var (count, parts) in resultBag) - { - if (best == null || count > best.Count) - best = parts; - } - - (evaluator as IDisposable)?.Dispose(); - - Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts"); - return best ?? new List(); -} -``` - -**Step 3: Add OpenNest.Gpu reference to UI project** - -In `OpenNest/OpenNest.csproj`, add to the `` with other project references: - -```xml - -``` - -**Step 4: Build full solution** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeded - -**Step 5: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs OpenNest/OpenNest.csproj -git commit -m "feat: wire GpuPairEvaluator into NestEngine with UseGpu flag" -``` - ---- - -### Task 6: Add UI toggle for GPU mode - -**Files:** -- Modify: `OpenNest/Forms/MainForm.cs` -- Modify: `OpenNest/Forms/MainForm.Designer.cs` - -This task adds a "Use GPU" checkbox menu item under the Tools menu. The exact placement depends on the existing menu structure. - -**Step 1: Check existing menu structure** - -Read `MainForm.Designer.cs` to find the Tools menu items and their initialization to determine where to add the GPU toggle. Look for `mnuTools` items. - -**Step 2: Add menu item field** - -In `MainForm.Designer.cs`, add a field declaration near the other menu fields: - -```csharp -private System.Windows.Forms.ToolStripMenuItem mnuToolsUseGpu; -``` - -**Step 3: Initialize menu item** - -In the `InitializeComponent()` method, initialize the item and add it to the Tools menu `DropDownItems`: - -```csharp -this.mnuToolsUseGpu = new System.Windows.Forms.ToolStripMenuItem(); -this.mnuToolsUseGpu.Name = "mnuToolsUseGpu"; -this.mnuToolsUseGpu.Text = "Use GPU for Best Fit"; -this.mnuToolsUseGpu.CheckOnClick = true; -this.mnuToolsUseGpu.Click += new System.EventHandler(this.UseGpu_Click); -``` - -Add `this.mnuToolsUseGpu` to the Tools menu's `DropDownItems` array. - -**Step 4: Add click handler in MainForm.cs** - -```csharp -private void UseGpu_Click(object sender, EventArgs e) -{ - // The CheckOnClick property handles toggling automatically -} -``` - -**Step 5: Pass the flag when creating NestEngine** - -Find where `NestEngine` is created in the codebase (likely in auto-nest or fill actions) and set `UseGpu = mnuToolsUseGpu.Checked` on the engine after creation. - -This requires reading the code to find the exact creation points. Search for `new NestEngine(` in the codebase. - -**Step 6: Build and verify** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeded - -**Step 7: Commit** - -```bash -git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs -git commit -m "feat: add Use GPU toggle in Tools menu" -``` - ---- - -### Task 7: Smoke test - -**Step 1: Run the application** - -Run: `dotnet run --project OpenNest/OpenNest.csproj` - -**Step 2: Manual verification** - -1. Open a nest file with parts -2. Verify the geometry path still works (GPU unchecked) — auto-nest a plate -3. Enable "Use GPU for Best Fit" in Tools menu -4. Auto-nest the same plate with GPU enabled -5. Compare part counts — GPU results should be close to geometry results (not exact due to bitmap approximation) -6. Check Debug output for `[FillWithPairs]` timing differences - -**Step 3: Commit any fixes** - -If any issues found, fix and commit with appropriate message. diff --git a/docs/plans/2026-03-07-net8-migration.md b/docs/plans/2026-03-07-net8-migration.md deleted file mode 100644 index ad2a3da..0000000 --- a/docs/plans/2026-03-07-net8-migration.md +++ /dev/null @@ -1,1024 +0,0 @@ -# .NET 8 Migration Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Convert OpenNest from .NET Framework 4.8 to .NET 8, replacing netDxf with ACadSharp. - -**Architecture:** Three projects (OpenNest.Core, OpenNest.Engine, OpenNest) converted from legacy csproj to SDK-style targeting `net8.0-windows`. The netDxf NuGet dependency is replaced with ACadSharp 3.1.32 in the WinForms project. The 3 IO files (DxfImporter, DxfExporter, Extensions) are rewritten against the ACadSharp API. - -**Tech Stack:** .NET 8, WinForms, ACadSharp 3.1.32, System.Drawing.Common, CSMath - ---- - -## Reference Material - -- ACadSharp usage examples: `C:\Users\aisaacs\Desktop\Projects\Geometry\Geometry.Dxf\Extensions.cs` and `DxfImport.cs` -- ACadSharp key types: - - Reading: `ACadSharp.IO.DxfReader` → `.Read()` returns `CadDocument` - - Writing: `ACadSharp.IO.DxfWriter` - - Entities: `ACadSharp.Entities.{Line, Arc, Circle, Polyline, LwPolyline, Spline, Ellipse}` - - Vectors: `CSMath.XY`, `CSMath.XYZ` - - Layers: `ACadSharp.Tables.Layer` - - Colors: `ACadSharp.Color` (ACI index-based) - - Document: `ACadSharp.CadDocument` -- netDxf → ACadSharp type mapping: - - `netDxf.Vector2` → `CSMath.XY` - - `netDxf.Vector3` → `CSMath.XYZ` - - `netDxf.DxfDocument` → `ACadSharp.CadDocument` - - `netDxf.DxfDocument.Load(path)` → `new DxfReader(path).Read()` - - `netDxf.DxfDocument.Save(stream)` → `new DxfWriter(stream, doc).Write()` - - `netDxf.Tables.Layer` → `ACadSharp.Tables.Layer` - - `netDxf.AciColor` → `ACadSharp.Color` - - `netDxf.Linetype` → `ACadSharp.Tables.LineType` - - `doc.Entities.Lines` → `doc.Entities.OfType()` - - `doc.Entities.Arcs` → `doc.Entities.OfType()` - - `spline.PolygonalVertexes(n)` → manual control point interpolation - - `polyline.Vertexes` → `polyline.Vertices` - - `ellipse.ToPolyline2D(n)` → manual parametric calculation - ---- - -### Task 1: Convert OpenNest.Core to SDK-style .NET 8 - -**Files:** -- Replace: `OpenNest.Core/OpenNest.Core.csproj` -- Delete: `OpenNest.Core/Properties/AssemblyInfo.cs` - -**Step 1: Replace the csproj with SDK-style** - -Replace `OpenNest.Core/OpenNest.Core.csproj` with: - -```xml - - - net8.0-windows - OpenNest - OpenNest.Core - false - - - - - -``` - -Notes: -- `net8.0-windows` needed because `System.Drawing.Common` requires Windows on .NET 8 -- `GenerateAssemblyInfo` false to keep existing AssemblyInfo.cs (we'll remove it in step 2 and re-enable) -- SDK-style auto-includes all `.cs` files, so no `` items needed -- `System.Drawing.Common` NuGet replaces the Framework's built-in `System.Drawing` - -**Step 2: Delete AssemblyInfo.cs and enable auto-generated assembly info** - -Delete `OpenNest.Core/Properties/AssemblyInfo.cs`. - -Then update the csproj to remove `GenerateAssemblyInfo`: - -```xml - - - net8.0-windows - OpenNest - OpenNest.Core - - - - - -``` - -**Step 3: Build to verify** - -Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` -Expected: Build succeeds with 0 errors. - -**Step 4: Commit** - -``` -feat: convert OpenNest.Core to .NET 8 SDK-style project -``` - ---- - -### Task 2: Convert OpenNest.Engine to SDK-style .NET 8 - -**Files:** -- Replace: `OpenNest.Engine/OpenNest.Engine.csproj` -- Delete: `OpenNest.Engine/Properties/AssemblyInfo.cs` - -**Step 1: Replace the csproj with SDK-style** - -```xml - - - net8.0-windows - OpenNest - OpenNest.Engine - - - - - -``` - -**Step 2: Delete AssemblyInfo.cs** - -Delete `OpenNest.Engine/Properties/AssemblyInfo.cs`. - -**Step 3: Build to verify** - -Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` -Expected: Build succeeds. - -**Step 4: Commit** - -``` -feat: convert OpenNest.Engine to .NET 8 SDK-style project -``` - ---- - -### Task 3: Convert OpenNest (WinForms) to SDK-style .NET 8 - -**Files:** -- Replace: `OpenNest/OpenNest.csproj` -- Delete: `OpenNest/Properties/AssemblyInfo.cs` -- Delete: `OpenNest/packages.config` -- Keep: `OpenNest/Properties/Settings.settings`, `Settings.Designer.cs`, `Resources.resx`, `Resources.Designer.cs` -- Keep: `OpenNest/app.config` - -**Step 1: Replace the csproj with SDK-style** - -```xml - - - WinExe - net8.0-windows - OpenNest - OpenNest - true - false - DpiUnaware - - - - - - - - - - True - Resources.resx - True - - - True - Settings.settings - True - - - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - -``` - -Notes: -- `UseWindowsForms` enables WinForms support on .NET 8 -- `ApplicationHighDpiMode` replaces the manual `SetProcessDPIAware()` P/Invoke -- ACadSharp replaces netDxf package reference -- `GenerateAssemblyInfo` false initially — we'll clean up AssemblyInfo and re-enable after - -**Step 2: Delete AssemblyInfo.cs and packages.config** - -Delete `OpenNest/Properties/AssemblyInfo.cs` and `OpenNest/packages.config`. - -Then remove `GenerateAssemblyInfo` from the csproj. - -**Step 3: Build to check what breaks** - -Run: `dotnet build OpenNest/OpenNest.csproj` -Expected: Build fails — the 3 IO files reference `netDxf` types. This is expected and will be fixed in Tasks 4-6. - -**Step 4: Commit (with build errors expected)** - -``` -feat: convert OpenNest WinForms project to .NET 8 SDK-style - -Build errors expected in IO files — netDxf references not yet migrated to ACadSharp. -``` - ---- - -### Task 4: Rewrite Extensions.cs for ACadSharp - -**Files:** -- Modify: `OpenNest/IO/Extensions.cs` -- Reference: `C:\Users\aisaacs\Desktop\Projects\Geometry\Geometry.Dxf\Extensions.cs` - -**Step 1: Rewrite Extensions.cs** - -Replace the full file. Key changes: -- `netDxf.Vector2` → `CSMath.XY` -- `netDxf.Vector3` → `CSMath.XYZ` -- `netDxf.Entities.*` → `ACadSharp.Entities.*` -- `netDxf.Tables.Layer` → `ACadSharp.Tables.Layer` -- `layer.Color.ToColor()` → `System.Drawing.Color.FromArgb(layer.Color.GetColorIndex()...)` or equivalent -- `spline.PolygonalVertexes(n)` → manual control point iteration -- `polyline.Vertexes` → `polyline.Vertices` -- `ellipse.ToPolyline2D(n)` → manual parametric calculation -- Polyline2D closed check: use `Flags` property -- `arc.StartAngle`/`arc.EndAngle` — ACadSharp stores arc angles in **radians** already (netDxf used degrees) - -```csharp -using System.Collections.Generic; -using System.Linq; -using ACadSharp.Entities; -using CSMath; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest.IO -{ - internal static class Extensions - { - public static Vector ToOpenNest(this XY v) - { - return new Vector(v.X, v.Y); - } - - public static Vector ToOpenNest(this XYZ v) - { - return new Vector(v.X, v.Y); - } - - public static Arc ToOpenNest(this ACadSharp.Entities.Arc arc) - { - // ACadSharp stores angles in radians already - return new Arc( - arc.Center.X, arc.Center.Y, arc.Radius, - arc.StartAngle, - arc.EndAngle) - { - Layer = arc.Layer.ToOpenNest() - }; - } - - public static Circle ToOpenNest(this ACadSharp.Entities.Circle circle) - { - return new Circle( - circle.Center.X, circle.Center.Y, - circle.Radius) - { - Layer = circle.Layer.ToOpenNest() - }; - } - - public static Line ToOpenNest(this ACadSharp.Entities.Line line) - { - return new Line( - line.StartPoint.X, line.StartPoint.Y, - line.EndPoint.X, line.EndPoint.Y) - { - Layer = line.Layer.ToOpenNest() - }; - } - - public static List ToOpenNest(this Spline spline, int precision = 200) - { - var lines = new List(); - var controlPoints = spline.ControlPoints - .Select(p => new Vector(p.X, p.Y)).ToList(); - - if (controlPoints.Count < 2) - return lines; - - var lastPoint = controlPoints[0]; - - for (int i = 1; i < controlPoints.Count; i++) - { - var nextPoint = controlPoints[i]; - lines.Add(new Line(lastPoint, nextPoint) - { Layer = spline.Layer.ToOpenNest() }); - lastPoint = nextPoint; - } - - if (spline.IsClosed) - lines.Add(new Line(lastPoint, controlPoints[0]) - { Layer = spline.Layer.ToOpenNest() }); - - return lines; - } - - public static List ToOpenNest(this Polyline polyline) - { - var lines = new List(); - var vertices = polyline.Vertices.ToList(); - - if (vertices.Count == 0) - return lines; - - var lastPoint = vertices[0].Location.ToOpenNest(); - - for (int i = 1; i < vertices.Count; i++) - { - var nextPoint = vertices[i].Location.ToOpenNest(); - lines.Add(new Line(lastPoint, nextPoint) - { Layer = polyline.Layer.ToOpenNest() }); - lastPoint = nextPoint; - } - - if ((polyline.Flags & PolylineFlags.ClosedPolylineOrClosedPolygonMeshInM) != 0) - lines.Add(new Line(lastPoint, vertices[0].Location.ToOpenNest()) - { Layer = polyline.Layer.ToOpenNest() }); - - return lines; - } - - public static List ToOpenNest(this LwPolyline polyline) - { - var lines = new List(); - var vertices = polyline.Vertices.ToList(); - - if (vertices.Count == 0) - return lines; - - var lastPoint = new Vector(vertices[0].Location.X, vertices[0].Location.Y); - - for (int i = 1; i < vertices.Count; i++) - { - var nextPoint = new Vector(vertices[i].Location.X, vertices[i].Location.Y); - lines.Add(new Line(lastPoint, nextPoint) - { Layer = polyline.Layer.ToOpenNest() }); - lastPoint = nextPoint; - } - - if ((polyline.Flags & LwPolylineFlags.Closed) != 0) - lines.Add(new Line(lastPoint, - new Vector(vertices[0].Location.X, vertices[0].Location.Y)) - { Layer = polyline.Layer.ToOpenNest() }); - - return lines; - } - - public static List ToOpenNest(this Ellipse ellipse, int precision = 200) - { - var lines = new List(); - var points = new List(); - var startParam = ellipse.StartParameter; - var endParam = ellipse.EndParameter; - var paramRange = endParam - startParam; - var radius = System.Math.Sqrt( - ellipse.EndPoint.X * ellipse.EndPoint.X + - ellipse.EndPoint.Y * ellipse.EndPoint.Y); - - for (int i = 0; i <= precision; i++) - { - var t = i / (double)precision; - var angle = startParam + paramRange * t; - var x = ellipse.Center.X + radius * System.Math.Cos(angle); - var y = ellipse.Center.Y + radius * System.Math.Sin(angle); - points.Add(new Vector(x, y)); - } - - for (int i = 1; i < points.Count; i++) - { - lines.Add(new Line(points[i - 1], points[i]) - { Layer = ellipse.Layer.ToOpenNest() }); - } - - if (points.Count > 1 && - System.Math.Abs(paramRange - 2 * System.Math.PI) < 0.0001) - { - lines.Add(new Line(points[points.Count - 1], points[0]) - { Layer = ellipse.Layer.ToOpenNest() }); - } - - return lines; - } - - public static Layer ToOpenNest(this ACadSharp.Tables.Layer layer) - { - return new Layer(layer.Name) - { - Color = System.Drawing.Color.FromArgb( - layer.Color.R, layer.Color.G, layer.Color.B), - IsVisible = layer.IsOn - }; - } - - public static XY ToAcad(this Vector v) - { - return new XY(v.X, v.Y); - } - - public static XYZ ToAcadXYZ(this Vector v) - { - return new XYZ(v.X, v.Y, 0); - } - } -} -``` - -**Step 2: Commit** - -``` -refactor: rewrite IO Extensions for ACadSharp -``` - ---- - -### Task 5: Rewrite DxfImporter.cs for ACadSharp - -**Files:** -- Modify: `OpenNest/IO/DxfImporter.cs` - -**Step 1: Rewrite DxfImporter.cs** - -Key changes: -- `DxfDocument.Load(path)` → `new DxfReader(path).Read()` returning `CadDocument` -- `doc.Entities.Lines` → `doc.Entities.OfType()` -- Same pattern for Arcs, Circles, Splines, etc. -- `doc.Entities.Polylines2D` → `doc.Entities.OfType()` + `doc.Entities.OfType()` -- `doc.Entities.Polylines3D` → handled via `Polyline` type - -```csharp -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using ACadSharp; -using ACadSharp.IO; -using OpenNest.Geometry; - -namespace OpenNest.IO -{ - public class DxfImporter - { - public int SplinePrecision { get; set; } - - public DxfImporter() - { - } - - private List GetGeometry(CadDocument doc) - { - var entities = new List(); - var lines = new List(); - var arcs = new List(); - - foreach (var entity in doc.Entities) - { - switch (entity) - { - case ACadSharp.Entities.Spline spline: - lines.AddRange(spline.ToOpenNest(SplinePrecision)); - break; - - case ACadSharp.Entities.LwPolyline lwPolyline: - lines.AddRange(lwPolyline.ToOpenNest()); - break; - - case ACadSharp.Entities.Polyline polyline: - lines.AddRange(polyline.ToOpenNest()); - break; - - case ACadSharp.Entities.Ellipse ellipse: - lines.AddRange(ellipse.ToOpenNest(SplinePrecision)); - break; - - case ACadSharp.Entities.Line line: - lines.Add(line.ToOpenNest()); - break; - - case ACadSharp.Entities.Arc arc: - arcs.Add(arc.ToOpenNest()); - break; - - case ACadSharp.Entities.Circle circle: - entities.Add(circle.ToOpenNest()); - break; - } - } - - Helper.Optimize(lines); - Helper.Optimize(arcs); - - entities.AddRange(lines); - entities.AddRange(arcs); - - return entities; - } - - public bool GetGeometry(Stream stream, out List geometry) - { - bool success = false; - - try - { - using var reader = new DxfReader(stream); - var doc = reader.Read(); - geometry = GetGeometry(doc); - success = true; - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - geometry = new List(); - } - - return success; - } - - public bool GetGeometry(string path, out List geometry) - { - bool success = false; - - try - { - using var reader = new DxfReader(path); - var doc = reader.Read(); - geometry = GetGeometry(doc); - success = true; - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - geometry = new List(); - } - - return success; - } - } -} -``` - -**Step 2: Commit** - -``` -refactor: rewrite DxfImporter for ACadSharp -``` - ---- - -### Task 6: Rewrite DxfExporter.cs for ACadSharp - -**Files:** -- Modify: `OpenNest/IO/DxfExporter.cs` - -**Step 1: Rewrite DxfExporter.cs** - -Key changes: -- `new DxfDocument()` → `new CadDocument()` -- `doc.Entities.Add(entity)` → `doc.Entities.Add(entity)` (same pattern) -- `doc.Save(stream)` → `using var writer = new DxfWriter(stream); writer.Write(doc);` -- `new netDxf.Entities.Line(pt1, pt2)` → `new ACadSharp.Entities.Line { StartPoint = xyz1, EndPoint = xyz2 }` -- `new netDxf.Vector2(x, y)` → `new XYZ(x, y, 0)` (ACadSharp Lines use XYZ) -- `AciColor.Red` → `new Color(1)` (ACI color index 1 = red) -- `AciColor.Blue` → `new Color(5)` (ACI color index 5 = blue) -- `AciColor.Cyan` → `new Color(4)` (ACI color index 4 = cyan) -- `Linetype.Dashed` → look up or create dashed linetype -- `Vector2 curpos` → `XYZ curpos` (track as 3D) -- Arc angles: netDxf used degrees, ACadSharp uses radians — remove degree conversions - -```csharp -using System; -using System.Diagnostics; -using System.IO; -using ACadSharp; -using ACadSharp.Entities; -using ACadSharp.IO; -using ACadSharp.Tables; -using CSMath; -using OpenNest.CNC; -using OpenNest.Math; - -namespace OpenNest.IO -{ - using AcadLine = ACadSharp.Entities.Line; - using AcadArc = ACadSharp.Entities.Arc; - using AcadCircle = ACadSharp.Entities.Circle; - - public class DxfExporter - { - private CadDocument doc; - private XYZ curpos; - private Mode mode; - private readonly Layer cutLayer; - private readonly Layer rapidLayer; - private readonly Layer plateLayer; - - public DxfExporter() - { - doc = new CadDocument(); - - cutLayer = new Layer("Cut"); - cutLayer.Color = new Color(1); // Red - - rapidLayer = new Layer("Rapid"); - rapidLayer.Color = new Color(5); // Blue - - plateLayer = new Layer("Plate"); - plateLayer.Color = new Color(4); // Cyan - } - - public void ExportProgram(Program program, Stream stream) - { - doc = new CadDocument(); - EnsureLayers(); - AddProgram(program); - - using var writer = new DxfWriter(stream); - writer.Write(doc); - } - - public bool ExportProgram(Program program, string path) - { - Stream stream = null; - bool success = false; - - try - { - stream = File.Create(path); - ExportProgram(program, stream); - success = true; - } - catch - { - Debug.Fail("DxfExporter.ExportProgram failed to write program to file: " + path); - } - finally - { - if (stream != null) - stream.Close(); - } - - return success; - } - - public void ExportPlate(Plate plate, Stream stream) - { - doc = new CadDocument(); - EnsureLayers(); - AddPlateOutline(plate); - - foreach (var part in plate.Parts) - { - var endpt = part.Location.ToAcadXYZ(); - var line = new AcadLine(); - line.StartPoint = curpos; - line.EndPoint = endpt; - line.Layer = rapidLayer; - doc.Entities.Add(line); - curpos = endpt; - AddProgram(part.Program); - } - - using var writer = new DxfWriter(stream); - writer.Write(doc); - } - - public bool ExportPlate(Plate plate, string path) - { - Stream stream = null; - bool success = false; - - try - { - stream = File.Create(path); - ExportPlate(plate, stream); - success = true; - } - catch - { - Debug.Fail("DxfExporter.ExportPlate failed to write plate to file: " + path); - } - finally - { - if (stream != null) - stream.Close(); - } - - return success; - } - - private void EnsureLayers() - { - if (!doc.Layers.Contains(cutLayer.Name)) - doc.Layers.Add(cutLayer); - if (!doc.Layers.Contains(rapidLayer.Name)) - doc.Layers.Add(rapidLayer); - if (!doc.Layers.Contains(plateLayer.Name)) - doc.Layers.Add(plateLayer); - } - - private void AddPlateOutline(Plate plate) - { - XYZ pt1, pt2, pt3, pt4; - - switch (plate.Quadrant) - { - case 1: - pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, plate.Size.Height, 0); - pt3 = new XYZ(plate.Size.Width, plate.Size.Height, 0); - pt4 = new XYZ(plate.Size.Width, 0, 0); - break; - - case 2: - pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, plate.Size.Height, 0); - pt3 = new XYZ(-plate.Size.Width, plate.Size.Height, 0); - pt4 = new XYZ(-plate.Size.Width, 0, 0); - break; - - case 3: - pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, -plate.Size.Height, 0); - pt3 = new XYZ(-plate.Size.Width, -plate.Size.Height, 0); - pt4 = new XYZ(-plate.Size.Width, 0, 0); - break; - - case 4: - pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, -plate.Size.Height, 0); - pt3 = new XYZ(plate.Size.Width, -plate.Size.Height, 0); - pt4 = new XYZ(plate.Size.Width, 0, 0); - break; - - default: - return; - } - - AddLine(pt1, pt2, plateLayer); - AddLine(pt2, pt3, plateLayer); - AddLine(pt3, pt4, plateLayer); - AddLine(pt4, pt1, plateLayer); - - // Inner margin lines - var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0); - var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0); - var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0); - var m4 = new XYZ(m3.X, m1.Y, 0); - - AddLine(m1, m2, plateLayer); - AddLine(m2, m3, plateLayer); - AddLine(m3, m4, plateLayer); - AddLine(m4, m1, plateLayer); - } - - private void AddLine(XYZ start, XYZ end, Layer layer) - { - var line = new AcadLine(); - line.StartPoint = start; - line.EndPoint = end; - line.Layer = layer; - doc.Entities.Add(line); - } - - private void AddProgram(Program program) - { - mode = program.Mode; - - for (int i = 0; i < program.Length; ++i) - { - var code = program[i]; - - switch (code.Type) - { - case CodeType.ArcMove: - var arc = (ArcMove)code; - AddArcMove(arc); - break; - - case CodeType.LinearMove: - var line = (LinearMove)code; - AddLinearMove(line); - break; - - case CodeType.RapidMove: - var rapid = (RapidMove)code; - AddRapidMove(rapid); - break; - - case CodeType.SubProgramCall: - var tmpmode = mode; - var subpgm = (CNC.SubProgramCall)code; - AddProgram(subpgm.Program); - mode = tmpmode; - break; - } - } - } - - private void AddLinearMove(LinearMove line) - { - var pt = line.EndPoint.ToAcadXYZ(); - - if (mode == Mode.Incremental) - pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0); - - AddLine(curpos, pt, cutLayer); - curpos = pt; - } - - private void AddRapidMove(RapidMove rapid) - { - var pt = rapid.EndPoint.ToAcadXYZ(); - - if (mode == Mode.Incremental) - pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0); - - AddLine(curpos, pt, rapidLayer); - curpos = pt; - } - - private void AddArcMove(ArcMove arc) - { - var center = arc.CenterPoint.ToAcadXYZ(); - var endpt = arc.EndPoint.ToAcadXYZ(); - - if (mode == Mode.Incremental) - { - endpt = new XYZ(endpt.X + curpos.X, endpt.Y + curpos.Y, 0); - center = new XYZ(center.X + curpos.X, center.Y + curpos.Y, 0); - } - - // ACadSharp uses radians — no conversion needed - var startAngle = System.Math.Atan2( - curpos.Y - center.Y, - curpos.X - center.X); - - var endAngle = System.Math.Atan2( - endpt.Y - center.Y, - endpt.X - center.X); - - if (arc.Rotation == OpenNest.RotationType.CW) - Generic.Swap(ref startAngle, ref endAngle); - - var dx = endpt.X - center.X; - var dy = endpt.Y - center.Y; - var radius = System.Math.Sqrt(dx * dx + dy * dy); - - if (startAngle.IsEqualTo(endAngle)) - { - var circle = new AcadCircle(); - circle.Center = center; - circle.Radius = radius; - circle.Layer = cutLayer; - doc.Entities.Add(circle); - } - else - { - var arc2 = new AcadArc(); - arc2.Center = center; - arc2.Radius = radius; - arc2.StartAngle = startAngle; - arc2.EndAngle = endAngle; - arc2.Layer = cutLayer; - doc.Entities.Add(arc2); - } - - curpos = endpt; - } - } -} -``` - -**Step 2: Commit** - -``` -refactor: rewrite DxfExporter for ACadSharp -``` - ---- - -### Task 7: Update MainApp.cs for .NET 8 - -**Files:** -- Modify: `OpenNest/MainApp.cs` - -**Step 1: Simplify MainApp.cs** - -.NET 8 WinForms handles DPI awareness via the `ApplicationHighDpiMode` project property. Remove the manual `SetProcessDPIAware()` P/Invoke. - -```csharp -using System; -using System.Windows.Forms; -using OpenNest.Forms; - -namespace OpenNest -{ - internal static class MainApp - { - [STAThread] - private static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new MainForm()); - } - } -} -``` - -**Step 2: Commit** - -``` -refactor: simplify MainApp for .NET 8 DPI handling -``` - ---- - -### Task 8: Update solution file and clean up - -**Files:** -- Modify: `OpenNest.sln` -- Delete: `packages/` directory (if present on disk) -- Verify: `.gitignore` already ignores `packages/` - -**Step 1: Update solution file** - -Replace `OpenNest.sln` with a clean version. The project GUIDs stay the same but the solution format is updated: - -``` -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.0.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest", "OpenNest\OpenNest.csproj", "{1F1E40E0-5C53-474F-A258-69C9C3FAC15A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Core", "OpenNest.Core\OpenNest.Core.csproj", "{5A5FDE8D-F8DB-440E-866C-C4807E1686CF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine", "OpenNest.Engine\OpenNest.Engine.csproj", "{0083B9CC-54AD-4085-A30D-56BC6834B71A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.Build.0 = Release|Any CPU - {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.Build.0 = Release|Any CPU - {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal -``` - -**Step 2: Full solution build** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds with 0 errors. - -**Step 3: Fix any remaining build errors** - -If there are build errors, fix them. Common issues: -- `System.IO.Compression` — now built into .NET 8, no reference needed -- Missing `using` statements for new namespaces -- API differences in `System.Drawing.Common` on .NET 8 -- `LayoutViewGL.cs` exists on disk but was never in the old csproj — SDK-style will auto-include it. Exclude or delete it if it causes errors. - -**Step 4: Commit** - -``` -feat: complete .NET 8 migration — update solution and clean up -``` - ---- - -### Task 9: Update CLAUDE.md - -**Files:** -- Modify: `CLAUDE.md` - -**Step 1: Update build instructions and dependency info** - -Update the Build section to reflect `dotnet build` instead of `msbuild`, remove references to `packages/` folder and `nuget restore`, update netDxf references to ACadSharp, and note .NET 8 target. - -**Step 2: Commit** - -``` -docs: update CLAUDE.md for .NET 8 migration -``` diff --git a/docs/plans/2026-03-08-mcp-service-design.md b/docs/plans/2026-03-08-mcp-service-design.md deleted file mode 100644 index 2caf7d7..0000000 --- a/docs/plans/2026-03-08-mcp-service-design.md +++ /dev/null @@ -1,91 +0,0 @@ -# OpenNest MCP Service + IO Library Refactor - -## Goal - -Create an MCP server so Claude Code can load nest files, run nesting algorithms, and inspect results — enabling rapid iteration on nesting strategies without launching the WinForms app. - -## Project Changes - -``` -OpenNest.Core (no external deps) — add Plate.GetRemnants() -OpenNest.Engine → Core -OpenNest.IO (NEW) → Core + ACadSharp — extracted from OpenNest/IO/ -OpenNest.Mcp (NEW) → Core + Engine + IO -OpenNest (WinForms) → Core + Engine + IO (drops ACadSharp direct ref) -``` - -## OpenNest.IO Library - -New class library. Move from the UI project (`OpenNest/IO/`): - -- `DxfImporter` -- `DxfExporter` -- `NestReader` -- `NestWriter` -- `ProgramReader` -- ACadSharp NuGet dependency (3.1.32) - -The WinForms project drops its direct ACadSharp reference and references OpenNest.IO instead. - -## Plate.GetRemnants() - -Add to `Plate` in Core. Simple strip-based scan: - -1. Collect all part bounding boxes inflated by `PartSpacing`. -2. Scan the work area for clear rectangular strips along edges and between part columns/rows. -3. Return `List` of usable empty regions. - -No engine dependency — uses only work area and part bounding boxes already available on Plate. - -## MCP Tools - -### Input -| Tool | Description | -|------|-------------| -| `load_nest` | Load a `.nest` zip file, returns nest summary (plates, drawings, part counts) | -| `import_dxf` | Import a DXF file as a drawing | -| `create_drawing` | Create from built-in shape primitive (rect, circle, L, T) or raw G-code string | - -### Setup -| Tool | Description | -|------|-------------| -| `create_plate` | Define a plate with dimensions, spacing, edge spacing, quadrant | -| `clear_plate` | Remove all parts from a plate | - -### Nesting -| Tool | Description | -|------|-------------| -| `fill_plate` | Fill entire plate with a single drawing (NestEngine.Fill) | -| `fill_area` | Fill a specific box region on a plate | -| `fill_remnants` | Auto-detect remnants via Plate.GetRemnants(), fill each with a drawing | -| `pack_plate` | Multi-drawing bin packing (NestEngine.Pack) | - -### Inspection -| Tool | Description | -|------|-------------| -| `get_plate_info` | Dimensions, part count, utilization %, remnant boxes | -| `get_parts` | List placed parts with location, rotation, bounding box | -| `check_overlaps` | Run overlap detection, return collision points | - -## Example Workflow - -``` -load_nest("N0308-008.zip") -→ 1 plate (36x36), 75 parts, 1 drawing (Converto 3 YRD DUMPER), utilization 80.2% - -get_plate_info(plate: 0) -→ utilization: 80.2%, remnants: [{x:33.5, y:0, w:2.5, h:36}] - -fill_remnants(plate: 0, drawing: "Converto 3 YRD DUMPER") -→ added 3 parts, new utilization: 83.1% - -check_overlaps(plate: 0) -→ no overlaps -``` - -## MCP Server Implementation - -- .NET 8 console app using stdio transport -- Published to `~/.claude/mcp/OpenNest.Mcp/` -- Registered in `~/.claude/settings.local.json` -- In-memory state: holds the current `Nest` object across tool calls diff --git a/docs/plans/2026-03-08-mcp-service-plan.md b/docs/plans/2026-03-08-mcp-service-plan.md deleted file mode 100644 index 4c98638..0000000 --- a/docs/plans/2026-03-08-mcp-service-plan.md +++ /dev/null @@ -1,1047 +0,0 @@ -# OpenNest MCP Service + IO Library Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Create an MCP server that allows Claude Code to load nest files, run nesting algorithms, and inspect results for rapid iteration on nesting strategies. - -**Architecture:** Extract IO classes from the WinForms project into a new `OpenNest.IO` class library, add `Plate.GetRemnants()` to Core, then build an `OpenNest.Mcp` console app that references Core + Engine + IO and exposes nesting operations as MCP tools over stdio. - -**Tech Stack:** .NET 8, ModelContextProtocol SDK, Microsoft.Extensions.Hosting, ACadSharp 3.1.32 - ---- - -### Task 1: Create the OpenNest.IO class library - -**Files:** -- Create: `OpenNest.IO/OpenNest.IO.csproj` -- Move: `OpenNest/IO/DxfImporter.cs` → `OpenNest.IO/DxfImporter.cs` -- Move: `OpenNest/IO/DxfExporter.cs` → `OpenNest.IO/DxfExporter.cs` -- Move: `OpenNest/IO/NestReader.cs` → `OpenNest.IO/NestReader.cs` -- Move: `OpenNest/IO/NestWriter.cs` → `OpenNest.IO/NestWriter.cs` -- Move: `OpenNest/IO/ProgramReader.cs` → `OpenNest.IO/ProgramReader.cs` -- Move: `OpenNest/IO/Extensions.cs` → `OpenNest.IO/Extensions.cs` -- Modify: `OpenNest/OpenNest.csproj` — replace ACadSharp ref with OpenNest.IO project ref -- Modify: `OpenNest.sln` — add OpenNest.IO project - -**Step 1: Create the IO project** - -```bash -cd C:/Users/AJ/Desktop/Projects/OpenNest -dotnet new classlib -n OpenNest.IO --framework net8.0-windows -``` - -Delete the auto-generated `Class1.cs`. - -**Step 2: Configure the csproj** - -`OpenNest.IO/OpenNest.IO.csproj`: -```xml - - - net8.0-windows - OpenNest.IO - OpenNest.IO - - - - - - -``` - -**Step 3: Move files** - -Move all 6 files from `OpenNest/IO/` to `OpenNest.IO/`: -- `DxfImporter.cs` -- `DxfExporter.cs` -- `NestReader.cs` -- `NestWriter.cs` -- `ProgramReader.cs` -- `Extensions.cs` - -These files already use `namespace OpenNest.IO` so no namespace changes needed. - -**Step 4: Update the WinForms csproj** - -In `OpenNest/OpenNest.csproj`, replace the ACadSharp PackageReference with a project reference to OpenNest.IO: - -Remove: -```xml - -``` - -Add: -```xml - -``` - -**Step 5: Add to solution** - -```bash -dotnet sln OpenNest.sln add OpenNest.IO/OpenNest.IO.csproj -``` - -**Step 6: Build and verify** - -```bash -dotnet build OpenNest.sln -``` - -Expected: clean build, zero errors. The WinForms project's `using OpenNest.IO` statements should resolve via the transitive reference. - -**Step 7: Commit** - -```bash -git add OpenNest.IO/ OpenNest/OpenNest.csproj OpenNest.sln -git add -u OpenNest/IO/ # stages the deletions -git commit -m "refactor: extract OpenNest.IO class library from WinForms project - -Move DxfImporter, DxfExporter, NestReader, NestWriter, ProgramReader, -and Extensions into a new OpenNest.IO class library. The WinForms project -now references OpenNest.IO instead of ACadSharp directly." -``` - ---- - -### Task 2: Add Plate.GetRemnants() - -**Files:** -- Modify: `OpenNest.Core/Plate.cs` - -**Step 1: Read Plate.cs to find the insertion point** - -Read the full `Plate.cs` to understand the existing structure and find the right location for the new method (after `HasOverlappingParts`). - -**Step 2: Implement GetRemnants** - -Add this method to the `Plate` class. The algorithm: -1. Get the work area (plate bounds minus edge spacing). -2. Collect all part bounding boxes, inflated by `PartSpacing`. -3. Find the rightmost part edge — the strip to the right is a remnant. -4. Find the topmost part edge — the strip above is a remnant. -5. Filter out boxes that are too small to be useful (area < 1.0) or overlap existing parts. - -```csharp -/// -/// Finds rectangular remnant (empty) regions on the plate. -/// Returns strips along edges that are clear of parts. -/// -public List GetRemnants() -{ - var work = WorkArea(); - var results = new List(); - - if (Parts.Count == 0) - { - results.Add(work); - return results; - } - - var obstacles = new List(); - foreach (var part in Parts) - obstacles.Add(part.BoundingBox.Offset(PartSpacing)); - - // Right strip: from the rightmost part edge to the work area right edge - var maxRight = double.MinValue; - foreach (var box in obstacles) - { - if (box.Right > maxRight) - maxRight = box.Right; - } - - if (maxRight < work.Right) - { - var strip = new Box(maxRight, work.Bottom, work.Right - maxRight, work.Height); - if (strip.Area() > 1.0) - results.Add(strip); - } - - // Top strip: from the topmost part edge to the work area top edge - var maxTop = double.MinValue; - foreach (var box in obstacles) - { - if (box.Top > maxTop) - maxTop = box.Top; - } - - if (maxTop < work.Top) - { - var strip = new Box(work.Left, maxTop, work.Width, work.Top - maxTop); - if (strip.Area() > 1.0) - results.Add(strip); - } - - // Bottom strip: from work area bottom to the lowest part edge - var minBottom = double.MaxValue; - foreach (var box in obstacles) - { - if (box.Bottom < minBottom) - minBottom = box.Bottom; - } - - if (minBottom > work.Bottom) - { - var strip = new Box(work.Left, work.Bottom, work.Width, minBottom - work.Bottom); - if (strip.Area() > 1.0) - results.Add(strip); - } - - // Left strip: from work area left to the leftmost part edge - var minLeft = double.MaxValue; - foreach (var box in obstacles) - { - if (box.Left < minLeft) - minLeft = box.Left; - } - - if (minLeft > work.Left) - { - var strip = new Box(work.Left, work.Bottom, minLeft - work.Left, work.Height); - if (strip.Area() > 1.0) - results.Add(strip); - } - - return results; -} -``` - -**Step 3: Build and verify** - -```bash -dotnet build OpenNest.sln -``` - -Expected: clean build. - -**Step 4: Commit** - -```bash -git add OpenNest.Core/Plate.cs -git commit -m "feat: add Plate.GetRemnants() for finding empty edge strips" -``` - ---- - -### Task 3: Create the OpenNest.Mcp project scaffold - -**Files:** -- Create: `OpenNest.Mcp/OpenNest.Mcp.csproj` -- Create: `OpenNest.Mcp/Program.cs` -- Create: `OpenNest.Mcp/NestSession.cs` -- Modify: `OpenNest.sln` - -**Step 1: Create the console project** - -```bash -cd C:/Users/AJ/Desktop/Projects/OpenNest -dotnet new console -n OpenNest.Mcp --framework net8.0-windows -``` - -**Step 2: Configure the csproj** - -`OpenNest.Mcp/OpenNest.Mcp.csproj`: -```xml - - - Exe - net8.0-windows - OpenNest.Mcp - OpenNest.Mcp - - - - - - - - - -``` - -**Step 3: Create NestSession.cs** - -This holds the in-memory state across tool calls — the current `Nest` object, a list of standalone plates and drawings for synthetic tests. - -```csharp -using System.Collections.Generic; - -namespace OpenNest.Mcp -{ - public class NestSession - { - public Nest Nest { get; set; } - public List Plates { get; } = new(); - public List Drawings { get; } = new(); - - public Plate GetPlate(int index) - { - if (Nest != null && index < Nest.Plates.Count) - return Nest.Plates[index]; - - var adjustedIndex = index - (Nest?.Plates.Count ?? 0); - if (adjustedIndex >= 0 && adjustedIndex < Plates.Count) - return Plates[adjustedIndex]; - - return null; - } - - public Drawing GetDrawing(string name) - { - if (Nest != null) - { - foreach (var d in Nest.Drawings) - { - if (d.Name == name) - return d; - } - } - - foreach (var d in Drawings) - { - if (d.Name == name) - return d; - } - - return null; - } - - public List AllPlates() - { - var all = new List(); - if (Nest != null) - all.AddRange(Nest.Plates); - all.AddRange(Plates); - return all; - } - - public List AllDrawings() - { - var all = new List(); - if (Nest != null) - all.AddRange(Nest.Drawings); - all.AddRange(Drawings); - return all; - } - } -} -``` - -**Step 4: Create Program.cs** - -```csharp -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ModelContextProtocol.Server; -using OpenNest.Mcp; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Services.AddSingleton(); -builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(typeof(Program).Assembly); - -var app = builder.Build(); -await app.RunAsync(); -``` - -**Step 5: Add to solution and build** - -```bash -dotnet sln OpenNest.sln add OpenNest.Mcp/OpenNest.Mcp.csproj -dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj -``` - -**Step 6: Commit** - -```bash -git add OpenNest.Mcp/ OpenNest.sln -git commit -m "feat: scaffold OpenNest.Mcp project with session state" -``` - ---- - -### Task 4: Implement input tools (load_nest, import_dxf, create_drawing) - -**Files:** -- Create: `OpenNest.Mcp/Tools/InputTools.cs` - -**Step 1: Create the tools file** - -`OpenNest.Mcp/Tools/InputTools.cs`: - -```csharp -using System.ComponentModel; -using System.Text; -using ModelContextProtocol.Server; -using OpenNest.CNC; -using OpenNest.Converters; -using OpenNest.Geometry; -using OpenNest.IO; - -namespace OpenNest.Mcp.Tools -{ - public class InputTools - { - private readonly NestSession _session; - - public InputTools(NestSession session) - { - _session = session; - } - - [McpServerTool(Name = "load_nest")] - [Description("Load a .nest zip file. Returns a summary of plates, drawings, and part counts.")] - public string LoadNest( - [Description("Full path to the .nest zip file")] string path) - { - var nest = NestReader.Read(path); - _session.Nest = nest; - - var sb = new StringBuilder(); - sb.AppendLine($"Loaded: {nest.Name}"); - sb.AppendLine($"Plates: {nest.Plates.Count}"); - sb.AppendLine($"Drawings: {nest.Drawings.Count}"); - - for (var i = 0; i < nest.Plates.Count; i++) - { - var plate = nest.Plates[i]; - sb.AppendLine($" Plate {i}: {plate.Size.Width}x{plate.Size.Height}, " + - $"{plate.Parts.Count} parts, " + - $"utilization: {plate.Utilization():P1}"); - } - - for (var i = 0; i < nest.Drawings.Count; i++) - { - var dwg = nest.Drawings[i]; - var bbox = dwg.Program.BoundingBox(); - sb.AppendLine($" Drawing: \"{dwg.Name}\" ({bbox.Width:F4}x{bbox.Height:F4})"); - } - - return sb.ToString(); - } - - [McpServerTool(Name = "import_dxf")] - [Description("Import a DXF file as a drawing.")] - public string ImportDxf( - [Description("Full path to the DXF file")] string path, - [Description("Name for the drawing (defaults to filename)")] string name = null) - { - var importer = new DxfImporter(); - var geometry = importer.Import(path); - - if (geometry == null || geometry.Count == 0) - return "Error: No geometry found in DXF file."; - - var pgm = ConvertGeometry.ToProgram(geometry); - if (pgm == null) - return "Error: Could not convert DXF geometry to program."; - - var drawingName = name ?? System.IO.Path.GetFileNameWithoutExtension(path); - var drawing = new Drawing(drawingName) { Program = pgm }; - drawing.UpdateArea(); - _session.Drawings.Add(drawing); - - var bbox = pgm.BoundingBox(); - return $"Imported \"{drawingName}\": {bbox.Width:F4}x{bbox.Height:F4}, area: {drawing.Area:F4}"; - } - - [McpServerTool(Name = "create_drawing")] - [Description("Create a drawing from a built-in shape (rectangle, circle, l_shape, t_shape) or raw G-code.")] - public string CreateDrawing( - [Description("Name for the drawing")] string name, - [Description("Shape type: rectangle, circle, l_shape, t_shape, gcode")] string shape, - [Description("Width (for rectangle, l_shape, t_shape)")] double width = 0, - [Description("Height (for rectangle, l_shape, t_shape)")] double height = 0, - [Description("Radius (for circle)")] double radius = 0, - [Description("Secondary width (for l_shape: notch width, t_shape: stem width)")] double width2 = 0, - [Description("Secondary height (for l_shape: notch height, t_shape: stem height)")] double height2 = 0, - [Description("Raw G-code string (for gcode shape)")] string gcode = null) - { - Program pgm; - - switch (shape.ToLowerInvariant()) - { - case "rectangle": - pgm = BuildRectangle(width, height); - break; - case "circle": - pgm = BuildCircle(radius); - break; - case "l_shape": - pgm = BuildLShape(width, height, width2, height2); - break; - case "t_shape": - pgm = BuildTShape(width, height, width2, height2); - break; - case "gcode": - if (string.IsNullOrEmpty(gcode)) - return "Error: gcode parameter required for gcode shape."; - pgm = ProgramReader.Parse(gcode); - break; - default: - return $"Error: Unknown shape '{shape}'. Use: rectangle, circle, l_shape, t_shape, gcode."; - } - - var drawing = new Drawing(name) { Program = pgm }; - drawing.UpdateArea(); - _session.Drawings.Add(drawing); - - var bbox = pgm.BoundingBox(); - return $"Created \"{name}\": {bbox.Width:F4}x{bbox.Height:F4}, area: {drawing.Area:F4}"; - } - - private static Program BuildRectangle(double w, double h) - { - var shape = new Shape(); - shape.Entities.Add(new Line(0, 0, w, 0)); - shape.Entities.Add(new Line(w, 0, w, h)); - shape.Entities.Add(new Line(w, h, 0, h)); - shape.Entities.Add(new Line(0, h, 0, 0)); - return ConvertGeometry.ToProgram(shape); - } - - private static Program BuildCircle(double r) - { - var shape = new Shape(); - shape.Entities.Add(new Circle(0, 0, r)); - return ConvertGeometry.ToProgram(shape); - } - - private static Program BuildLShape(double w, double h, double w2, double h2) - { - // L-shape: full rectangle minus top-right notch - var shape = new Shape(); - shape.Entities.Add(new Line(0, 0, w, 0)); - shape.Entities.Add(new Line(w, 0, w, h - h2)); - shape.Entities.Add(new Line(w, h - h2, w - w2, h - h2)); - shape.Entities.Add(new Line(w - w2, h - h2, w - w2, h)); - shape.Entities.Add(new Line(w - w2, h, 0, h)); - shape.Entities.Add(new Line(0, h, 0, 0)); - return ConvertGeometry.ToProgram(shape); - } - - private static Program BuildTShape(double w, double h, double stemW, double stemH) - { - // T-shape: wide top + centered stem - var stemLeft = (w - stemW) / 2.0; - var stemRight = stemLeft + stemW; - var shape = new Shape(); - shape.Entities.Add(new Line(stemLeft, 0, stemRight, 0)); - shape.Entities.Add(new Line(stemRight, 0, stemRight, stemH)); - shape.Entities.Add(new Line(stemRight, stemH, w, stemH)); - shape.Entities.Add(new Line(w, stemH, w, h)); - shape.Entities.Add(new Line(w, h, 0, h)); - shape.Entities.Add(new Line(0, h, 0, stemH)); - shape.Entities.Add(new Line(0, stemH, stemLeft, stemH)); - shape.Entities.Add(new Line(stemLeft, stemH, stemLeft, 0)); - return ConvertGeometry.ToProgram(shape); - } - } -} -``` - -Note: `DxfImporter.Import()` may have a different signature — check the actual method. It might return `List` or take different parameters. Also check if `ProgramReader.Parse(string)` exists or if it reads from files. Adapt as needed. - -**Step 2: Build and verify** - -```bash -dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj -``` - -Fix any compilation issues from API mismatches (DxfImporter signature, ProgramReader usage). - -**Step 3: Commit** - -```bash -git add OpenNest.Mcp/Tools/InputTools.cs -git commit -m "feat(mcp): add input tools — load_nest, import_dxf, create_drawing" -``` - ---- - -### Task 5: Implement setup tools (create_plate, clear_plate) - -**Files:** -- Create: `OpenNest.Mcp/Tools/SetupTools.cs` - -**Step 1: Create the tools file** - -```csharp -using System.ComponentModel; -using ModelContextProtocol.Server; -using OpenNest.Geometry; - -namespace OpenNest.Mcp.Tools -{ - public class SetupTools - { - private readonly NestSession _session; - - public SetupTools(NestSession session) - { - _session = session; - } - - [McpServerTool(Name = "create_plate")] - [Description("Create a new plate with specified dimensions and spacing.")] - public string CreatePlate( - [Description("Plate width")] double width, - [Description("Plate height")] double height, - [Description("Spacing between parts (default 0.125)")] double partSpacing = 0.125, - [Description("Edge spacing on all sides (default 0.25)")] double edgeSpacing = 0.25, - [Description("Quadrant 1-4 (default 3 = bottom-left origin)")] int quadrant = 3) - { - var plate = new Plate(width, height) - { - PartSpacing = partSpacing, - Quadrant = quadrant, - EdgeSpacing = new Spacing(edgeSpacing) - }; - - _session.Plates.Add(plate); - var index = _session.AllPlates().Count - 1; - var work = plate.WorkArea(); - - return $"Created plate {index}: {width}x{height}, " + - $"work area: {work.Width:F4}x{work.Height:F4}, " + - $"quadrant: {quadrant}, part spacing: {partSpacing}, edge spacing: {edgeSpacing}"; - } - - [McpServerTool(Name = "clear_plate")] - [Description("Remove all parts from a plate.")] - public string ClearPlate( - [Description("Plate index (0-based)")] int plate) - { - var p = _session.GetPlate(plate); - if (p == null) - return $"Error: Plate {plate} not found."; - - var count = p.Parts.Count; - p.Parts.Clear(); - - return $"Cleared plate {plate}: removed {count} parts."; - } - } -} -``` - -Note: Check that `Spacing` has a constructor that takes a single value for all sides. If not, set `Top`, `Bottom`, `Left`, `Right` individually. - -**Step 2: Build and verify** - -```bash -dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj -``` - -**Step 3: Commit** - -```bash -git add OpenNest.Mcp/Tools/SetupTools.cs -git commit -m "feat(mcp): add setup tools — create_plate, clear_plate" -``` - ---- - -### Task 6: Implement nesting tools (fill_plate, fill_area, fill_remnants, pack_plate) - -**Files:** -- Create: `OpenNest.Mcp/Tools/NestingTools.cs` - -**Step 1: Create the tools file** - -```csharp -using System.Collections.Generic; -using System.ComponentModel; -using System.Text; -using ModelContextProtocol.Server; -using OpenNest.Geometry; - -namespace OpenNest.Mcp.Tools -{ - public class NestingTools - { - private readonly NestSession _session; - - public NestingTools(NestSession session) - { - _session = session; - } - - [McpServerTool(Name = "fill_plate")] - [Description("Fill an entire plate with a single drawing using NestEngine.Fill.")] - public string FillPlate( - [Description("Plate index (0-based)")] int plate, - [Description("Drawing name")] string drawing, - [Description("Max quantity (0 = unlimited)")] int quantity = 0) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var d = _session.GetDrawing(drawing); - if (d == null) return $"Error: Drawing '{drawing}' not found."; - - var before = p.Parts.Count; - var engine = new NestEngine(p); - var item = new NestItem { Drawing = d, Quantity = quantity }; - var success = engine.Fill(item); - - var after = p.Parts.Count; - return $"Fill plate {plate}: added {after - before} parts " + - $"(total: {after}), utilization: {p.Utilization():P1}"; - } - - [McpServerTool(Name = "fill_area")] - [Description("Fill a specific rectangular area on a plate with a drawing.")] - public string FillArea( - [Description("Plate index (0-based)")] int plate, - [Description("Drawing name")] string drawing, - [Description("Area left X")] double x, - [Description("Area bottom Y")] double y, - [Description("Area width")] double width, - [Description("Area height")] double height, - [Description("Max quantity (0 = unlimited)")] int quantity = 0) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var d = _session.GetDrawing(drawing); - if (d == null) return $"Error: Drawing '{drawing}' not found."; - - var before = p.Parts.Count; - var engine = new NestEngine(p); - var area = new Box(x, y, width, height); - var item = new NestItem { Drawing = d, Quantity = quantity }; - var success = engine.Fill(item, area); - - var after = p.Parts.Count; - return $"Fill area: added {after - before} parts " + - $"(total: {after}), utilization: {p.Utilization():P1}"; - } - - [McpServerTool(Name = "fill_remnants")] - [Description("Auto-detect empty remnant strips on a plate and fill each with a drawing.")] - public string FillRemnants( - [Description("Plate index (0-based)")] int plate, - [Description("Drawing name")] string drawing, - [Description("Max quantity per remnant (0 = unlimited)")] int quantity = 0) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var d = _session.GetDrawing(drawing); - if (d == null) return $"Error: Drawing '{drawing}' not found."; - - var remnants = p.GetRemnants(); - if (remnants.Count == 0) - return "No remnants found on the plate."; - - var sb = new StringBuilder(); - sb.AppendLine($"Found {remnants.Count} remnant(s):"); - - var totalAdded = 0; - var before = p.Parts.Count; - - foreach (var remnant in remnants) - { - var partsBefore = p.Parts.Count; - var engine = new NestEngine(p); - var item = new NestItem { Drawing = d, Quantity = quantity }; - engine.Fill(item, remnant); - var added = p.Parts.Count - partsBefore; - totalAdded += added; - - sb.AppendLine($" Remnant ({remnant.X:F2},{remnant.Y:F2}) " + - $"{remnant.Width:F2}x{remnant.Height:F2}: +{added} parts"); - } - - sb.AppendLine($"Total: +{totalAdded} parts ({p.Parts.Count} total), " + - $"utilization: {p.Utilization():P1}"); - - return sb.ToString(); - } - - [McpServerTool(Name = "pack_plate")] - [Description("Pack multiple drawings onto a plate using bin-packing (PackBottomLeft).")] - public string PackPlate( - [Description("Plate index (0-based)")] int plate, - [Description("Comma-separated list of drawing names")] string drawings, - [Description("Comma-separated quantities for each drawing (default: 1 each)")] string quantities = null) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var names = drawings.Split(','); - var qtys = quantities?.Split(','); - var items = new List(); - - for (var i = 0; i < names.Length; i++) - { - var d = _session.GetDrawing(names[i].Trim()); - if (d == null) return $"Error: Drawing '{names[i].Trim()}' not found."; - - var qty = 1; - if (qtys != null && i < qtys.Length) - int.TryParse(qtys[i].Trim(), out qty); - - items.Add(new NestItem { Drawing = d, Quantity = qty }); - } - - var before = p.Parts.Count; - var engine = new NestEngine(p); - engine.Pack(items); - - var after = p.Parts.Count; - return $"Pack plate {plate}: added {after - before} parts " + - $"(total: {after}), utilization: {p.Utilization():P1}"; - } - } -} -``` - -**Step 2: Build and verify** - -```bash -dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj -``` - -**Step 3: Commit** - -```bash -git add OpenNest.Mcp/Tools/NestingTools.cs -git commit -m "feat(mcp): add nesting tools — fill_plate, fill_area, fill_remnants, pack_plate" -``` - ---- - -### Task 7: Implement inspection tools (get_plate_info, get_parts, check_overlaps) - -**Files:** -- Create: `OpenNest.Mcp/Tools/InspectionTools.cs` - -**Step 1: Create the tools file** - -```csharp -using System.ComponentModel; -using System.Linq; -using System.Text; -using ModelContextProtocol.Server; -using OpenNest.Geometry; - -namespace OpenNest.Mcp.Tools -{ - public class InspectionTools - { - private readonly NestSession _session; - - public InspectionTools(NestSession session) - { - _session = session; - } - - [McpServerTool(Name = "get_plate_info")] - [Description("Get plate dimensions, part count, utilization, and remnant areas.")] - public string GetPlateInfo( - [Description("Plate index (0-based)")] int plate) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var sb = new StringBuilder(); - sb.AppendLine($"Plate {plate}:"); - sb.AppendLine($" Size: {p.Size.Width}x{p.Size.Height}"); - sb.AppendLine($" Quadrant: {p.Quadrant}"); - sb.AppendLine($" Part spacing: {p.PartSpacing}"); - sb.AppendLine($" Edge spacing: T={p.EdgeSpacing.Top} B={p.EdgeSpacing.Bottom} " + - $"L={p.EdgeSpacing.Left} R={p.EdgeSpacing.Right}"); - - var work = p.WorkArea(); - sb.AppendLine($" Work area: ({work.X:F4},{work.Y:F4}) {work.Width:F4}x{work.Height:F4}"); - sb.AppendLine($" Parts: {p.Parts.Count}"); - sb.AppendLine($" Area: {p.Area():F4}"); - sb.AppendLine($" Utilization: {p.Utilization():P2}"); - - if (p.Material != null) - sb.AppendLine($" Material: {p.Material.Name}"); - - var remnants = p.GetRemnants(); - sb.AppendLine($" Remnants: {remnants.Count}"); - foreach (var r in remnants) - { - sb.AppendLine($" ({r.X:F2},{r.Y:F2}) {r.Width:F2}x{r.Height:F2} " + - $"(area: {r.Area():F2})"); - } - - // List unique drawings and their counts - var drawingCounts = p.Parts - .GroupBy(part => part.BaseDrawing.Name) - .Select(g => new { Name = g.Key, Count = g.Count() }); - - sb.AppendLine($" Drawing breakdown:"); - foreach (var dc in drawingCounts) - sb.AppendLine($" \"{dc.Name}\": {dc.Count}"); - - return sb.ToString(); - } - - [McpServerTool(Name = "get_parts")] - [Description("List all placed parts on a plate with location, rotation, and bounding box.")] - public string GetParts( - [Description("Plate index (0-based)")] int plate, - [Description("Max parts to return (default 50)")] int limit = 50) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - var sb = new StringBuilder(); - sb.AppendLine($"Plate {plate}: {p.Parts.Count} parts (showing up to {limit})"); - - var count = 0; - foreach (var part in p.Parts) - { - if (count >= limit) break; - - var bbox = part.BoundingBox; - sb.AppendLine($" [{count}] \"{part.BaseDrawing.Name}\" " + - $"loc:({part.Location.X:F4},{part.Location.Y:F4}) " + - $"rot:{OpenNest.Math.Angle.ToDegrees(part.Rotation):F1}° " + - $"bbox:({bbox.X:F4},{bbox.Y:F4} {bbox.Width:F4}x{bbox.Height:F4})"); - - count++; - } - - if (p.Parts.Count > limit) - sb.AppendLine($" ... and {p.Parts.Count - limit} more"); - - return sb.ToString(); - } - - [McpServerTool(Name = "check_overlaps")] - [Description("Run overlap detection on a plate and report any collisions.")] - public string CheckOverlaps( - [Description("Plate index (0-based)")] int plate) - { - var p = _session.GetPlate(plate); - if (p == null) return $"Error: Plate {plate} not found."; - - System.Collections.Generic.List pts; - var hasOverlaps = p.HasOverlappingParts(out pts); - - if (!hasOverlaps) - return $"Plate {plate}: No overlaps detected ({p.Parts.Count} parts)."; - - var sb = new StringBuilder(); - sb.AppendLine($"Plate {plate}: OVERLAPS DETECTED — {pts.Count} intersection point(s)"); - - var limit = System.Math.Min(pts.Count, 20); - for (var i = 0; i < limit; i++) - sb.AppendLine($" Intersection at ({pts[i].X:F4},{pts[i].Y:F4})"); - - if (pts.Count > limit) - sb.AppendLine($" ... and {pts.Count - limit} more"); - - return sb.ToString(); - } - } -} -``` - -**Step 2: Build and verify** - -```bash -dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj -``` - -**Step 3: Commit** - -```bash -git add OpenNest.Mcp/Tools/InspectionTools.cs -git commit -m "feat(mcp): add inspection tools — get_plate_info, get_parts, check_overlaps" -``` - ---- - -### Task 8: Publish and register the MCP server - -**Step 1: Build the full solution** - -```bash -dotnet build OpenNest.sln -``` - -Verify zero errors across all projects. - -**Step 2: Publish the MCP server** - -```bash -dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp" -``` - -**Step 3: Register with Claude Code** - -Create or update the project-level `.mcp.json` in the repo root: - -```json -{ - "mcpServers": { - "opennest": { - "command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe", - "args": [] - } - } -} -``` - -Alternatively, register at user level: - -```bash -claude mcp add --transport stdio --scope user opennest -- "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe" -``` - -**Step 4: Commit** - -```bash -git add .mcp.json OpenNest.Mcp/ -git commit -m "feat(mcp): publish OpenNest.Mcp and register in .mcp.json" -``` - ---- - -### Task 9: Smoke test with N0308-008.zip - -After restarting Claude Code, verify the MCP tools work end-to-end: - -1. `load_nest` with `C:/Users/AJ/Desktop/N0308-008.zip` -2. `get_plate_info` for plate 0 — verify 75 parts, 36x36 plate -3. `get_parts` — verify part locations look reasonable -4. `fill_remnants` — fill empty strips with the existing drawing -5. `check_overlaps` — verify no collisions -6. `get_plate_info` again — verify increased utilization - -This is a manual verification step. Fix any runtime issues discovered. - -**Commit any fixes:** - -```bash -git add -u -git commit -m "fix(mcp): address issues found during smoke testing" -``` - ---- - -### Task 10: Update CLAUDE.md and memory - -**Files:** -- Modify: `CLAUDE.md` — update architecture section to include OpenNest.IO and OpenNest.Mcp - -**Step 1: Update project description in CLAUDE.md** - -Add OpenNest.IO and OpenNest.Mcp to the architecture section. Update the dependency description. - -**Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: update CLAUDE.md with OpenNest.IO and OpenNest.Mcp projects" -``` diff --git a/docs/plans/2026-03-08-remnant-fill-investigation.md b/docs/plans/2026-03-08-remnant-fill-investigation.md deleted file mode 100644 index 728bc6e..0000000 --- a/docs/plans/2026-03-08-remnant-fill-investigation.md +++ /dev/null @@ -1,86 +0,0 @@ -# Remnant Fill Optimization — Investigation & Fix - -## Status: Both fixes done - -## Problem 1 (FIXED): N0308-008 hinge plate remnant - -`NestEngine.Fill(NestItem, Box)` got 7 parts in a 4.7x35.0 remnant strip where manual nesting gets 8 using a staggered brick pattern with alternating rotations. - -### Test Case -``` -load_nest("C:/Users/AJ/Desktop/N0308-008.zip") -fill_remnants(0, "Converto 3 YRD DUMPERSTER HINGE PLATE #2") → was 7, now 9 -``` -Reference: `C:/Users/AJ/Desktop/N0308-008 - Copy.zip` (83 parts total, 8 in remnant). - -### Root Cause Found -- FillLinear rotation sweep works correctly — tested at 1° resolution, max is always 7 -- The reference uses a **staggered pair pattern** (alternating 90°/270° rotations with horizontal offset) -- `FillWithPairs` generates ~2572 pair candidates but only tried top 50 sorted by minimum bounding area -- The winning pair ranked ~882nd — excluded by the `Take(50)` cutoff -- Top-50-by-area favors compact pairs for full-plate tiling, not narrow pairs suited for remnant strips - -### Fix Applied (in `OpenNest.Engine/NestEngine.cs`) -Added `SelectPairCandidates()` method: -1. Always includes standard top 50 pairs by area (no change for full-plate fills) -2. When work area is narrow (`shortSide < plateShortSide * 0.5`), includes **all** pairs whose shortest side fits the strip width -3. Updated `FillWithPairs()` to call `SelectPairCandidates()` instead of `Take(50)` - -### Results -- Remnant fill: 7 → **9 parts** (beats reference of 8, with partial pattern fill) -- Full-plate fill: 75 parts (unchanged, no regression) -- Remnant fill time: ~440ms -- Overlap check: PASS - ---- - -## Problem 2 (FIXED): N0308-017 PT02 remnant - -`N0308-017.zip` — 54 parts on a 144x72 plate. Two remnant areas: -- Remnant 0: `(119.57, 0.75) 24.13x70.95` — end-of-sheet strip -- Remnant 1: `(0.30, 66.15) 143.40x5.55` — bottom strip - -Drawing "4980 A24 PT02" has bbox 10.562x15.406. Engine filled 8 parts (2 cols × 4 rows) in remnant 0. Reference (`N0308-017 - Copy.zip`) has 10 parts using alternating 0°/180° rows. - -### Investigation -1. Tested PT02 in remnant isolation → still 8 parts (not a multi-drawing ordering issue) -2. Brute-forced all 7224 pair candidates → max was 8 (no pair yields >8 with full-pattern-only tiling) -3. Tried finer offset resolution (0.05" step) across 0°/90°/180°/270° → still max 8 -4. Analyzed reference nest (`N0308-017 - Copy.zip`): **64 PT02 parts on full plate, 10 in remnant area** - -### Root Cause Found -The reference uses a 0°/170° staggered pair pattern that tiles in 5 rows × 2 columns: -- Rows at y: 0.75, 14.88, 28.40, 42.53, 56.06 (alternating 0° and 170°) -- Pattern copy distance: ~27.65" (pair tiling distance) -- 2 full pairs = 8 parts, top at ~58.56" -- Remaining height: 71.70 - 58.56 = ~13.14" — enough for 1 more row of 0° parts (height 15.41) -- **But `FillLinear.TilePattern` only placed complete pattern copies**, so the partial 3rd pair (just the 0° row) was never attempted - -The pair candidate DID exist in the candidate set and was being tried. The issue was entirely in `FillLinear.TilePattern` — it tiled 2 complete pairs (8 parts) and stopped, even though 2 more individual parts from the next incomplete pair would still fit within the work area. - -### Fix Applied (in `OpenNest.Engine/FillLinear.cs`) -Added **partial pattern fill** to `TilePattern()`: -- After tiling complete pattern copies, if the pattern has multiple parts, clone the next would-be copy -- Check each individual part's bounding box against the work area -- Add any that fit — guaranteed no overlaps by the copy distance computation - -This is safe because: -- The copy distance ensures no overlaps between adjacent full copies → partial (subset) is also safe -- Parts within the same pattern copy don't overlap by construction -- Individual bounds checking catches parts that exceed the work area - -### Results -- PT02 remnant fill: 8 → **10 parts** (matches reference) -- Hinge remnant fill: 8 → **9 parts** (bonus improvement from same fix) -- Full-plate fill: 75 parts (unchanged, no regression) -- All overlap checks: PASS -- PT02 fill time: ~32s (unchanged, dominated by pair candidate evaluation) - ---- - -## Files Modified -- `OpenNest.Engine/NestEngine.cs` — Added `SelectPairCandidates()`, updated `FillWithPairs()`, rotation sweep (pre-existing change) -- `OpenNest.Engine/FillLinear.cs` — Added partial pattern fill to `TilePattern()` - -## Temp Files to Clean Up -- `OpenNest.Test/` — temporary test console project (can be deleted or kept for debugging) diff --git a/docs/plans/2026-03-09-remainder-strip-refill.md b/docs/plans/2026-03-09-remainder-strip-refill.md deleted file mode 100644 index 5fa1f5b..0000000 --- a/docs/plans/2026-03-09-remainder-strip-refill.md +++ /dev/null @@ -1,382 +0,0 @@ -# Remainder Strip Re-Fill Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** After the main fill, detect oddball last column/row, remove it, and re-fill the remainder strip independently to maximize part count (30 -> 32 for the test case). - -**Architecture:** Extract the strategy selection logic from `Fill(NestItem, Box)` into a reusable `FindBestFill` method. Add `TryRemainderImprovement` that clusters placed parts, detects oddball last cluster, computes the remainder strip box, and calls `FindBestFill` on it. Only used when it improves the count. - -**Tech Stack:** C# / .NET 8, OpenNest.Engine - ---- - -### Task 1: Extract FindBestFill from Fill(NestItem, Box) - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs:32-105` - -**Step 1: Create `FindBestFill` by extracting the strategy logic** - -Move lines 34-95 (everything except the quantity check and `Plate.Parts.AddRange`) into a new private method. `Fill` delegates to it. - -```csharp -private List FindBestFill(NestItem item, Box workArea) -{ - var bestRotation = RotationAnalysis.FindBestRotation(item); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - - // Build candidate rotation angles — always try the best rotation and +90°. - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - - // When the work area is narrow relative to the part, sweep rotation - // angles so we can find one that fits the part into the tight strip. - var testPart = new Part(item.Drawing); - - if (!bestRotation.IsEqualTo(0)) - testPart.Rotate(bestRotation); - - testPart.UpdateBounds(); - - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Height); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height); - - if (workAreaShortSide < partLongestSide) - { - // Try every 5° from 0 to 175° to find rotations that fit. - var step = Angle.ToRadians(5); - - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - List best = null; - - foreach (var angle in angles) - { - var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal); - var v = engine.Fill(item.Drawing, angle, NestDirection.Vertical); - - if (IsBetterFill(h, best)) - best = h; - - if (IsBetterFill(v, best)) - best = v; - } - - Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}"); - - // Try rectangle best-fit (mixes orientations to fill remnant strips). - var rectResult = FillRectangleBestFit(item, workArea); - - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); - - if (IsBetterFill(rectResult, best)) - best = rectResult; - - // Try pair-based approach. - var pairResult = FillWithPairs(item, workArea); - - Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts"); - - if (IsBetterFill(pairResult, best)) - best = pairResult; - - return best; -} -``` - -**Step 2: Simplify `Fill(NestItem, Box)` to delegate** - -```csharp -public bool Fill(NestItem item, Box workArea) -{ - var best = FindBestFill(item, workArea); - - if (best == null || best.Count == 0) - return false; - - if (item.Quantity > 0 && best.Count > item.Quantity) - best = best.Take(item.Quantity).ToList(); - - Plate.Parts.AddRange(best); - return true; -} -``` - -**Step 3: Build and verify no regressions** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds, no errors. - -**Step 4: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs -git commit -m "refactor: extract FindBestFill from Fill(NestItem, Box)" -``` - ---- - -### Task 2: Add ClusterParts helper - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs` - -**Step 1: Add the `ClusterParts` method** - -Place after `IsBetterValidFill` (around line 287). Groups parts into positional clusters (columns or rows) based on center position gaps. - -```csharp -/// -/// Groups parts into positional clusters along the given axis. -/// Parts whose center positions are separated by more than half -/// the part dimension start a new cluster. -/// -private static List> ClusterParts(List parts, bool horizontal) -{ - var sorted = horizontal - ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() - : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); - - var refDim = horizontal - ? sorted.Max(p => p.BoundingBox.Width) - : sorted.Max(p => p.BoundingBox.Height); - var gapThreshold = refDim * 0.5; - - var clusters = new List>(); - var current = new List { sorted[0] }; - - for (var i = 1; i < sorted.Count; i++) - { - var prevCenter = horizontal - ? sorted[i - 1].BoundingBox.Center.X - : sorted[i - 1].BoundingBox.Center.Y; - var currCenter = horizontal - ? sorted[i].BoundingBox.Center.X - : sorted[i].BoundingBox.Center.Y; - - if (currCenter - prevCenter > gapThreshold) - { - clusters.Add(current); - current = new List(); - } - - current.Add(sorted[i]); - } - - clusters.Add(current); - return clusters; -} -``` - -**Step 2: Build** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds. - -**Step 3: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs -git commit -m "feat: add ClusterParts helper for positional grouping" -``` - ---- - -### Task 3: Add TryStripRefill and TryRemainderImprovement - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs` - -**Step 1: Add `TryStripRefill`** - -This method analyzes one axis: clusters parts, checks if last cluster is an oddball, computes the strip, and fills it. - -```csharp -/// -/// Checks whether the last column (horizontal) or row (vertical) is an -/// oddball with fewer parts than the main grid. If so, removes those parts, -/// computes the remainder strip, and fills it independently. -/// Returns null if no improvement is possible. -/// -private List TryStripRefill(NestItem item, Box workArea, List parts, bool horizontal) -{ - var clusters = ClusterParts(parts, horizontal); - - if (clusters.Count < 2) - return null; - - var lastCluster = clusters[clusters.Count - 1]; - var otherClusters = clusters.Take(clusters.Count - 1).ToList(); - - // Find the most common cluster size (mode). - var modeCount = otherClusters - .Select(c => c.Count) - .GroupBy(x => x) - .OrderByDescending(g => g.Count()) - .First().Key; - - // Only proceed if last cluster is smaller (it's the oddball). - if (lastCluster.Count >= modeCount) - return null; - - var mainParts = otherClusters.SelectMany(c => c).ToList(); - var mainBbox = ((IEnumerable)mainParts).GetBoundingBox(); - - Box strip; - - if (horizontal) - { - var stripLeft = mainBbox.Right + Plate.PartSpacing; - var stripWidth = workArea.Right - stripLeft; - - if (stripWidth < 1) - return null; - - strip = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height); - } - else - { - var stripBottom = mainBbox.Top + Plate.PartSpacing; - var stripHeight = workArea.Top - stripBottom; - - if (stripHeight < 1) - return null; - - strip = new Box(workArea.X, stripBottom, workArea.Width, stripHeight); - } - - Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} strip: {strip.Width:F1}x{strip.Height:F1} | Main: {mainParts.Count} | Oddball: {lastCluster.Count}"); - - var stripParts = FindBestFill(item, strip); - - if (stripParts == null || stripParts.Count <= lastCluster.Count) - return null; - - Debug.WriteLine($"[TryStripRefill] Strip fill: {stripParts.Count} parts (was {lastCluster.Count} oddball)"); - - var combined = new List(mainParts); - combined.AddRange(stripParts); - return combined; -} -``` - -**Step 2: Add `TryRemainderImprovement`** - -Tries both horizontal and vertical strip analysis. - -```csharp -/// -/// Attempts to improve a fill result by detecting an oddball last -/// column or row and re-filling the remainder strip independently. -/// Returns null if no improvement is found. -/// -private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) -{ - if (currentBest == null || currentBest.Count < 3) - return null; - - List bestImproved = null; - - var hImproved = TryStripRefill(item, workArea, currentBest, horizontal: true); - - if (IsBetterFill(hImproved, bestImproved)) - bestImproved = hImproved; - - var vImproved = TryStripRefill(item, workArea, currentBest, horizontal: false); - - if (IsBetterFill(vImproved, bestImproved)) - bestImproved = vImproved; - - return bestImproved; -} -``` - -**Step 3: Build** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds. - -**Step 4: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs -git commit -m "feat: add TryStripRefill and TryRemainderImprovement" -``` - ---- - -### Task 4: Wire remainder improvement into Fill - -**Files:** -- Modify: `OpenNest.Engine/NestEngine.cs` — the `Fill(NestItem, Box)` method - -**Step 1: Add remainder improvement call** - -Update `Fill(NestItem, Box)` to try improving the result after the initial fill: - -```csharp -public bool Fill(NestItem item, Box workArea) -{ - var best = FindBestFill(item, workArea); - - // Try improving by filling the remainder strip separately. - var improved = TryRemainderImprovement(item, workArea, best); - - if (IsBetterFill(improved, best)) - { - Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); - best = improved; - } - - if (best == null || best.Count == 0) - return false; - - if (item.Quantity > 0 && best.Count > item.Quantity) - best = best.Take(item.Quantity).ToList(); - - Plate.Parts.AddRange(best); - return true; -} -``` - -**Step 2: Build** - -Run: `dotnet build OpenNest.sln` -Expected: Build succeeds. - -**Step 3: Commit** - -```bash -git add OpenNest.Engine/NestEngine.cs -git commit -m "feat: wire remainder strip re-fill into Fill(NestItem, Box)" -``` - ---- - -### Task 5: Verify with MCP tools - -**Step 1: Publish MCP server** - -```bash -dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp" -``` - -**Step 2: Test fill** - -Use MCP tools to: -1. Import the DXF drawing from `30pcs Fill.zip` (or create equivalent plate + drawing) -2. Create a 96x48 plate with the same spacing (part=0.25, edges L=0.25 B=0.75 R=0.25 T=0.25) -3. Fill the plate -4. Verify part count is 32 (up from 30) -5. Check for overlaps - -**Step 3: Compare against 32pcs reference** - -Verify the layout matches the 32pcs.zip reference — 24 parts in the main grid + 8 in the remainder strip. - -**Step 4: Final commit if any fixups needed** diff --git a/docs/plans/2026-03-09-xunit-test-suite.md b/docs/plans/2026-03-09-xunit-test-suite.md deleted file mode 100644 index a8a2a16..0000000 --- a/docs/plans/2026-03-09-xunit-test-suite.md +++ /dev/null @@ -1,417 +0,0 @@ -# OpenNest xUnit Test Suite Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Convert the ad-hoc OpenNest.Test console harness into a proper xUnit test suite with test data hosted in a separate git repo. - -**Architecture:** Replace the console app with an xUnit test project. A `TestData` helper resolves the test data path from env var `OPENNEST_TEST_DATA` or fallback `../OpenNest.Test.Data/`. Tests skip with a message if data is missing. Test classes: `FillTests` (full plate fills), `RemnantFillTests` (filling remnant areas). All tests assert part count >= target and zero overlaps. - -**Tech Stack:** C# / .NET 8, xUnit, OpenNest.Core + Engine + IO - ---- - -### Task 1: Set up test data repo and push fixture files - -**Step 1: Clone the empty repo next to OpenNest** - -```bash -cd C:/Users/AJ/Desktop/Projects -git clone https://git.thecozycat.net/aj/OpenNest.Test.git OpenNest.Test.Data -``` - -**Step 2: Copy fixture files into the repo** - -```bash -cp ~/Desktop/"N0308-017.zip" OpenNest.Test.Data/ -cp ~/Desktop/"N0308-008.zip" OpenNest.Test.Data/ -cp ~/Desktop/"30pcs Fill.zip" OpenNest.Test.Data/ -``` - -**Step 3: Commit and push** - -```bash -cd OpenNest.Test.Data -git add . -git commit -m "feat: add initial test fixture nest files" -git push -``` - ---- - -### Task 2: Convert OpenNest.Test from console app to xUnit project - -**Files:** -- Modify: `OpenNest.Test/OpenNest.Test.csproj` -- Delete: `OpenNest.Test/Program.cs` -- Create: `OpenNest.Test/TestData.cs` - -**Step 1: Replace the csproj with xUnit configuration** - -Overwrite `OpenNest.Test/OpenNest.Test.csproj`: - -```xml - - - - net8.0-windows - enable - enable - false - true - - - - - - - - - - - - - - - -``` - -**Step 2: Delete Program.cs** - -```bash -rm OpenNest.Test/Program.cs -``` - -**Step 3: Create TestData helper** - -Create `OpenNest.Test/TestData.cs`: - -```csharp -using OpenNest.IO; - -namespace OpenNest.Test; - -public static class TestData -{ - private static readonly string? BasePath = ResolveBasePath(); - - public static bool IsAvailable => BasePath != null; - - public static string SkipReason => - "Test data not found. Set OPENNEST_TEST_DATA env var or clone " + - "https://git.thecozycat.net/aj/OpenNest.Test.git to ../OpenNest.Test.Data/"; - - public static string GetPath(string filename) - { - if (BasePath == null) - throw new InvalidOperationException(SkipReason); - - var path = Path.Combine(BasePath, filename); - - if (!File.Exists(path)) - throw new FileNotFoundException($"Test fixture not found: {path}"); - - return path; - } - - public static Nest LoadNest(string filename) - { - var reader = new NestReader(GetPath(filename)); - return reader.Read(); - } - - public static Plate CleanPlateFrom(Plate reference) - { - var plate = new Plate(); - plate.Size = reference.Size; - plate.PartSpacing = reference.PartSpacing; - plate.EdgeSpacing = reference.EdgeSpacing; - plate.Quadrant = reference.Quadrant; - return plate; - } - - private static string? ResolveBasePath() - { - // 1. Environment variable - var envPath = Environment.GetEnvironmentVariable("OPENNEST_TEST_DATA"); - - if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) - return envPath; - - // 2. Sibling directory (../OpenNest.Test.Data/ relative to solution root) - var dir = AppContext.BaseDirectory; - - // Walk up from bin/Debug/net8.0-windows to find the solution root. - for (var i = 0; i < 6; i++) - { - var parent = Directory.GetParent(dir); - - if (parent == null) - break; - - dir = parent.FullName; - var candidate = Path.Combine(dir, "OpenNest.Test.Data"); - - if (Directory.Exists(candidate)) - return candidate; - } - - return null; - } -} -``` - -**Step 4: Build** - -Run: `dotnet build OpenNest.Test/OpenNest.Test.csproj` -Expected: Build succeeds. - -**Step 5: Commit** - -```bash -git add OpenNest.Test/ -git commit -m "refactor: convert OpenNest.Test to xUnit project with TestData helper" -``` - ---- - -### Task 3: Add FillTests - -**Files:** -- Create: `OpenNest.Test/FillTests.cs` - -**Step 1: Create FillTests.cs** - -```csharp -using OpenNest.Geometry; -using Xunit; -using Xunit.Abstractions; - -namespace OpenNest.Test; - -public class FillTests -{ - private readonly ITestOutputHelper _output; - - public FillTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Category", "Fill")] - public void N0308_008_HingePlate_FillsAtLeast75() - { - Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); - - var nest = TestData.LoadNest("N0308-008.zip"); - var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2")); - var plate = TestData.CleanPlateFrom(nest.Plates[0]); - - var engine = new NestEngine(plate); - var sw = System.Diagnostics.Stopwatch.StartNew(); - engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }); - sw.Stop(); - - _output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms"); - - Assert.True(plate.Parts.Count >= 75, - $"Expected >= 75 parts, got {plate.Parts.Count}"); - AssertNoOverlaps(plate.Parts.ToList()); - } - - [Fact] - [Trait("Category", "Fill")] - public void RemainderStripRefill_30pcs_FillsAtLeast32() - { - Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); - - var nest = TestData.LoadNest("30pcs Fill.zip"); - var drawing = nest.Drawings.First(); - var plate = TestData.CleanPlateFrom(nest.Plates[0]); - - var engine = new NestEngine(plate); - var sw = System.Diagnostics.Stopwatch.StartNew(); - engine.Fill(new NestItem { Drawing = drawing, Quantity = 0 }); - sw.Stop(); - - _output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms"); - - Assert.True(plate.Parts.Count >= 32, - $"Expected >= 32 parts, got {plate.Parts.Count}"); - AssertNoOverlaps(plate.Parts.ToList()); - } - - private void AssertNoOverlaps(List parts) - { - for (var i = 0; i < parts.Count; i++) - { - for (var j = i + 1; j < parts.Count; j++) - { - if (parts[i].Intersects(parts[j], out _)) - Assert.Fail($"Overlap detected: part [{i}] vs [{j}]"); - } - } - } -} -``` - -**Step 2: Run tests** - -Run: `dotnet test OpenNest.Test/ -v normal` -Expected: 2 tests pass (or skip if data missing). - -**Step 3: Commit** - -```bash -git add OpenNest.Test/FillTests.cs -git commit -m "test: add FillTests for full plate fill and remainder strip refill" -``` - ---- - -### Task 4: Add RemnantFillTests - -**Files:** -- Create: `OpenNest.Test/RemnantFillTests.cs` - -**Step 1: Create RemnantFillTests.cs** - -```csharp -using OpenNest.Geometry; -using Xunit; -using Xunit.Abstractions; - -namespace OpenNest.Test; - -public class RemnantFillTests -{ - private readonly ITestOutputHelper _output; - - public RemnantFillTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - [Trait("Category", "Remnant")] - public void N0308_017_PT02_RemnantFillsAtLeast10() - { - Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); - - var nest = TestData.LoadNest("N0308-017.zip"); - var plate = nest.Plates[0]; - var pt02 = nest.Drawings.First(d => d.Name.Contains("PT02")); - var remnant = plate.GetRemnants()[0]; - - _output.WriteLine($"Remnant: ({remnant.X:F2},{remnant.Y:F2}) {remnant.Width:F2}x{remnant.Height:F2}"); - - var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); - var sw = System.Diagnostics.Stopwatch.StartNew(); - engine.Fill(new NestItem { Drawing = pt02, Quantity = 0 }, remnant); - sw.Stop(); - - var added = plate.Parts.Count - countBefore; - _output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms"); - - Assert.True(added >= 10, $"Expected >= 10 parts in remnant, got {added}"); - - var newParts = plate.Parts.Skip(countBefore).ToList(); - AssertNoOverlaps(newParts); - AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts); - } - - [Fact] - [Trait("Category", "Remnant")] - public void N0308_008_HingePlate_RemnantFillsAtLeast8() - { - Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); - - var nest = TestData.LoadNest("N0308-008.zip"); - var plate = nest.Plates[0]; - var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2")); - var remnants = plate.GetRemnants(); - - _output.WriteLine($"Remnant 0: ({remnants[0].X:F2},{remnants[0].Y:F2}) {remnants[0].Width:F2}x{remnants[0].Height:F2}"); - - var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); - var sw = System.Diagnostics.Stopwatch.StartNew(); - engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }, remnants[0]); - sw.Stop(); - - var added = plate.Parts.Count - countBefore; - _output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms"); - - Assert.True(added >= 8, $"Expected >= 8 parts in remnant, got {added}"); - - var newParts = plate.Parts.Skip(countBefore).ToList(); - AssertNoOverlaps(newParts); - AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts); - } - - private void AssertNoOverlaps(List parts) - { - for (var i = 0; i < parts.Count; i++) - { - for (var j = i + 1; j < parts.Count; j++) - { - if (parts[i].Intersects(parts[j], out _)) - Assert.Fail($"Overlap detected: part [{i}] vs [{j}]"); - } - } - } - - private void AssertNoCrossOverlaps(List existing, List added) - { - for (var i = 0; i < existing.Count; i++) - { - for (var j = 0; j < added.Count; j++) - { - if (existing[i].Intersects(added[j], out _)) - Assert.Fail($"Cross-overlap: existing [{i}] vs added [{j}]"); - } - } - } -} -``` - -**Step 2: Run all tests** - -Run: `dotnet test OpenNest.Test/ -v normal` -Expected: 4 tests pass. - -**Step 3: Commit** - -```bash -git add OpenNest.Test/RemnantFillTests.cs -git commit -m "test: add RemnantFillTests for remnant area filling" -``` - ---- - -### Task 5: Add test project to solution and final verification - -**Step 1: Add to solution** - -```bash -cd C:/Users/AJ/Desktop/Projects/OpenNest -dotnet sln OpenNest.sln add OpenNest.Test/OpenNest.Test.csproj -``` - -**Step 2: Build entire solution** - -Run: `dotnet build OpenNest.sln` -Expected: All projects build, 0 errors. - -**Step 3: Run all tests** - -Run: `dotnet test OpenNest.sln -v normal` -Expected: 4 tests pass, 0 failures. - -**Step 4: Commit** - -```bash -git add OpenNest.sln -git commit -m "chore: add OpenNest.Test to solution" -``` diff --git a/docs/plans/2026-03-10-gpu-overlap-debug.md b/docs/plans/2026-03-10-gpu-overlap-debug.md deleted file mode 100644 index 268bffd..0000000 --- a/docs/plans/2026-03-10-gpu-overlap-debug.md +++ /dev/null @@ -1,110 +0,0 @@ -# GPU Pair Evaluator — Overlap Detection Bug - -**Date**: 2026-03-10 -**Status**: RESOLVED — commit b55aa7a - -## Problem - -The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization). - -## Root Cause (identified but not yet fully fixed) - -The bitmap coordinate system doesn't match the `Part2Offset` coordinate system. - -### How Part2Offset is computed -`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which: -1. Clones the drawing's program -2. Rotates it -3. Calls `Program.BoundingBox()` to get the bbox -4. Offsets by `-bbox.Location` to normalize to origin - -`Part2Offset` is the final position of Part2 in this **normalized** coordinate space. - -### How bitmaps are rasterized -`PartBitmap.FromDrawing` / `FromDrawingRotated`: -1. Extracts closed polygons from the drawing (filters out rapids, open shapes) -2. Rotates them (for B) -3. Rasterizes with `OriginX/Y = polygon min` - -### The mismatch -`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means: -- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change -- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3 -- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space - -For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0). - -## What we tried - -### Attempt 1: BlitPair approach (correct but too slow) -- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid -- Eliminated all offset math from the kernel (trivial element-wise AND) -- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds. - -### Attempt 2: Integer offsets with gap correction -- Kept shared-bitmap approach (one A + one B per rotation group) -- Changed offsets from `float` to `int` with `Math.Round()` on CPU -- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location` -- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice. - -### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state) -- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()` -- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin` -- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize` -- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap. - -## Current state of code - -### Files modified - -**`OpenNest.Gpu/PartBitmap.cs`**: -- Added `BlitPair()` static method (from attempt 1, still present but unused) -- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize -- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes - -**`OpenNest.Gpu/GpuPairEvaluator.cs`**: -- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated` -- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU -- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1` -- `PadBitmap` helper restored -- Removed the old `NestingKernel` with float offsets - -**`OpenNest/Forms/MainForm.cs`**: -- Added `using OpenNest.Engine.BestFit;` -- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);` - -## Next steps to debug - -1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate: - - Print bitmapA: OriginX, OriginY, Width, Height - - Print bitmapB: OriginX, OriginY, Width, Height - - Print the computed integer offset - - Print the overlap count from the kernel - - Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate - -2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**: - - `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()` - - If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong - - Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)? - -3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match. - -4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong. - -5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A. - -## Performance notes -- CPU evaluator: 25.0s compute, 5954 kept, correct results -- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps -- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group -- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group - -## Key files to reference -- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator -- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization -- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference) -- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values -- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()` -- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0) -- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in -- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up