Compare commits

...

9 Commits

Author SHA1 Message Date
aj 09fc8b889e fix: close polygon offset shape and handle nest template load failure
Shape.OffsetEntity computed joins between consecutive offset segments
but never joined the last segment back to the first, leaving the
closing corner with a straight line instead of a proper miter/arc.
Track the first entity and apply the same join logic after the loop.

Also wrap nest template loading in try-catch so a corrupt template
file doesn't crash the app on startup — falls back to default nest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:37:39 -04:00
aj 2216b8553f docs: add implementation plans for best-fit viewer, pair finding, and .NET 8 migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:24:45 -04:00
aj 6ed20a6a48 refactor: switch auto-nest from Pack to per-item Fill with explicit quantity tracking
Replace engine.Pack(items) with individual engine.Fill(item) calls so each
drawing is filled independently. Quantity decrements now count parts actually
placed per plate (grouped by drawing) instead of relying on the drawing's
internal remaining counter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:24:42 -04:00
aj c68139e15e merge: resolve .gitignore conflict, keep both entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:22:45 -04:00
aj a084819889 perf: comment out Debug.WriteLine calls in FillLinear
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:44:14 -04:00
aj d0d334e734 fix: use chain tolerance for shape building to handle DXF endpoint gaps
DXF files can have endpoint gaps at entity junctions that fall right at
the floating-point boundary of Tolerance.Epsilon (0.00001). This caused
shapes to not close, resulting in 0 area and 0% utilization in Best-Fit.

