chore: remove remaining stale plan docs

All features have been implemented; docs recoverable from git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 00:14:18 -04:00
parent 4060430757
commit e9678c73b2
11 changed files with 0 additions and 5343 deletions

View File

@@ -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<Part> BuildPairParts(BestFitResult bestFit, Drawing drawing)
```
To:
```csharp
internal static List<Part> 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"
```

View File

@@ -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` `<Compile>` 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<Vector>)` — returns closed `Polygon`
- `BoundingRectangleResult``Angle`, `Width`, `Height`, `Area` from rotating calipers
### Key Existing Methods (in `Helper`)
- `Helper.GetShapes(IEnumerable<Entity>)` — 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 `<ItemGroup>` that contains `<Compile>` entries, before `</ItemGroup>`:
```xml
<Compile Include="BestFit\PairCandidate.cs" />
```
**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
<Compile Include="BestFit\BestFitResult.cs" />
```
**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<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
}
}
```
**Step 2: Add to .csproj**
```xml
<Compile Include="BestFit\IBestFitStrategy.cs" />
```
**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<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List<PairCandidate>();
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<PairCandidate> 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
<Compile Include="BestFit\RotationSlideStrategy.cs" />
```
**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<IBoundable>)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<Vector> pts;
if (shapes1[i].Intersects(shapes2[j], out pts))
return true;
}
}
return false;
}
private List<Shape> 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<Vector> GetPartVertices(Part part)
{
var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var points = new List<Vector>();
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
<Compile Include="BestFit\PairEvaluator.cs" />
```
**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<BestFitResult> 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
<Compile Include="BestFit\BestFitFilter.cs" />
```
**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<PairPlacement> 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<PairPlacement>();
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
<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
```
**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<BestFitResult> FindBestFits(
Drawing drawing,
double spacing = 0.25,
double stepSize = 0.25,
BestFitSortField sortBy = BestFitSortField.Area)
{
var strategies = BuildStrategies(drawing);
var allCandidates = new List<PairCandidate>();
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<TileResult> 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<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
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<double> GetRotationAngles(Drawing drawing)
{
var angles = new List<double>
{
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<double> GetHullEdgeAngles(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(0.1);
points.AddRange(polygon.Vertices);
}
if (points.Count < 3)
return new List<double>();
var hull = ConvexHull.Compute(points);
var vertices = hull.Vertices;
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
var hullAngles = new List<double>();
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<double> angles, double angle)
{
angle = Angle.NormalizeRad(angle);
foreach (var existing in angles)
{
if (existing.IsEqualTo(angle))
return;
}
angles.Add(angle);
}
private List<BestFitResult> SortResults(List<BestFitResult> 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
<Compile Include="BestFit\BestFitFinder.cs" />
```
**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
<Compile Include="BestFit\PairCandidate.cs" />
<Compile Include="BestFit\BestFitResult.cs" />
<Compile Include="BestFit\IBestFitStrategy.cs" />
<Compile Include="BestFit\RotationSlideStrategy.cs" />
<Compile Include="BestFit\PairEvaluator.cs" />
<Compile Include="BestFit\BestFitFilter.cs" />
<Compile Include="BestFit\Tiling\TileResult.cs" />
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
<Compile Include="BestFit\BestFitFinder.cs" />
```
**Step 3: Final commit**
If any build fixes were needed, commit them:
```
fix: resolve build issues in best-fit pair finding engine
```

View File

@@ -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<BestFitResult> EvaluateAll(List<PairCandidate> 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)

View File

@@ -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<BestFitResult> EvaluateAll(List<PairCandidate> 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<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
var resultBag = new System.Collections.Concurrent.ConcurrentBag<BestFitResult>();
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<BestFitResult>` 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ILGPU" Version="1.5.1" />
<PackageReference Include="ILGPU.Algorithms" Version="1.5.1" />
</ItemGroup>
</Project>
```
**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<int>(), 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<int>(), 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<Polygon> GetClosedPolygons(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var polygons = new List<Polygon>();
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<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
if (_bitmap.Width == 0 || _bitmap.Height == 0 || candidates.Count == 0)
return new List<BestFitResult>();
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<int>,
ArrayView<float>,
ArrayView<int>,
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<BestFitResult>(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<int> bitmap,
ArrayView<float> candidateParams,
ArrayView<int> 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<Part> 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<Part> 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<Part> 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<Part>();
}
```
**Step 3: Add OpenNest.Gpu reference to UI project**
In `OpenNest/OpenNest.csproj`, add to the `<ItemGroup>` with other project references:
```xml
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
```
**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.

File diff suppressed because it is too large Load Diff

View File

@@ -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<Box>` 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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<Part> 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<double> { 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<Part> 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
/// <summary>
/// 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.
/// </summary>
private static List<List<Part>> ClusterParts(List<Part> 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<List<Part>>();
var current = new List<Part> { 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<Part>();
}
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
/// <summary>
/// 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.
/// </summary>
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> 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<IBoundable>)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<Part>(mainParts);
combined.AddRange(stripParts);
return combined;
}
```
**Step 2: Add `TryRemainderImprovement`**
Tries both horizontal and vertical strip analysis.
```csharp
/// <summary>
/// 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.
/// </summary>
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
{
if (currentBest == null || currentBest.Count < 3)
return null;
List<Part> 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**

View File

@@ -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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
```
**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<Part> 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<Part> 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<Part> existing, List<Part> 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"
```

View File

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