docs: add implementation plans for best-fit viewer, pair finding, and .NET 8 migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 07:24:45 -04:00
parent 6ed20a6a48
commit 2216b8553f
3 changed files with 2365 additions and 0 deletions

View File

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

View File

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