Added ChainTolerance (0.0001) for endpoint chaining in GetConnected and
Shape.IsClosed, keeping the tighter Epsilon for geometric precision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:37:39 -04:00
aj 9f84357c34 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:48:08 -04:00
aj 164bab4a83 docs: update NFP autonest spec with review fixes
Address spec review findings: add Clipper2 dependency, fix BLF
point selection priority (Y-first), add Drawing.Id, clarify spacing
semantics, add CancellationToken support, specify convex decomposition
approach, add error handling and polygon vertex budget sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:42:42 -04:00
aj f126bfe2ba docs: add NFP-based autonesting design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:28:22 -04:00
10 changed files with 2757 additions and 58 deletions
+3
View File
@@ -199,5 +199,8 @@ FakesAssemblies/
# Visual Studio 6 workspace options file
*.opt
# Git worktrees
.worktrees/
# Claude Code
.claude/
+54 -28
View File
@@ -26,6 +26,7 @@ namespace OpenNest.Geometry
if (Entities.Count == 0)
return false;
var tol = Math.Tolerance.ChainTolerance;
var first = Entities[0];
Vector firstStartPoint;
Vector firstEndPoint;
@@ -65,7 +66,7 @@ namespace OpenNest.Geometry
case EntityType.Arc:
var arc = (Arc)geo;
if (arc.StartPoint() != endpt)
if (arc.StartPoint().DistanceTo(endpt) > tol)
return false;
endpt = arc.EndPoint();
@@ -77,7 +78,7 @@ namespace OpenNest.Geometry
case EntityType.Line:
var line = (Line)geo;
if (line.StartPoint != endpt)
if (line.StartPoint.DistanceTo(endpt) > tol)
return false;
endpt = line.EndPoint;
@@ -112,7 +113,7 @@ namespace OpenNest.Geometry
return false;
}
return lastEndPoint == firstStartPoint;
return lastEndPoint.DistanceTo(firstStartPoint) <= tol;
}
/// <summary>
@@ -462,6 +463,8 @@ namespace OpenNest.Geometry
var offsetShape = new Shape();
var definedShape = new ShapeProfile(this);
Entity firstEntity = null;
Entity firstOffsetEntity = null;
Entity lastEntity = null;
Entity lastOffsetEntity = null;
@@ -472,6 +475,12 @@ namespace OpenNest.Geometry
if (offsetEntity == null)
continue;
if (firstEntity == null)
{
firstEntity = entity;
firstOffsetEntity = offsetEntity;
}
switch (entity.Type)
{
case EntityType.Line:
@@ -481,31 +490,10 @@ namespace OpenNest.Geometry
if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line)
{
var lastLine = lastEntity as Line;
var lastOffsetLine = lastOffsetEntity as Line;
if (lastLine == null || lastOffsetLine == null)
continue;
Vector intersection;
if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection))
{
offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection;
}
else
{
var arc = new Arc(
line.StartPoint,
distance,
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
line.StartPoint.AngleTo(offsetLine.StartPoint),
side == OffsetSide.Left
);
offsetShape.Entities.Add(arc);
}
JoinOffsetLines(
(Line)lastEntity, (Line)lastOffsetEntity,
line, offsetLine,
distance, side, offsetShape);
}
offsetShape.Entities.Add(offsetLine);
@@ -521,12 +509,50 @@ namespace OpenNest.Geometry
lastEntity = entity;
}
// Close the shape: join last offset entity back to first
if (lastOffsetEntity != null && firstOffsetEntity != null
&& lastOffsetEntity != firstOffsetEntity
&& lastOffsetEntity.Type == EntityType.Line
&& firstOffsetEntity.Type == EntityType.Line)
{
JoinOffsetLines(
(Line)lastEntity, (Line)lastOffsetEntity,
(Line)firstEntity, (Line)firstOffsetEntity,
distance, side, offsetShape);
}
foreach (var cutout in definedShape.Cutouts)
offsetShape.Entities.AddRange(((Shape)cutout.OffsetEntity(distance, side)).Entities);
return offsetShape;
}
private static void JoinOffsetLines(
Line lastLine, Line lastOffsetLine,
Line line, Line offsetLine,
double distance, OffsetSide side, Shape offsetShape)
{
Vector intersection;
if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection))
{
offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection;
}
else
{
var arc = new Arc(
line.StartPoint,
distance,
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
line.StartPoint.AngleTo(offsetLine.StartPoint),
side == OffsetSide.Left
);
offsetShape.Entities.Add(arc);
}
}
public override Entity OffsetEntity(double distance, Vector pt)
{
throw new NotImplementedException();
+6 -4
View File
@@ -339,6 +339,8 @@ namespace OpenNest
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{
var tol = Math.Tolerance.ChainTolerance;
foreach (var geo in geometry)
{
switch (geo.Type)
@@ -346,10 +348,10 @@ namespace OpenNest
case EntityType.Arc:
var arc = (Arc)geo;
if (arc.StartPoint() == pt)
if (arc.StartPoint().DistanceTo(pt) <= tol)
return arc;
if (arc.EndPoint() == pt)
if (arc.EndPoint().DistanceTo(pt) <= tol)
{
arc.Reverse();
return arc;
@@ -360,10 +362,10 @@ namespace OpenNest
case EntityType.Line:
var line = (Line)geo;
if (line.StartPoint == pt)
if (line.StartPoint.DistanceTo(pt) <= tol)
return line;
if (line.EndPoint == pt)
if (line.EndPoint.DistanceTo(pt) <= tol)
{
line.Reverse();
return line;
+6
View File
@@ -6,6 +6,12 @@ namespace OpenNest.Math
{
public const double Epsilon = 0.00001;
/// <summary>
/// Tolerance for chaining entity endpoints when building shapes from DXF imports.
/// Larger than Epsilon to handle floating-point gaps at entity junctions.
/// </summary>
public const double ChainTolerance = 0.0001;
public static bool IsEqualTo(this double a, double b, double tolerance = Epsilon)
{
return System.Math.Abs(b - a) <= tolerance;
+9 -9
View File
@@ -86,7 +86,7 @@ namespace OpenNest
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir);
var copyDist = ComputeCopyDistance(bboxDim, slideDistance);
System.Diagnostics.Debug.WriteLine($"[FindCopyDistance] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} locA={partA.Location} locB={locationB} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
//System.Diagnostics.Debug.WriteLine($"[FindCopyDistance] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} locA={partA.Location} locB={locationB} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
return copyDist;
}
@@ -177,7 +177,7 @@ namespace OpenNest
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir);
var copyDist = ComputeCopyDistance(bboxDim, slideDistance);
System.Diagnostics.Debug.WriteLine($"[FindSinglePartPatternCopyDist] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} patternParts={patternA.Parts.Count} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
//System.Diagnostics.Debug.WriteLine($"[FindSinglePartPatternCopyDist] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} patternParts={patternA.Parts.Count} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
return copyDist;
}
@@ -348,13 +348,13 @@ namespace OpenNest
var perpAxis = PerpendicularAxis(direction);
var gridResult = FillRecursive(rowPattern, perpAxis, depth + 1);
System.Diagnostics.Debug.WriteLine($"[FillRecursive] Grid: {gridResult.Count} parts, rowSize={rowPattern.Parts.Count}, dir={direction}");
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Grid: {gridResult.Count} parts, rowSize={rowPattern.Parts.Count}, dir={direction}");
// Fill the remaining strip (after the last full row/column)
// with individual parts from the seed pattern.
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
System.Diagnostics.Debug.WriteLine($"[FillRecursive] Remainder: {remaining.Count} parts");
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Remainder: {remaining.Count} parts");
if (remaining.Count > 0)
gridResult.AddRange(remaining);
@@ -363,7 +363,7 @@ namespace OpenNest
// fit more parts than the extra row contained.
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
System.Diagnostics.Debug.WriteLine($"[FillRecursive] TryFewerRows: {fewerResult?.Count ?? -1} vs grid+remainder={gridResult.Count}");
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] TryFewerRows: {fewerResult?.Count ?? -1} vs grid+remainder={gridResult.Count}");
if (fewerResult != null && fewerResult.Count > gridResult.Count)
return fewerResult;
@@ -390,12 +390,12 @@ namespace OpenNest
{
var rowPartCount = rowPattern.Parts.Count;
System.Diagnostics.Debug.WriteLine($"[TryFewerRows] fullResult={fullResult.Count}, rowPartCount={rowPartCount}, tiledAxis={tiledAxis}");
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] fullResult={fullResult.Count}, rowPartCount={rowPartCount}, tiledAxis={tiledAxis}");
// Need at least 2 rows for this to make sense (remove 1, keep 1+).
if (fullResult.Count < rowPartCount * 2)
{
System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows");
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows");
return null;
}
@@ -415,11 +415,11 @@ namespace OpenNest
if (e > edge) edge = e;
}
System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Kept {fewerParts.Count} parts, edge={edge:F2}, workArea={WorkArea}");
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Kept {fewerParts.Count} parts, edge={edge:F2}, workArea={WorkArea}");
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)");
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)");
if (remaining.Count <= rowPartCount)
return null;
+51 -17
View File
@@ -47,6 +47,17 @@ namespace OpenNest.Forms
// BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);
}
private Nest CreateDefaultNest()
{
var nest = new Nest();
nest.Units = Properties.Settings.Default.DefaultUnit;
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 1, 1, 1);
nest.PlateDefaults.PartSpacing = 1;
nest.PlateDefaults.Size = new OpenNest.Geometry.Size(100, 100);
nest.PlateDefaults.Quadrant = 1;
return nest;
}
private string GetNestName(DateTime date, int id)
{
var month = date.Month.ToString().PadLeft(2, '0');
@@ -326,17 +337,24 @@ namespace OpenNest.Forms
if (File.Exists(Properties.Settings.Default.NestTemplatePath))
{
var reader = new NestReader(Properties.Settings.Default.NestTemplatePath);
nest = reader.Read();
try
{
var reader = new NestReader(Properties.Settings.Default.NestTemplatePath);
nest = reader.Read();
}
catch (Exception ex)
{
MessageBox.Show(
$"Failed to load nest template:\n{ex.Message}\n\nA default nest will be created instead.",
"Template Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
nest = CreateDefaultNest();
}
}
else
{
nest = new Nest();
nest.Units = Properties.Settings.Default.DefaultUnit;
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
nest.PlateDefaults.PartSpacing = 0;
nest.PlateDefaults.Size = new OpenNest.Geometry.Size(100, 100);
nest.PlateDefaults.Quadrant = 1;
nest = CreateDefaultNest();
}
nest.DateCreated = DateTime.Now;
@@ -680,26 +698,42 @@ namespace OpenNest.Forms
return;
var items = form.GetNestItems();
var qty = new int[items.Count];
while (true)
while (items.Any(it => it.Quantity > 0))
{
for (int i = 0; i < items.Count; i++)
qty[i] = items[i].Drawing.Quantity.Remaining;
var plate = activeForm.PlateView.Plate.Parts.Count > 0
? activeForm.Nest.CreatePlate()
: activeForm.PlateView.Plate;
var engine = new NestEngine(plate);
var filled = false;
if (!engine.Pack(items))
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
if (engine.Fill(item))
filled = true;
}
if (!filled)
break;
activeForm.Nest.UpdateDrawingQuantities();
// Decrement requested quantities by counting parts actually
// placed on this plate, grouped by drawing.
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing))
{
var placed = group.Count();
for (int i = 0; i < items.Count; i++)
items[i].Quantity -= qty[i] - items[i].Drawing.Quantity.Remaining;
foreach (var item in items)
{
if (item.Drawing == group.Key)
item.Quantity -= placed;
}
}
activeForm.Nest.UpdateDrawingQuantities();
}
}
@@ -0,0 +1,378 @@
# 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"
```
@@ -0,0 +1,963 @@
# 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
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
# NFP-Based Autonesting Design
## Problem
OpenNest's current nesting engine handles single-drawing plate fills well (FillLinear, FillBestFit, FillWithPairs), but cannot produce mixed-part layouts — placing multiple different drawings together on a plate with true geometry-aware interlocking. The existing `PackBottomLeft` supports mixed parts but operates on bounding-box rectangles only, wasting the concave space around irregular shapes.
Commercial nesting software (e.g., PEP at $30k) produces mixed-part layouts but with mediocre results — significant gaps and poor material utilization.
## Goal
Build an NFP-based mixed-part autonester that:
- Places multiple different drawings on a plate with true geometry-aware collision avoidance
- Produces tighter layouts than bounding-box packing by allowing parts to interlock
- Uses simulated annealing to optimize part ordering and rotation
- Integrates cleanly alongside existing Fill/Pack methods in NestEngine
- Provides an abstracted optimizer interface for future GA/parallel upgrades
## Dependencies
**Clipper2** (NuGet: `Clipper2Lib`, MIT license) — provides polygon boolean operations (union, difference, intersection) and polygon offsetting. Required for feasible region computation and perimeter inflation. Added to `OpenNest.Core`.
## Architecture
Three layers, built bottom-up:
```
┌──────────────────────────────────┐
│ Simulated Annealing Optimizer │ Layer 3: searches orderings/rotations
│ (implements INestOptimizer) │
├──────────────────────────────────┤
│ NFP-Based Bottom-Left Fill │ Layer 2: places parts using feasible regions
│ (BLF placement engine) │
├──────────────────────────────────┤
│ NFP / IFP Computation + Cache │ Layer 1: geometric foundation
└──────────────────────────────────┘
```
## Layer 1: No-Fit Polygon (NFP) Computation
### What is an NFP?
The No-Fit Polygon between a stationary polygon A and an orbiting polygon B defines all positions where B's reference point would cause A and B to overlap. If B's reference point is:
- **Inside** the NFP → overlap
- **On the boundary** → touching
- **Outside** → no overlap
### Computation Method: Convex Decomposition
Use the **Minkowski sum** approach: NFP(A, B) = A ⊕ (-B), where -B is B reflected through its reference point.
For convex polygons this is a simple merge-sort of edge vectors — O(n+m) where n and m are vertex counts.
For concave polygons (most real CNC parts), use decomposition:
1. Decompose each concave polygon into convex sub-polygons using ear-clipping triangulation (produces O(n) triangles per polygon)
2. For each pair of convex pieces (one from A, one from -B), compute the convex Minkowski sum
3. Union all partial results using Clipper2 to produce the final NFP
The number of convex pair computations is O(t_A * t_B) where t_A and t_B are the triangle counts. Each convex Minkowski sum is O(n+m) ≈ O(6) for triangle pairs, so the cost is dominated by the Clipper2 union step.
### Input
Polygons come from the existing conversion chain:
```
Drawing.Program
→ ConvertProgram.ToGeometry() // recursively expands SubProgramCalls
→ Helper.GetShapes() // chains connected entities into Shapes
→ DefinedShape // separates perimeter from cutouts
→ .Perimeter // outer boundary
→ .OffsetEntity(halfSpacing) // inflate at Shape level (offset is implemented on Shape)
→ .ToPolygonWithTolerance(circumscribe) // polygon with arcs circumscribed
```
Only `DefinedShape.Perimeter` is used for NFP — cutouts do not affect part-to-part collision. Spacing is applied by inflating the perimeter shape by half-spacing on each part (so two adjacent parts have full spacing between them). Inflation is done at the `Shape` level via `Shape.OffsetEntity()` before polygon conversion, consistent with how `PartBoundary` works today.
### Polygon Vertex Budget
`Shape.ToPolygonWithTolerance` with tolerance 0.01 can produce hundreds of vertices for arc-heavy parts. For NFP computation, a coarser tolerance (e.g., 0.05-0.1) may be used to keep triangle counts reasonable. The exact tolerance should be tuned during implementation — start with the existing 0.01 and coarsen only if profiling shows NFP computation is a bottleneck.
### NFP Cache
NFPs are keyed by `(DrawingA.Id, RotationA, DrawingB.Id, RotationB)` and computed once per unique combination. During SA optimization, the same NFPs are reused thousands of times.
`Drawing.Id` is a new `int` property added to the `Drawing` class, auto-assigned on construction.
**Type:** `NfpCache` — dictionary-based lookup with lazy computation (compute on first access, store for reuse).
For N drawings with R candidate rotations, the cache holds up to N^2 * R^2 entries. With typical values (6 drawings, 10 rotations), that's ~3,600 entries — well within memory.
### New Types
| Type | Project | Namespace | Purpose |
|------|---------|-----------|---------|
| `NoFitPolygon` | Core | `OpenNest.Geometry` | Static methods for NFP computation between two polygons |
| `InnerFitPolygon` | Core | `OpenNest.Geometry` | Static methods for IFP (part inside plate boundary) |
| `ConvexDecomposition` | Core | `OpenNest.Geometry` | Ear-clipping triangulation of concave polygons |
| `NfpCache` | Engine | `OpenNest` | Caches computed NFPs keyed by drawing ID/rotation pairs |
## Layer 2: Inner-Fit Polygon (IFP)
The IFP answers "where can this part be placed inside the plate?" It is the NFP of the plate boundary with the part, but inverted — the feasible region is the interior.
For a rectangular plate and a convex part, the IFP is a smaller rectangle (plate shrunk by part dimensions). For concave parts the IFP boundary follows the plate edges inset by the part's profile.
### Feasible Region
For placing part P(i) given already-placed parts P(1)...P(i-1):
```
FeasibleRegion = IFP(plate, P(i)) minus union(NFP(P(1), P(i)), ..., NFP(P(i-1), P(i)))
```
Both the union and difference operations use Clipper2.
The placement point is the bottom-left-most point on the feasible region boundary.
## Layer 3: NFP-Based Bottom-Left Fill (BLF)
### Algorithm
```
BLF(sequence, plate, nfpCache):
placedParts = []
for each (drawing, rotation) in sequence:
polygon = getPerimeterPolygon(drawing, rotation)
ifp = InnerFitPolygon.Compute(plate.WorkArea, polygon)
nfps = [nfpCache.Get(placed, polygon) for placed in placedParts]
feasible = Clipper2.Difference(ifp, Clipper2.Union(nfps))
point = bottomLeftMost(feasible)
if point exists:
place part at point
placedParts.append(part)
return FillScore.Compute(placedParts, plate.WorkArea)
```
### Bottom-Left Point Selection
"Bottom-left" means: minimize Y first (lowest), then minimize X (leftmost). This is standard BLF convention in the nesting literature. The feasible region boundary is walked to find the point with the smallest Y coordinate, breaking ties by smallest X.
Note: the existing `PackBottomLeft.FindPointVertical` uses leftmost-first priority. The new BLF uses lowest-first to match established nesting algorithms. Both remain available.
### Replaces
This replaces `PackBottomLeft` for mixed-part scenarios. The existing rectangle-based `PackBottomLeft` remains available for pure-rectangle use cases.
## Layer 4: Simulated Annealing Optimizer
### Interface
```csharp
interface INestOptimizer
{
NestResult Optimize(List<NestItem> items, Plate plate, NfpCache cache,
CancellationToken cancellation = default);
}
```
This abstraction allows swapping SA for GA (or other meta-heuristics) in the future without touching the NFP or BLF layers. `CancellationToken` enables UI responsiveness and user-initiated cancellation for long-running optimizations.
### State Representation
The SA state is a sequence of `(DrawingId, RotationAngle)` tuples — one entry per physical part to place (respecting quantities from NestItem). The sequence determines placement order for BLF.
### Mutation Operators
Each iteration, one mutation is selected randomly:
| Operator | Description |
|----------|-------------|
| **Swap** | Exchange two parts' positions in the sequence |
| **Rotate** | Change one part's rotation to another candidate angle |
| **Segment reverse** | Reverse a contiguous subsequence |
### Cooling Schedule
- **Initial temperature:** Calibrated so ~80% of worse moves are accepted initially
- **Cooling rate:** Geometric (T = T * alpha, alpha ~0.995-0.999)
- **Termination:** Temperature below threshold OR no improvement for N consecutive iterations OR cancellation requested
- **Quality focus:** Since quality > speed, allow long runs (thousands of iterations)
### Candidate Rotations
Per drawing, candidate rotations are determined by existing logic:
- Hull-edge angles from `RotationAnalysis.FindHullEdgeAngles()` (needs a new overload accepting a `Drawing` or `Polygon` instead of `List<Part>`)
- 0 degrees baseline
- Fixed-increment sweep (e.g., 5 degrees) for small work areas
### Initial Solution
Generate the initial sequence by sorting parts largest-area-first (matches existing `PackBottomLeft` heuristic). This gives SA a reasonable starting point rather than random.
## Integration: NestEngine.AutoNest
New public entry point:
```csharp
public List<Part> AutoNest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
```
### Flow
1. Extract perimeter polygons via `DefinedShape` for each unique drawing
2. Inflate perimeters by half-spacing at the `Shape` level via `OffsetEntity()`
3. Convert to polygons via `ToPolygonWithTolerance(circumscribe: true)`
4. Compute candidate rotations per drawing
5. Pre-compute `NfpCache` for all (drawing, rotation) pair combinations
6. Run `INestOptimizer.Optimize()` → best sequence
7. Final BLF placement with best solution → placed `Part` instances
8. Return parts
### Error Handling
- Drawing produces no valid perimeter polygon → skip drawing, log warning
- No parts fit on plate → return empty list
- NFP produces degenerate polygon (zero area) → treat as non-overlapping (safe fallback)
### Coexistence with Existing Methods
| Method | Use Case | Status |
|--------|----------|--------|
| `Fill(NestItem)` | Single-drawing plate fill (tiling) | Unchanged |
| `Pack(List<NestItem>)` | Rectangle bin packing | Unchanged |
| `AutoNest(List<NestItem>, Plate)` | Mixed-part geometry-aware nesting | **New** |
## Future: Simplifying FillLinear with NFP
Once NFP infrastructure is in place, `FillLinear.FindCopyDistance` can be replaced with NFP projections:
- Copy distance along any axis = extreme point of NFP projected onto that axis
- Eliminates directional edge filtering, line-to-line sliding, and special-case handling in `PartBoundary`
- `BestFitFinder` pair evaluation simplifies to walking NFP boundaries
This refactor is deferred to a future phase to keep scope focused.
## Future: Nesting Inside Cutouts
`DefinedShape.Cutouts` are preserved but unused in Phase 1. Future optimization: for parts with large interior cutouts, compute IFP of the cutout boundary and attempt to place small parts inside. This requires:
- Minimum cutout size threshold
- Modified BLF that considers cutout interiors as additional placement regions
## Future: Parallel Optimization (Threadripper)
The `INestOptimizer` interface naturally supports parallelism:
- **SA parallelism:** Run multiple independent SA chains with different random seeds, take the best result
- **GA upgrade:** Population-based evaluation is embarrassingly parallel — evaluate N candidate orderings simultaneously on N threads
- NFP cache is read-only during optimization, so it's inherently thread-safe
## Future: Orbiting NFP Algorithm
If convex decomposition proves too slow for vertex-heavy parts, the orbiting (edge-tracing) method computes NFPs directly on concave polygons without decomposition. Deferred unless profiling identifies decomposition as a bottleneck.
## Scoring
Results are scored using existing `FillScore` (lexicographic: count → usable remnant area → density). `FillScore.Compute` takes `(List<Part>, Box workArea)` — pass `plate.WorkArea` as the Box. No changes to FillScore needed.
## Project Location
All new types go in existing projects:
- `NoFitPolygon`, `InnerFitPolygon`, `ConvexDecomposition``OpenNest.Core/Geometry/`
- `NfpCache`, BLF engine, SA optimizer, `INestOptimizer``OpenNest.Engine/`
- `AutoNest` method → `NestEngine.cs`
- `Drawing.Id` property → `OpenNest.Core/Drawing.cs`
- Clipper2 NuGet → `OpenNest.Core.csproj`