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:
378
docs/plans/2026-03-07-best-fit-viewer-design.md
Normal file
378
docs/plans/2026-03-07-best-fit-viewer-design.md
Normal 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"
|
||||||
|
```
|
||||||
963
docs/plans/2026-03-07-bestfit-pair-finding.md
Normal file
963
docs/plans/2026-03-07-bestfit-pair-finding.md
Normal 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
|
||||||
|
```
|
||||||
1024
docs/plans/2026-03-07-net8-migration.md
Normal file
1024
docs/plans/2026-03-07-net8-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user