chore: remove stale superpowers docs and update gitignore
Remove implemented plan/spec docs from docs/superpowers/ (recoverable from git history). Add .superpowers/ and launchSettings.json to gitignore. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,475 +0,0 @@
|
||||
# FillScore Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace raw part-count comparisons with a structured FillScore (count → largest usable remnant → density) and expand remainder strip rotation coverage so denser pair patterns can win.
|
||||
|
||||
**Architecture:** New `FillScore` readonly struct with lexicographic comparison. Thread `workArea` parameter through `NestEngine` comparison methods. Expand `FillLinear.FillRemainingStrip` to try 0° and 90° in addition to seed rotations.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, OpenNest.Engine
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: FillScore and NestEngine Integration
|
||||
|
||||
### Task 1: Create FillScore struct
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/FillScore.cs`
|
||||
|
||||
- [ ] **Step 1: Create FillScore.cs**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum short-side dimension for a remnant to be considered usable.
|
||||
/// </summary>
|
||||
public const double MinRemnantDimension = 12.0;
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Area of the largest remnant whose short side >= MinRemnantDimension.
|
||||
/// Zero if no usable remnant exists.
|
||||
/// </summary>
|
||||
public double UsableRemnantArea { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double usableRemnantArea, double density)
|
||||
{
|
||||
Count = count;
|
||||
UsableRemnantArea = usableRemnantArea;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a fill score from placed parts and the work area they were placed in.
|
||||
/// </summary>
|
||||
public static FillScore Compute(List<Part> parts, Box workArea)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in parts)
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var bboxArea = bbox.Area();
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea);
|
||||
|
||||
return new FillScore(parts.Count, usableRemnantArea, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
|
||||
/// by checking right and top edge strips between placed parts and the work area boundary.
|
||||
/// </summary>
|
||||
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
|
||||
{
|
||||
var maxRight = double.MinValue;
|
||||
var maxTop = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Right > maxRight)
|
||||
maxRight = bb.Right;
|
||||
|
||||
if (bb.Top > maxTop)
|
||||
maxTop = bb.Top;
|
||||
}
|
||||
|
||||
var largest = 0.0;
|
||||
|
||||
// Right strip
|
||||
if (maxRight < workArea.Right)
|
||||
{
|
||||
var width = workArea.Right - maxRight;
|
||||
var height = workArea.Height;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
// Top strip
|
||||
if (maxTop < workArea.Top)
|
||||
{
|
||||
var width = workArea.Width;
|
||||
var height = workArea.Top - maxTop;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
return largest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then usable remnant area, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = UsableRemnantArea.CompareTo(other.UsableRemnantArea);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return Density.CompareTo(other.Density);
|
||||
}
|
||||
|
||||
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
|
||||
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
|
||||
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
|
||||
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillScore.cs
|
||||
git commit -m "feat: add FillScore struct with lexicographic comparison"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update NestEngine to use FillScore
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
|
||||
This task threads `workArea` through the comparison methods and replaces the inline logic with `FillScore`.
|
||||
|
||||
- [ ] **Step 1: Replace IsBetterFill**
|
||||
|
||||
Replace the existing `IsBetterFill` method (lines 299-315) with:
|
||||
|
||||
```csharp
|
||||
private bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace IsBetterValidFill**
|
||||
|
||||
Replace the existing `IsBetterValidFill` method (lines 317-323) with:
|
||||
|
||||
```csharp
|
||||
private bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
|
||||
return false;
|
||||
|
||||
return IsBetterFill(candidate, current, workArea);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all IsBetterFill call sites in FindBestFill**
|
||||
|
||||
In `FindBestFill` (lines 55-121), the `workArea` parameter is already available. Update each call:
|
||||
|
||||
```csharp
|
||||
// Line 95 — was: if (IsBetterFill(h, best))
|
||||
if (IsBetterFill(h, best, workArea))
|
||||
|
||||
// Line 98 — was: if (IsBetterFill(v, best))
|
||||
if (IsBetterFill(v, best, workArea))
|
||||
|
||||
// Line 109 — was: if (IsBetterFill(rectResult, best))
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
|
||||
// Line 117 — was: if (IsBetterFill(pairResult, best))
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update IsBetterFill call sites in Fill(NestItem, Box)**
|
||||
|
||||
In `Fill(NestItem item, Box workArea)` (lines 32-53):
|
||||
|
||||
```csharp
|
||||
// Line 39 — was: if (IsBetterFill(improved, best))
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update call sites in Fill(List\<Part\>, Box)**
|
||||
|
||||
In `Fill(List<Part> groupParts, Box workArea)` (lines 123-166):
|
||||
|
||||
```csharp
|
||||
// Line 141 — was: if (IsBetterFill(rectResult, best))
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
|
||||
// Line 148 — was: if (IsBetterFill(pairResult, best))
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
|
||||
// Line 154 — was: if (IsBetterFill(improved, best))
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update FillPattern to accept and pass workArea**
|
||||
|
||||
Change the signature and update calls inside:
|
||||
|
||||
```csharp
|
||||
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
|
||||
if (IsBetterValidFill(h, best, workArea))
|
||||
best = h;
|
||||
|
||||
if (IsBetterValidFill(v, best, workArea))
|
||||
best = v;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update FillPattern call sites**
|
||||
|
||||
Two call sites — both have `workArea` available:
|
||||
|
||||
In `Fill(List<Part> groupParts, Box workArea)` (line 130):
|
||||
```csharp
|
||||
// was: var best = FillPattern(engine, groupParts, angles);
|
||||
var best = FillPattern(engine, groupParts, angles, workArea);
|
||||
```
|
||||
|
||||
In `FillWithPairs` (line 216):
|
||||
```csharp
|
||||
// was: var filled = FillPattern(engine, pairParts, angles);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update FillWithPairs to use FillScore**
|
||||
|
||||
Replace the `ConcurrentBag` and comparison logic (lines 208-228):
|
||||
|
||||
```csharp
|
||||
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, candidates.Count, i =>
|
||||
{
|
||||
var result = candidates[i];
|
||||
var pairParts = result.BuildParts(item.Drawing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
if (filled != null && filled.Count > 0)
|
||||
resultBag.Add((FillScore.Compute(filled, workArea), filled));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var (score, parts) in resultBag)
|
||||
{
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = parts;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Update TryRemainderImprovement call sites**
|
||||
|
||||
In `TryRemainderImprovement` (lines 438-456), the method already receives `workArea` — just update the internal `IsBetterFill` calls:
|
||||
|
||||
```csharp
|
||||
// Line 447 — was: if (IsBetterFill(hResult, best))
|
||||
if (IsBetterFill(hResult, best, workArea))
|
||||
|
||||
// Line 452 — was: if (IsBetterFill(vResult, best))
|
||||
if (IsBetterFill(vResult, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update FillWithPairs debug logging**
|
||||
|
||||
Update the debug line after the `foreach` loop over `resultBag` (line 230):
|
||||
|
||||
```csharp
|
||||
// was: Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
```
|
||||
|
||||
Also update `FindBestFill` debug line (line 102):
|
||||
|
||||
```csharp
|
||||
// was: Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
|
||||
var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat: use FillScore for fill result comparisons in NestEngine"
|
||||
```
|
||||
|
||||
**Note — deliberately excluded comparisons:**
|
||||
|
||||
- `TryStripRefill` (line 424): `stripParts.Count <= lastCluster.Count` — this is a threshold check ("did the strip refill find more parts than the ragged cluster it replaced?"), not a quality comparison between two complete fills. FillScore is not meaningful here because we're comparing a fill result against a subset of existing parts.
|
||||
- `FillLinear.FillRemainingStrip` (line 436): internal sub-fill within a strip where remnant quality doesn't apply. Count-only is correct at this level.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Expanded Remainder Rotations
|
||||
|
||||
### Task 3: Expand FillRemainingStrip rotation coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/FillLinear.cs`
|
||||
|
||||
This is the change that fixes the 45→47 case. Currently `FillRemainingStrip` only tries rotations from the seed pattern. Adding 0° and 90° ensures the remainder strip can discover better orientations.
|
||||
|
||||
- [ ] **Step 1: Update FillRemainingStrip rotation loop**
|
||||
|
||||
Replace the rotation loop in `FillRemainingStrip` (lines 409-441) with:
|
||||
|
||||
```csharp
|
||||
// Build rotation set: always try cardinal orientations (0° and 90°),
|
||||
// plus any unique rotations from the seed pattern.
|
||||
var filler = new FillLinear(remainingStrip, PartSpacing);
|
||||
List<Part> best = null;
|
||||
var rotations = new List<(Drawing drawing, double rotation)>();
|
||||
|
||||
// Cardinal rotations for each unique drawing.
|
||||
var drawings = new List<Drawing>();
|
||||
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var found = false;
|
||||
|
||||
foreach (var d in drawings)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
drawings.Add(seedPart.BaseDrawing);
|
||||
}
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
rotations.Add((drawing, 0));
|
||||
rotations.Add((drawing, Angle.HalfPI));
|
||||
}
|
||||
|
||||
// Add seed pattern rotations that aren't already covered.
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var skip = false;
|
||||
|
||||
foreach (var (d, r) in rotations)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
|
||||
{
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip)
|
||||
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
|
||||
}
|
||||
|
||||
foreach (var (drawing, rotation) in rotations)
|
||||
{
|
||||
var h = filler.Fill(drawing, rotation, NestDirection.Horizontal);
|
||||
var v = filler.Fill(drawing, rotation, NestDirection.Vertical);
|
||||
|
||||
if (h != null && h.Count > 0 && (best == null || h.Count > best.Count))
|
||||
best = h;
|
||||
|
||||
if (v != null && v.Count > 0 && (best == null || v.Count > best.Count))
|
||||
best = v;
|
||||
}
|
||||
```
|
||||
|
||||
Note: The comparison inside `FillRemainingStrip` stays as count-only. This is an internal sub-fill within a strip — remnant quality doesn't apply at this level.
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillLinear.cs
|
||||
git commit -m "feat: try cardinal rotations in FillRemainingStrip for better strip fills"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full build and manual verification
|
||||
|
||||
- [ ] **Step 1: Build entire solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors
|
||||
|
||||
- [ ] **Step 2: Manual test with 4980 A24 PT02 nest**
|
||||
|
||||
Open the application, load the 4980 A24 PT02 drawing on a 60×120" plate, run Ctrl+F fill. Check Debug output for:
|
||||
1. Pattern #1 (89.7°) should now get 47 parts via expanded remainder rotations
|
||||
2. FillScore comparison should pick 47 over 45
|
||||
3. Verify no overlaps in the result
|
||||
@@ -1,367 +0,0 @@
|
||||
# OpenNest Test Harness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest.
|
||||
|
||||
**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path.
|
||||
|
||||
**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. |
|
||||
| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest |
|
||||
| Modify | `OpenNest.sln` | Add new project to solution |
|
||||
| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Console App + MCP Tool
|
||||
|
||||
### Task 1: Create the OpenNest.TestHarness project
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj`
|
||||
|
||||
- [ ] **Step 1: Create the project file**
|
||||
|
||||
Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool.
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.TestHarness</RootNamespace>
|
||||
<AssemblyName>OpenNest.TestHarness</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add project to solution**
|
||||
|
||||
```bash
|
||||
dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify it builds**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write the TestHarness Program.cs
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/Program.cs`
|
||||
|
||||
The console app does:
|
||||
1. Parse command-line args for nest file path, optional drawing name, plate index, output path
|
||||
2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file
|
||||
3. Load the nest file via `NestReader`
|
||||
4. Find the drawing and plate
|
||||
5. Clear existing parts from the plate
|
||||
6. Run `NestEngine.Fill()`
|
||||
7. Print summary (part count, utilization, log file path) to stdout
|
||||
8. Save the nest via `NestWriter`
|
||||
|
||||
- [ ] **Step 1: Write Program.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.IO;
|
||||
|
||||
// Parse arguments.
|
||||
var nestFile = args.Length > 0 ? args[0] : null;
|
||||
var drawingName = (string)null;
|
||||
var plateIndex = 0;
|
||||
var outputFile = (string)null;
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--drawing" when i + 1 < args.Length:
|
||||
drawingName = args[++i];
|
||||
break;
|
||||
case "--plate" when i + 1 < args.Length:
|
||||
plateIndex = int.Parse(args[++i]);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
outputFile = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.TestHarness <nest-file> [--drawing <name>] [--plate <index>] [--output <path>]");
|
||||
Console.Error.WriteLine(" nest-file Path to a .zip nest file");
|
||||
Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --output Output nest file path (default: <input>-result.zip)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Set up debug log file.
|
||||
var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
||||
var logWriter = new StreamWriter(logFile) { AutoFlush = true };
|
||||
Trace.Listeners.Add(new TextWriterTraceListener(logWriter));
|
||||
|
||||
// Load nest.
|
||||
var reader = new NestReader(nestFile);
|
||||
var nest = reader.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: nest file contains no plates");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (plateIndex >= nest.Plates.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var plate = nest.Plates[plateIndex];
|
||||
|
||||
// Find drawing.
|
||||
var drawing = drawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
Console.Error.WriteLine(drawingName != null
|
||||
? $"Error: drawing '{drawingName}' not found"
|
||||
: "Error: nest file contains no drawings");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear existing parts.
|
||||
var existingCount = plate.Parts.Count;
|
||||
plate.Parts.Clear();
|
||||
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}");
|
||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
||||
Console.WriteLine($"Cleared {existingCount} existing parts");
|
||||
Console.WriteLine("---");
|
||||
|
||||
// Run fill.
|
||||
var sw = Stopwatch.StartNew();
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = 0 };
|
||||
var success = engine.Fill(item);
|
||||
sw.Stop();
|
||||
|
||||
// Flush and close the log.
|
||||
Trace.Flush();
|
||||
logWriter.Dispose();
|
||||
|
||||
// Print results.
|
||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
||||
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
|
||||
Console.WriteLine($"Debug log: {logFile}");
|
||||
|
||||
// Save output.
|
||||
if (outputFile == null)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(nestFile);
|
||||
var name = Path.GetFileNameWithoutExtension(nestFile);
|
||||
outputFile = Path.Combine(dir, $"{name}-result.zip");
|
||||
}
|
||||
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
|
||||
return 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded with 0 errors.
|
||||
|
||||
- [ ] **Step 3: Run a smoke test with the real nest file**
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip"
|
||||
```
|
||||
|
||||
Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.TestHarness/ OpenNest.sln
|
||||
git commit -m "feat: add OpenNest.TestHarness console app for engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add the MCP test_engine tool
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Mcp/Tools/TestTools.cs`
|
||||
|
||||
The MCP tool:
|
||||
1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters
|
||||
2. Runs `dotnet run --project <path> -- <args>` capturing stdout (results) and stderr (errors only)
|
||||
3. Returns the summary + debug log file path (Claude can then Grep the log file)
|
||||
|
||||
Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree.
|
||||
|
||||
- [ ] **Step 1: Create TestTools.cs**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace OpenNest.Mcp.Tools
|
||||
{
|
||||
[McpServerToolType]
|
||||
public class TestTools
|
||||
{
|
||||
private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest";
|
||||
|
||||
private static readonly string HarnessProject = Path.Combine(
|
||||
SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj");
|
||||
|
||||
[McpServerTool(Name = "test_engine")]
|
||||
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
|
||||
public string TestEngine(
|
||||
[Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip",
|
||||
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
|
||||
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
|
||||
[Description("Output nest file path (default: <input>-result.zip)")] string outputFile = null)
|
||||
{
|
||||
if (!File.Exists(nestFile))
|
||||
return $"Error: nest file not found: {nestFile}";
|
||||
|
||||
var processArgs = new StringBuilder();
|
||||
processArgs.Append($"\"{nestFile}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(drawingName))
|
||||
processArgs.Append($" --drawing \"{drawingName}\"");
|
||||
|
||||
processArgs.Append($" --plate {plateIndex}");
|
||||
|
||||
if (!string.IsNullOrEmpty(outputFile))
|
||||
processArgs.Append($" --output \"{outputFile}\"");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = SolutionRoot
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(120_000);
|
||||
var stderr = stderrTask.Result;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
sb.Append(stdout.TrimEnd());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Errors ===");
|
||||
sb.Append(stderr.TrimEnd());
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Process exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
sb.AppendLine($"Error running test harness: {ex.Message}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the MCP project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Republish the MCP server**
|
||||
|
||||
```bash
|
||||
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
|
||||
```
|
||||
|
||||
Expected: Publish succeeded. The MCP server now has the `test_engine` tool.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/TestTools.cs
|
||||
git commit -m "feat: add test_engine MCP tool for iterative engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
After implementation, the workflow for iterating on FillLinear becomes:
|
||||
|
||||
1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs`
|
||||
2. **This session** calls `test_engine` (no args needed — defaults to the test nest file)
|
||||
3. The tool builds the latest code and runs it in a fresh process
|
||||
4. Returns: part count, utilization, timing, and **debug log file path**
|
||||
5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`)
|
||||
6. Repeat
|
||||
@@ -1,281 +0,0 @@
|
||||
# Contour Re-Indexing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
|
||||
|
||||
**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point |
|
||||
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Splitting Primitives
|
||||
|
||||
### Task 1: Add `Line.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Line.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Line`**
|
||||
|
||||
Add the following method to the `Line` class (after the existing `ClosestPointTo` method):
|
||||
|
||||
```csharp
|
||||
public (Line first, Line second) SplitAt(Vector point)
|
||||
{
|
||||
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(StartPoint, point);
|
||||
|
||||
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(point, EndPoint);
|
||||
|
||||
return (first, second);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Line.cs
|
||||
git commit -m "feat: add Line.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
### Task 2: Add `Arc.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Arc.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Arc`**
|
||||
|
||||
Add the following method to the `Arc` class (after the existing `EndPoint` method):
|
||||
|
||||
```csharp
|
||||
public (Arc first, Arc second) SplitAt(Vector point)
|
||||
{
|
||||
if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon)
|
||||
return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed));
|
||||
|
||||
if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon)
|
||||
return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null);
|
||||
|
||||
var splitAngle = Angle.NormalizeRad(Center.AngleTo(point));
|
||||
|
||||
var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed);
|
||||
var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed);
|
||||
|
||||
return (firstArc, secondArc);
|
||||
}
|
||||
```
|
||||
|
||||
Key details from spec:
|
||||
- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues).
|
||||
- `splitAngle` is computed from `Center.AngleTo(point)`, normalized.
|
||||
- Both halves preserve center, radius, and `IsReversed` direction.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Arc.cs
|
||||
git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Shape.ReindexAt
|
||||
|
||||
### Task 3: Add `Shape.ReindexAt(Vector, Entity)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Shape.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ReindexAt` method to `Shape`**
|
||||
|
||||
Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201):
|
||||
|
||||
```csharp
|
||||
public Shape ReindexAt(Vector point, Entity entity)
|
||||
{
|
||||
// Circle case: return a new shape with just the circle
|
||||
if (entity is Circle)
|
||||
{
|
||||
var result = new Shape();
|
||||
result.Entities.Add(entity);
|
||||
return result;
|
||||
}
|
||||
|
||||
var i = Entities.IndexOf(entity);
|
||||
if (i < 0)
|
||||
throw new ArgumentException("Entity not found in shape", nameof(entity));
|
||||
|
||||
// Split the entity at the point
|
||||
Entity firstHalf = null;
|
||||
Entity secondHalf = null;
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (f, s) = line.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
var (f, s) = arc.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
|
||||
// Build reindexed entity list
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// secondHalf of split entity (if not null)
|
||||
if (secondHalf != null)
|
||||
entities.Add(secondHalf);
|
||||
|
||||
// Entities after the split index (wrapping)
|
||||
for (var j = i + 1; j < Entities.Count; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// Entities before the split index (wrapping)
|
||||
for (var j = 0; j < i; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// firstHalf of split entity (if not null)
|
||||
if (firstHalf != null)
|
||||
entities.Add(firstHalf);
|
||||
|
||||
var reindexed = new Shape();
|
||||
reindexed.Entities.AddRange(entities);
|
||||
return reindexed;
|
||||
}
|
||||
```
|
||||
|
||||
The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Shape.cs
|
||||
git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Wire into ContourCuttingStrategy
|
||||
|
||||
### Task 4: Add `ConvertShapeToMoves` and replace stubs
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ConvertShapeToMoves` private method**
|
||||
|
||||
Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class):
|
||||
|
||||
```csharp
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint));
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
```
|
||||
|
||||
This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape).
|
||||
|
||||
- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
// Contour re-indexing: split shape entities at closestPt so cutting
|
||||
// starts there, convert to ICode, and add to result.Codes
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 5: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
|
||||
git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,767 +0,0 @@
|
||||
# Nest File Format v2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the XML+G-code nest file format with a single `nest.json` metadata file plus `programs/` folder inside the ZIP archive.
|
||||
|
||||
**Architecture:** Add a `NestFormat` static class containing DTO records and shared JSON options. Rewrite `NestWriter` to serialize DTOs to JSON and write programs under `programs/`. Rewrite `NestReader` to deserialize JSON and read programs from `programs/`. Public API unchanged.
|
||||
|
||||
**Tech Stack:** `System.Text.Json` (built into .NET 8, no new packages needed)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `OpenNest.IO/NestFormat.cs` | DTO records for JSON serialization + shared `JsonSerializerOptions` |
|
||||
| Rewrite | `OpenNest.IO/NestWriter.cs` | Serialize nest to JSON + write programs to `programs/` folder |
|
||||
| Rewrite | `OpenNest.IO/NestReader.cs` | Deserialize JSON + read programs from `programs/` folder |
|
||||
|
||||
No other files change. `ProgramReader.cs`, `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs`, all domain model classes, and all caller sites remain untouched.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: DTO Records and JSON Options
|
||||
|
||||
### Task 1: Create NestFormat.cs with DTO records
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.IO/NestFormat.cs`
|
||||
|
||||
These DTOs are the JSON shape — flat records that map 1:1 with the spec's JSON schema. They live in `OpenNest.IO` because they're serialization concerns, not domain model.
|
||||
|
||||
- [ ] **Step 1: Create `NestFormat.cs`**
|
||||
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public static class NestFormat
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public record NestDto
|
||||
{
|
||||
public int Version { get; init; } = 2;
|
||||
public string Name { get; init; } = "";
|
||||
public string Units { get; init; } = "Inches";
|
||||
public string Customer { get; init; } = "";
|
||||
public string DateCreated { get; init; } = "";
|
||||
public string DateLastModified { get; init; } = "";
|
||||
public string Notes { get; init; } = "";
|
||||
public PlateDefaultsDto PlateDefaults { get; init; } = new();
|
||||
public List<DrawingDto> Drawings { get; init; } = new();
|
||||
public List<PlateDto> Plates { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PlateDefaultsDto
|
||||
{
|
||||
public SizeDto Size { get; init; } = new();
|
||||
public double Thickness { get; init; }
|
||||
public int Quadrant { get; init; } = 1;
|
||||
public double PartSpacing { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
}
|
||||
|
||||
public record DrawingDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public string Customer { get; init; } = "";
|
||||
public ColorDto Color { get; init; } = new();
|
||||
public QuantityDto Quantity { get; init; } = new();
|
||||
public int Priority { get; init; }
|
||||
public ConstraintsDto Constraints { get; init; } = new();
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SourceDto Source { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PlateDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public SizeDto Size { get; init; } = new();
|
||||
public double Thickness { get; init; }
|
||||
public int Quadrant { get; init; } = 1;
|
||||
public int Quantity { get; init; } = 1;
|
||||
public double PartSpacing { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PartDto
|
||||
{
|
||||
public int DrawingId { get; init; }
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
}
|
||||
|
||||
public record SizeDto
|
||||
{
|
||||
public double Width { get; init; }
|
||||
public double Height { get; init; }
|
||||
}
|
||||
|
||||
public record MaterialDto
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string Grade { get; init; } = "";
|
||||
public double Density { get; init; }
|
||||
}
|
||||
|
||||
public record SpacingDto
|
||||
{
|
||||
public double Left { get; init; }
|
||||
public double Top { get; init; }
|
||||
public double Right { get; init; }
|
||||
public double Bottom { get; init; }
|
||||
}
|
||||
|
||||
public record ColorDto
|
||||
{
|
||||
public int A { get; init; } = 255;
|
||||
public int R { get; init; }
|
||||
public int G { get; init; }
|
||||
public int B { get; init; }
|
||||
}
|
||||
|
||||
public record QuantityDto
|
||||
{
|
||||
public int Required { get; init; }
|
||||
}
|
||||
|
||||
public record ConstraintsDto
|
||||
{
|
||||
public double StepAngle { get; init; }
|
||||
public double StartAngle { get; init; }
|
||||
public double EndAngle { get; init; }
|
||||
public bool Allow180Equivalent { get; init; }
|
||||
}
|
||||
|
||||
public record SourceDto
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public OffsetDto Offset { get; init; } = new();
|
||||
}
|
||||
|
||||
public record OffsetDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify DTOs compile**
|
||||
|
||||
Run: `dotnet build OpenNest.IO/OpenNest.IO.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestFormat.cs
|
||||
git commit -m "feat: add NestFormat DTOs for JSON nest file format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Rewrite NestWriter
|
||||
|
||||
### Task 2: Rewrite NestWriter to use JSON serialization
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `OpenNest.IO/NestWriter.cs`
|
||||
|
||||
The writer keeps the same public API: `NestWriter(Nest nest)` constructor and `bool Write(string file)`. Internally it builds a `NestDto` from the domain model, serializes it to `nest.json`, and writes each drawing's program to `programs/program-N`.
|
||||
|
||||
The G-code writing methods (`WriteDrawing`, `GetCodeString`, `GetLayerString`) are preserved exactly — they write program G-code to streams, which is unchanged. The `WritePlate` method and all XML methods (`AddNestInfo`, `AddPlateInfo`, `AddDrawingInfo`) are removed.
|
||||
|
||||
- [ ] **Step 1: Rewrite `NestWriter.cs`**
|
||||
|
||||
Replace the entire file. Key changes:
|
||||
- Remove `using System.Xml`
|
||||
- Add `using System.Text.Json`
|
||||
- Remove `AddNestInfo()`, `AddPlateInfo()`, `AddDrawingInfo()`, `AddPlates()`, `WritePlate()` methods
|
||||
- Add `BuildNestDto()` method that maps domain model → DTOs
|
||||
- `Write()` now serializes `NestDto` to `nest.json` and writes programs to `programs/program-N`
|
||||
- Keep `WriteDrawing()`, `GetCodeString()`, `GetLayerString()` exactly as-is
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public sealed class NestWriter
|
||||
{
|
||||
private const int OutputPrecision = 10;
|
||||
private const string CoordinateFormat = "0.##########";
|
||||
|
||||
private readonly Nest nest;
|
||||
private Dictionary<int, Drawing> drawingDict;
|
||||
|
||||
public NestWriter(Nest nest)
|
||||
{
|
||||
this.drawingDict = new Dictionary<int, Drawing>();
|
||||
this.nest = nest;
|
||||
}
|
||||
|
||||
public bool Write(string file)
|
||||
{
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
SetDrawingIds();
|
||||
|
||||
using var fileStream = new FileStream(file, FileMode.Create);
|
||||
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
WritePrograms(zipArchive);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SetDrawingIds()
|
||||
{
|
||||
var id = 1;
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
drawingDict.Add(id, drawing);
|
||||
id++;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteNestJson(ZipArchive zipArchive)
|
||||
{
|
||||
var dto = BuildNestDto();
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var entry = zipArchive.CreateEntry("nest.json");
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(json);
|
||||
}
|
||||
|
||||
private NestDto BuildNestDto()
|
||||
{
|
||||
return new NestDto
|
||||
{
|
||||
Version = 2,
|
||||
Name = nest.Name ?? "",
|
||||
Units = nest.Units.ToString(),
|
||||
Customer = nest.Customer ?? "",
|
||||
DateCreated = nest.DateCreated.ToString("o"),
|
||||
DateLastModified = nest.DateLastModified.ToString("o"),
|
||||
Notes = nest.Notes ?? "",
|
||||
PlateDefaults = BuildPlateDefaultsDto(),
|
||||
Drawings = BuildDrawingDtos(),
|
||||
Plates = BuildPlateDtos()
|
||||
};
|
||||
}
|
||||
|
||||
private PlateDefaultsDto BuildPlateDefaultsDto()
|
||||
{
|
||||
var pd = nest.PlateDefaults;
|
||||
return new PlateDefaultsDto
|
||||
{
|
||||
Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height },
|
||||
Thickness = pd.Thickness,
|
||||
Quadrant = pd.Quadrant,
|
||||
PartSpacing = pd.PartSpacing,
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = pd.Material.Name ?? "",
|
||||
Grade = pd.Material.Grade ?? "",
|
||||
Density = pd.Material.Density
|
||||
},
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = pd.EdgeSpacing.Left,
|
||||
Top = pd.EdgeSpacing.Top,
|
||||
Right = pd.EdgeSpacing.Right,
|
||||
Bottom = pd.EdgeSpacing.Bottom
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private List<DrawingDto> BuildDrawingDtos()
|
||||
{
|
||||
var list = new List<DrawingDto>();
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var d = kvp.Value;
|
||||
list.Add(new DrawingDto
|
||||
{
|
||||
Id = kvp.Key,
|
||||
Name = d.Name ?? "",
|
||||
Customer = d.Customer ?? "",
|
||||
Color = new ColorDto { A = d.Color.A, R = d.Color.R, G = d.Color.G, B = d.Color.B },
|
||||
Quantity = new QuantityDto { Required = d.Quantity.Required },
|
||||
Priority = d.Priority,
|
||||
Constraints = new ConstraintsDto
|
||||
{
|
||||
StepAngle = d.Constraints.StepAngle,
|
||||
StartAngle = d.Constraints.StartAngle,
|
||||
EndAngle = d.Constraints.EndAngle,
|
||||
Allow180Equivalent = d.Constraints.Allow180Equivalent
|
||||
},
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = d.Material.Name ?? "",
|
||||
Grade = d.Material.Grade ?? "",
|
||||
Density = d.Material.Density
|
||||
},
|
||||
Source = new SourceDto
|
||||
{
|
||||
Path = d.Source.Path ?? "",
|
||||
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y }
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<PlateDto> BuildPlateDtos()
|
||||
{
|
||||
var list = new List<PlateDto>();
|
||||
for (var i = 0; i < nest.Plates.Count; i++)
|
||||
{
|
||||
var plate = nest.Plates[i];
|
||||
var parts = new List<PartDto>();
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault();
|
||||
parts.Add(new PartDto
|
||||
{
|
||||
DrawingId = match.Key,
|
||||
X = part.Location.X,
|
||||
Y = part.Location.Y,
|
||||
Rotation = part.Rotation
|
||||
});
|
||||
}
|
||||
|
||||
list.Add(new PlateDto
|
||||
{
|
||||
Id = i + 1,
|
||||
Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height },
|
||||
Thickness = plate.Thickness,
|
||||
Quadrant = plate.Quadrant,
|
||||
Quantity = plate.Quantity,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = plate.Material.Name ?? "",
|
||||
Grade = plate.Material.Grade ?? "",
|
||||
Density = plate.Material.Density
|
||||
},
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = plate.EdgeSpacing.Left,
|
||||
Top = plate.EdgeSpacing.Top,
|
||||
Right = plate.EdgeSpacing.Right,
|
||||
Bottom = plate.EdgeSpacing.Bottom
|
||||
},
|
||||
Parts = parts
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private void WritePrograms(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var name = $"programs/program-{kvp.Key}";
|
||||
var stream = new MemoryStream();
|
||||
WriteDrawing(stream, kvp.Value);
|
||||
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using var entryStream = entry.Open();
|
||||
stream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDrawing(Stream stream, Drawing drawing)
|
||||
{
|
||||
var program = drawing.Program;
|
||||
var writer = new StreamWriter(stream);
|
||||
writer.AutoFlush = true;
|
||||
|
||||
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
for (var i = 0; i < drawing.Program.Length; ++i)
|
||||
{
|
||||
var code = drawing.Program[i];
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
private string GetCodeString(ICode code)
|
||||
{
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.ArcMove:
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var arcMove = (ArcMove)code;
|
||||
|
||||
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat);
|
||||
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat);
|
||||
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat);
|
||||
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat);
|
||||
|
||||
if (arcMove.Rotation == RotationType.CW)
|
||||
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j));
|
||||
else
|
||||
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
|
||||
|
||||
if (arcMove.Layer != LayerType.Cut)
|
||||
sb.Append(GetLayerString(arcMove.Layer));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
case CodeType.Comment:
|
||||
{
|
||||
var comment = (Comment)code;
|
||||
return ":" + comment.Value;
|
||||
}
|
||||
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var linearMove = (LinearMove)code;
|
||||
|
||||
sb.Append(string.Format("G01X{0}Y{1}",
|
||||
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
|
||||
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
|
||||
|
||||
if (linearMove.Layer != LayerType.Cut)
|
||||
sb.Append(GetLayerString(linearMove.Layer));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var rapidMove = (RapidMove)code;
|
||||
|
||||
return string.Format("G00X{0}Y{1}",
|
||||
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
|
||||
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
|
||||
}
|
||||
|
||||
case CodeType.SetFeedrate:
|
||||
{
|
||||
var setFeedrate = (Feedrate)code;
|
||||
return "F" + setFeedrate.Value;
|
||||
}
|
||||
|
||||
case CodeType.SetKerf:
|
||||
{
|
||||
var setKerf = (Kerf)code;
|
||||
|
||||
switch (setKerf.Value)
|
||||
{
|
||||
case KerfType.None: return "G40";
|
||||
case KerfType.Left: return "G41";
|
||||
case KerfType.Right: return "G42";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subProgramCall = (SubProgramCall)code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string GetLayerString(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Display:
|
||||
return ":DISPLAY";
|
||||
|
||||
case LayerType.Leadin:
|
||||
return ":LEADIN";
|
||||
|
||||
case LayerType.Leadout:
|
||||
return ":LEADOUT";
|
||||
|
||||
case LayerType.Scribe:
|
||||
return ":SCRIBE";
|
||||
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify NestWriter compiles**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestWriter.cs
|
||||
git commit -m "feat: rewrite NestWriter to use JSON format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Rewrite NestReader
|
||||
|
||||
### Task 3: Rewrite NestReader to use JSON deserialization
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `OpenNest.IO/NestReader.cs`
|
||||
|
||||
The reader keeps the same public API: `NestReader(string file)`, `NestReader(Stream stream)`, and `Nest Read()`. Internally it reads `nest.json`, deserializes to `NestDto`, reads programs from `programs/program-N`, and assembles the domain model.
|
||||
|
||||
All XML parsing, plate G-code parsing, dictionary-linking (`LinkProgramsToDrawings`, `LinkPartsToPlates`), and the helper enums/methods are removed.
|
||||
|
||||
- [ ] **Step 1: Rewrite `NestReader.cs`**
|
||||
|
||||
Replace the entire file:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public sealed class NestReader
|
||||
{
|
||||
private readonly Stream stream;
|
||||
private readonly ZipArchive zipArchive;
|
||||
|
||||
public NestReader(string file)
|
||||
{
|
||||
stream = new FileStream(file, FileMode.Open, FileAccess.Read);
|
||||
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
}
|
||||
|
||||
public NestReader(Stream stream)
|
||||
{
|
||||
this.stream = stream;
|
||||
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
}
|
||||
|
||||
public Nest Read()
|
||||
{
|
||||
var nestJson = ReadEntry("nest.json");
|
||||
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
|
||||
|
||||
var programs = ReadPrograms(dto.Drawings.Count);
|
||||
var drawingMap = BuildDrawings(dto, programs);
|
||||
var nest = BuildNest(dto, drawingMap);
|
||||
|
||||
zipArchive.Dispose();
|
||||
stream.Close();
|
||||
|
||||
return nest;
|
||||
}
|
||||
|
||||
private string ReadEntry(string name)
|
||||
{
|
||||
var entry = zipArchive.GetEntry(name)
|
||||
?? throw new InvalidDataException($"Nest file is missing required entry '{name}'.");
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private Dictionary<int, Program> ReadPrograms(int count)
|
||||
{
|
||||
var programs = new Dictionary<int, Program>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"programs/program-{i}");
|
||||
if (entry == null) continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
var memStream = new MemoryStream();
|
||||
entryStream.CopyTo(memStream);
|
||||
memStream.Position = 0;
|
||||
|
||||
var reader = new ProgramReader(memStream);
|
||||
programs[i] = reader.Read();
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
|
||||
{
|
||||
var map = new Dictionary<int, Drawing>();
|
||||
foreach (var d in dto.Drawings)
|
||||
{
|
||||
var drawing = new Drawing(d.Name);
|
||||
drawing.Customer = d.Customer;
|
||||
drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B);
|
||||
drawing.Quantity.Required = d.Quantity.Required;
|
||||
drawing.Priority = d.Priority;
|
||||
drawing.Constraints.StepAngle = d.Constraints.StepAngle;
|
||||
drawing.Constraints.StartAngle = d.Constraints.StartAngle;
|
||||
drawing.Constraints.EndAngle = d.Constraints.EndAngle;
|
||||
drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent;
|
||||
drawing.Material = new Material(d.Material.Name, d.Material.Grade, d.Material.Density);
|
||||
drawing.Source.Path = d.Source.Path;
|
||||
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
|
||||
|
||||
if (programs.TryGetValue(d.Id, out var pgm))
|
||||
drawing.Program = pgm;
|
||||
|
||||
map[d.Id] = drawing;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = dto.Name;
|
||||
|
||||
Units units;
|
||||
if (Enum.TryParse(dto.Units, true, out units))
|
||||
nest.Units = units;
|
||||
|
||||
nest.Customer = dto.Customer;
|
||||
nest.DateCreated = DateTime.Parse(dto.DateCreated);
|
||||
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
|
||||
nest.Notes = dto.Notes;
|
||||
|
||||
// Plate defaults
|
||||
var pd = dto.PlateDefaults;
|
||||
nest.PlateDefaults.Size = new Size(pd.Size.Width, pd.Size.Height);
|
||||
nest.PlateDefaults.Thickness = pd.Thickness;
|
||||
nest.PlateDefaults.Quadrant = pd.Quadrant;
|
||||
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
|
||||
nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density);
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top);
|
||||
|
||||
// Drawings
|
||||
foreach (var d in drawingMap.OrderBy(k => k.Key))
|
||||
nest.Drawings.Add(d.Value);
|
||||
|
||||
// Plates
|
||||
foreach (var p in dto.Plates.OrderBy(p => p.Id))
|
||||
{
|
||||
var plate = new Plate();
|
||||
plate.Size = new Size(p.Size.Width, p.Size.Height);
|
||||
plate.Thickness = p.Thickness;
|
||||
plate.Quadrant = p.Quadrant;
|
||||
plate.Quantity = p.Quantity;
|
||||
plate.PartSpacing = p.PartSpacing;
|
||||
plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density);
|
||||
plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top);
|
||||
|
||||
foreach (var partDto in p.Parts)
|
||||
{
|
||||
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
|
||||
continue;
|
||||
|
||||
var part = new Part(dwg);
|
||||
part.Rotate(partDto.Rotation);
|
||||
part.Offset(new Vector(partDto.X, partDto.Y));
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
|
||||
return nest;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify NestReader compiles**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestReader.cs
|
||||
git commit -m "feat: rewrite NestReader to use JSON format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Smoke Test
|
||||
|
||||
### Task 4: Manual smoke test via OpenNest.Console
|
||||
|
||||
**Files:** None modified — this is a verification step.
|
||||
|
||||
Use the `OpenNest.Console` project (or the MCP server) to verify round-trip: create a nest, save it, reload it, confirm data is intact.
|
||||
|
||||
- [ ] **Step 1: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors.
|
||||
|
||||
- [ ] **Step 2: Round-trip test via MCP tools**
|
||||
|
||||
Use the OpenNest MCP tools to:
|
||||
1. Create a drawing (e.g. a rectangle via `create_drawing`)
|
||||
2. Create a plate via `create_plate`
|
||||
3. Fill the plate via `fill_plate`
|
||||
4. Save the nest via the console app or verify `get_plate_info` shows parts
|
||||
5. If a nest file exists on disk, load it with `load_nest` and verify `get_plate_info` returns the same data
|
||||
|
||||
- [ ] **Step 3: Inspect the ZIP contents**
|
||||
|
||||
Unzip a saved nest file and verify:
|
||||
- `nest.json` exists with correct structure
|
||||
- `programs/program-1` (etc.) exist with G-code content
|
||||
- No `info`, `drawing-info`, `plate-info`, or `plate-NNN` files exist
|
||||
|
||||
- [ ] **Step 4: Commit any fixes**
|
||||
|
||||
If any issues were found and fixed, commit them:
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "fix: address issues found during nest format v2 smoke test"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
# Abstract Nest Engine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor the concrete `NestEngine` into an abstract `NestEngineBase` with pluggable implementations, a registry for engine discovery/selection, and plugin loading from DLLs.
|
||||
|
||||
**Architecture:** Extract shared state and utilities into `NestEngineBase` (abstract). Current logic becomes `DefaultNestEngine`. `NestEngineRegistry` provides factory creation, built-in registration, and DLL plugin discovery. All callsites migrate from `new NestEngine(plate)` to `NestEngineRegistry.Create(plate)`.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Mcp, OpenNest.Console
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md`
|
||||
|
||||
**Deferred:** `StripNester.cs` → `StripNestEngine.cs` conversion is deferred to the strip nester implementation plan (`docs/superpowers/plans/2026-03-15-strip-nester.md`). That plan should be updated to create `StripNestEngine` as a `NestEngineBase` subclass and register it in `NestEngineRegistry`. The UI engine selector combobox is also deferred — it can be added once there are multiple engines to choose from.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: NestEngineBase and DefaultNestEngine
|
||||
|
||||
### Task 1: Create NestEngineBase abstract class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineBase.cs`
|
||||
|
||||
This is the abstract base class. It holds shared properties, abstract `Name`/`Description`, virtual methods that return empty lists by default, convenience overloads that mutate the plate, `FillExact` (non-virtual), and protected utility methods extracted from the current `NestEngine`.
|
||||
|
||||
- [ ] **Step 1: Create NestEngineBase.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public abstract class NestEngineBase
|
||||
{
|
||||
protected NestEngineBase(Plate plate)
|
||||
{
|
||||
Plate = plate;
|
||||
}
|
||||
|
||||
public Plate Plate { get; set; }
|
||||
|
||||
public int PlateNumber { get; set; }
|
||||
|
||||
public NestDirection NestDirection { get; set; }
|
||||
|
||||
public NestPhase WinnerPhase { get; protected set; }
|
||||
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract string Description { get; }
|
||||
|
||||
// --- Virtual methods (side-effect-free, return parts) ---
|
||||
|
||||
public virtual List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
// --- FillExact (non-virtual, delegates to virtual Fill) ---
|
||||
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
// --- Convenience overloads (mutate plate, return bool) ---
|
||||
|
||||
public bool Fill(NestItem item)
|
||||
{
|
||||
return Fill(item, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(NestItem item, Box workArea)
|
||||
{
|
||||
var parts = Fill(item, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts)
|
||||
{
|
||||
return Fill(groupParts, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts, Box workArea)
|
||||
{
|
||||
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Pack(List<NestItem> items)
|
||||
{
|
||||
var workArea = Plate.WorkArea();
|
||||
var parts = PackArea(workArea, items, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Protected utilities ---
|
||||
|
||||
protected static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description)
|
||||
{
|
||||
if (progress == null || best == null || best.Count == 0)
|
||||
return;
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
var bounds = best.GetBoundingBox();
|
||||
|
||||
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
|
||||
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
|
||||
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
|
||||
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
|
||||
Debug.WriteLine(msg);
|
||||
try { System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
BestParts = clonedParts,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
|
||||
protected string BuildProgressSummary()
|
||||
{
|
||||
if (PhaseResults.Count == 0)
|
||||
return null;
|
||||
|
||||
var parts = new List<string>(PhaseResults.Count);
|
||||
|
||||
foreach (var r in PhaseResults)
|
||||
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
}
|
||||
|
||||
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
|
||||
{
|
||||
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsBetterFill(candidate, current, workArea);
|
||||
}
|
||||
|
||||
protected static bool HasOverlaps(List<Part> parts, double spacing)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var box1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var box2 = parts[j].BoundingBox;
|
||||
|
||||
if (box1.Right < box2.Left || box2.Right < box1.Left ||
|
||||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
|
||||
continue;
|
||||
|
||||
List<Vector> pts;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out pts))
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
var b2 = parts[j].BoundingBox;
|
||||
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
|
||||
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
|
||||
$" intersections={pts?.Count ?? 0}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string FormatPhaseName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "feat: add NestEngineBase abstract class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Convert NestEngine to DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Rename: `OpenNest.Engine/NestEngine.cs` → `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
Rename the class, make it inherit `NestEngineBase`, add `Name`/`Description`, change the virtual methods to `override`, and remove methods that now live in the base class (convenience overloads, `ReportProgress`, `BuildProgressSummary`, `IsBetterFill`, `IsBetterValidFill`, `HasOverlaps`, `FormatPhaseName`, `FillExact`).
|
||||
|
||||
- [ ] **Step 1: Rename the file**
|
||||
|
||||
```bash
|
||||
git mv OpenNest.Engine/NestEngine.cs OpenNest.Engine/DefaultNestEngine.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update class declaration and add inheritance**
|
||||
|
||||
In `DefaultNestEngine.cs`, change the class declaration from:
|
||||
|
||||
```csharp
|
||||
public class NestEngine
|
||||
{
|
||||
public NestEngine(Plate plate)
|
||||
{
|
||||
Plate = plate;
|
||||
}
|
||||
|
||||
public Plate Plate { get; set; }
|
||||
|
||||
public NestDirection NestDirection { get; set; }
|
||||
|
||||
public int PlateNumber { get; set; }
|
||||
|
||||
public NestPhase WinnerPhase { get; private set; }
|
||||
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```csharp
|
||||
public class DefaultNestEngine : NestEngineBase
|
||||
{
|
||||
public DefaultNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Default";
|
||||
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
```
|
||||
|
||||
This removes properties that now come from the base class (`Plate`, `PlateNumber`, `NestDirection`, `WinnerPhase`, `PhaseResults`, `AngleResults`).
|
||||
|
||||
- [ ] **Step 3: Convert the convenience Fill overloads to override the virtual methods**
|
||||
|
||||
Remove the non-progress `Fill` convenience overloads (they are now in the base class). The two remaining `Fill` methods that take `IProgress<NestProgress>` and `CancellationToken` become overrides.
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
|
||||
Remove these methods entirely (now in base class):
|
||||
- `bool Fill(NestItem item)` (2-arg convenience)
|
||||
- `bool Fill(NestItem item, Box workArea)` (convenience that calls the 4-arg)
|
||||
- `bool Fill(List<Part> groupParts)` (convenience)
|
||||
- `bool Fill(List<Part> groupParts, Box workArea)` (convenience that calls the 4-arg)
|
||||
- `FillExact` (now in base class)
|
||||
- `ReportProgress` (now in base class)
|
||||
- `BuildProgressSummary` (now in base class)
|
||||
- `IsBetterFill` (now in base class)
|
||||
- `IsBetterValidFill` (now in base class)
|
||||
- `HasOverlaps` (now in base class)
|
||||
- `FormatPhaseName` (now in base class)
|
||||
|
||||
- [ ] **Step 4: Convert Pack/PackArea to override**
|
||||
|
||||
Remove `Pack(List<NestItem>)` (now in base class).
|
||||
|
||||
Convert `PackArea` to override with the new signature. Replace:
|
||||
|
||||
```csharp
|
||||
public bool Pack(List<NestItem> items)
|
||||
{
|
||||
var workArea = Plate.WorkArea();
|
||||
return PackArea(workArea, items);
|
||||
}
|
||||
|
||||
public bool PackArea(Box box, List<NestItem> items)
|
||||
{
|
||||
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
|
||||
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
|
||||
|
||||
var engine = new PackBottomLeft(bin);
|
||||
engine.Pack(binItems);
|
||||
|
||||
var parts = BinConverter.ToParts(bin, items);
|
||||
Plate.Parts.AddRange(parts);
|
||||
|
||||
return parts.Count > 0;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
|
||||
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
|
||||
|
||||
var engine = new PackBottomLeft(bin);
|
||||
engine.Pack(binItems);
|
||||
|
||||
return BinConverter.ToParts(bin, items);
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `progress` and `token` parameters are not used yet in the default rectangle packing — the contract is there for engines that need them.
|
||||
|
||||
- [ ] **Step 5: Update BruteForceRunner to use DefaultNestEngine**
|
||||
|
||||
`BruteForceRunner.cs` is in the same project and still references `NestEngine`. It must be updated before the Engine project can compile. This is the one callsite that stays as a direct `DefaultNestEngine` reference (not via registry) because training data must come from the known algorithm.
|
||||
|
||||
In `OpenNest.Engine/ML/BruteForceRunner.cs`, change line 30:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```csharp
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded (other projects will have errors since their callsites still reference `NestEngine` — fixed in Chunk 3)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/ML/BruteForceRunner.cs
|
||||
git commit -m "refactor: rename NestEngine to DefaultNestEngine, inherit NestEngineBase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: NestEngineRegistry and NestEngineInfo
|
||||
|
||||
### Task 3: Create NestEngineInfo
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineInfo.cs`
|
||||
|
||||
- [ ] **Step 1: Create NestEngineInfo.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class NestEngineInfo
|
||||
{
|
||||
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
Factory = factory;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public Func<Plate, NestEngineBase> Factory { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineInfo.cs
|
||||
git commit -m "feat: add NestEngineInfo metadata class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create NestEngineRegistry
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineRegistry.cs`
|
||||
|
||||
Static class with built-in registration, plugin loading, active engine selection, and factory creation.
|
||||
|
||||
- [ ] **Step 1: Create NestEngineRegistry.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class NestEngineRegistry
|
||||
{
|
||||
private static readonly List<NestEngineInfo> engines = new();
|
||||
|
||||
static NestEngineRegistry()
|
||||
{
|
||||
Register("Default",
|
||||
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
plate => new DefaultNestEngine(plate));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
|
||||
|
||||
public static string ActiveEngineName { get; set; } = "Default";
|
||||
|
||||
public static NestEngineBase Create(Plate plate)
|
||||
{
|
||||
var info = engines.FirstOrDefault(e =>
|
||||
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
|
||||
info = engines[0];
|
||||
}
|
||||
|
||||
return info.Factory(plate);
|
||||
}
|
||||
|
||||
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(new[] { typeof(Plate) });
|
||||
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a temporary instance to read Name and Description.
|
||||
try
|
||||
{
|
||||
var tempPlate = new Plate();
|
||||
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
|
||||
Register(instance.Name, instance.Description,
|
||||
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
|
||||
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineRegistry.cs
|
||||
git commit -m "feat: add NestEngineRegistry with built-in registration and plugin loading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Callsite Migration
|
||||
|
||||
### Task 5: Migrate OpenNest.Mcp callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
|
||||
|
||||
Six `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`. The `PackArea` call on line 276 changes signature since `PackArea` now returns `List<Part>` instead of mutating the plate.
|
||||
|
||||
- [ ] **Step 1: Replace all NestEngine instantiations**
|
||||
|
||||
In `NestingTools.cs`, replace all six occurrences of `new NestEngine(plate)` with `NestEngineRegistry.Create(plate)`.
|
||||
|
||||
Lines to change:
|
||||
- Line 37: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 73: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 114: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 176: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 255: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 275: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 2: Fix PackArea call in AutoNestPlate**
|
||||
|
||||
The old code on line 276 was:
|
||||
```csharp
|
||||
engine.PackArea(workArea, packItems);
|
||||
```
|
||||
|
||||
This used the old `bool PackArea(Box, List<NestItem>)` which mutated the plate. The new virtual method returns `List<Part>`. Use the convenience `Pack`-like pattern instead. Replace lines 274-277:
|
||||
|
||||
```csharp
|
||||
var before = plate.Parts.Count;
|
||||
var engine = new NestEngine(plate);
|
||||
engine.PackArea(workArea, packItems);
|
||||
totalPlaced += plate.Parts.Count - before;
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
if (packParts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(packParts);
|
||||
totalPlaced += packParts.Count;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build OpenNest.Mcp**
|
||||
|
||||
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "refactor: migrate NestingTools to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Migrate OpenNest.Console callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Console/Program.cs`
|
||||
|
||||
Three `new NestEngine(plate)` calls. The `PackArea` call also needs the same signature update.
|
||||
|
||||
- [ ] **Step 1: Replace NestEngine instantiations**
|
||||
|
||||
In `Program.cs`, replace:
|
||||
- Line 351: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 380: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 2: Fix PackArea call**
|
||||
|
||||
Replace lines 370-372:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
var before = plate.Parts.Count;
|
||||
engine.PackArea(workArea, packItems);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(packParts);
|
||||
```
|
||||
|
||||
And update line 374-375 from:
|
||||
```csharp
|
||||
if (plate.Parts.Count > before)
|
||||
success = true;
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
if (packParts.Count > 0)
|
||||
success = true;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build OpenNest.Console**
|
||||
|
||||
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Console/Program.cs
|
||||
git commit -m "refactor: migrate Console Program to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Migrate OpenNest WinForms callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Actions/ActionFillArea.cs`
|
||||
- Modify: `OpenNest/Controls/PlateView.cs`
|
||||
- Modify: `OpenNest/Forms/MainForm.cs`
|
||||
|
||||
- [ ] **Step 1: Migrate ActionFillArea.cs**
|
||||
|
||||
In `ActionFillArea.cs`, replace both `new NestEngine(plateView.Plate)` calls:
|
||||
- Line 50: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);`
|
||||
- Line 64: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);`
|
||||
|
||||
- [ ] **Step 2: Migrate PlateView.cs**
|
||||
|
||||
In `PlateView.cs`, replace:
|
||||
- Line 836: `var engine = new NestEngine(Plate);` → `var engine = NestEngineRegistry.Create(Plate);`
|
||||
|
||||
- [ ] **Step 3: Migrate MainForm.cs**
|
||||
|
||||
In `MainForm.cs`, replace all three `new NestEngine(plate)` calls:
|
||||
- Line 797: `var engine = new NestEngine(plate) { PlateNumber = plateCount };` → `var engine = NestEngineRegistry.Create(plate); engine.PlateNumber = plateCount;`
|
||||
- Line 829: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 965: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 4: Fix MainForm PackArea call**
|
||||
|
||||
In `MainForm.cs`, the auto-nest pack phase (around line 829-832) uses the old `PackArea` signature. Replace:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
var partsBefore = plate.Parts.Count;
|
||||
engine.PackArea(workArea, packItems);
|
||||
var packed = plate.Parts.Count - partsBefore;
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(packParts);
|
||||
var packed = packParts.Count;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add plugin loading at startup**
|
||||
|
||||
In `MainForm.cs`, find where post-processors are loaded at startup (look for `Posts` directory loading) and add engine plugin loading nearby. Add after the existing plugin loading:
|
||||
|
||||
```csharp
|
||||
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||
```
|
||||
|
||||
If there is no explicit post-processor loading call visible, add this to the `MainForm` constructor or `Load` event.
|
||||
|
||||
- [ ] **Step 6: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Actions/ActionFillArea.cs OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "refactor: migrate WinForms callsites to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Verification and Cleanup
|
||||
|
||||
### Task 8: Verify no remaining NestEngine references
|
||||
|
||||
**Files:**
|
||||
- No changes expected — verification only
|
||||
|
||||
- [ ] **Step 1: Search for stale references**
|
||||
|
||||
Run: `grep -rn "new NestEngine(" --include="*.cs" .`
|
||||
Expected: Only `BruteForceRunner.cs` should have `new DefaultNestEngine(`. No `new NestEngine(` references should remain.
|
||||
|
||||
Also run: `grep -rn "class NestEngine[^B]" --include="*.cs" .`
|
||||
Expected: No matches (the old `class NestEngine` no longer exists).
|
||||
|
||||
- [ ] **Step 2: Build and run smoke test**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors, 0 warnings related to NestEngine
|
||||
|
||||
- [ ] **Step 3: Publish MCP server**
|
||||
|
||||
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
|
||||
Expected: Publish succeeded
|
||||
|
||||
- [ ] **Step 4: Commit if any fixes were needed**
|
||||
|
||||
If any issues were found and fixed in previous steps, commit them now.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update CLAUDE.md architecture documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Update architecture section**
|
||||
|
||||
Update the `### OpenNest.Engine` section in `CLAUDE.md` to document the new engine hierarchy:
|
||||
- `NestEngineBase` is the abstract base class
|
||||
- `DefaultNestEngine` is the current multi-phase engine (formerly `NestEngine`)
|
||||
- `NestEngineRegistry` manages available engines and the active selection
|
||||
- `NestEngineInfo` holds engine metadata
|
||||
- Plugin engines loaded from `Engines/` directory
|
||||
|
||||
Also update any references to `NestEngine` that should now say `DefaultNestEngine` or `NestEngineBase`.
|
||||
|
||||
- [ ] **Step 2: Build to verify no docs broke anything**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md for abstract nest engine architecture"
|
||||
```
|
||||
@@ -1,462 +0,0 @@
|
||||
# FillExact Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `FillExact` method to `NestEngine` that binary-searches for the smallest work area sub-region that fits an exact quantity of parts, then integrate it into AutoNest.
|
||||
|
||||
**Architecture:** `FillExact` wraps the existing `Fill(NestItem, Box, IProgress, CancellationToken)` method. It calls Fill repeatedly with progressively smaller test boxes (binary search on one dimension, both orientations), picks the tightest fit, then re-runs the winner with progress reporting. Callers swap `Fill` for `FillExact` — no other engine changes needed.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Console, OpenNest.Mcp
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-fill-exact-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Core Implementation
|
||||
|
||||
### Task 1: Add `BinarySearchFill` helper to NestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs` (add private method after the existing `Fill` overloads, around line 85)
|
||||
|
||||
- [ ] **Step 1: Add the BinarySearchFill method**
|
||||
|
||||
Add after the `Fill(NestItem, Box, IProgress, CancellationToken)` method (line 85):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Binary-searches for the smallest sub-area (one dimension fixed) that fits
|
||||
/// exactly item.Quantity parts. Returns the best parts list and the dimension
|
||||
/// value that achieved it.
|
||||
/// </summary>
|
||||
private (List<Part> parts, double usedDim) BinarySearchFill(
|
||||
NestItem item, Box workArea, bool shrinkWidth,
|
||||
CancellationToken token)
|
||||
{
|
||||
var quantity = item.Quantity;
|
||||
var partBox = item.Drawing.Program.BoundingBox();
|
||||
var partArea = item.Drawing.Area;
|
||||
|
||||
// Fixed and variable dimensions.
|
||||
var fixedDim = shrinkWidth ? workArea.Length : workArea.Width;
|
||||
var highDim = shrinkWidth ? workArea.Width : workArea.Length;
|
||||
|
||||
// Estimate starting point: target area at 50% utilization.
|
||||
var targetArea = partArea * quantity / 0.5;
|
||||
var minPartDim = shrinkWidth
|
||||
? partBox.Width + Plate.PartSpacing
|
||||
: partBox.Length + Plate.PartSpacing;
|
||||
var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim);
|
||||
|
||||
var low = estimatedDim;
|
||||
var high = highDim;
|
||||
|
||||
List<Part> bestParts = null;
|
||||
var bestDim = high;
|
||||
|
||||
for (var iter = 0; iter < 8; iter++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (high - low < Plate.PartSpacing)
|
||||
break;
|
||||
|
||||
var mid = (low + high) / 2.0;
|
||||
|
||||
var testBox = shrinkWidth
|
||||
? new Box(workArea.X, workArea.Y, mid, workArea.Length)
|
||||
: new Box(workArea.X, workArea.Y, workArea.Width, mid);
|
||||
|
||||
var result = Fill(item, testBox, null, token);
|
||||
|
||||
if (result.Count >= quantity)
|
||||
{
|
||||
bestParts = result.Count > quantity
|
||||
? result.Take(quantity).ToList()
|
||||
: result;
|
||||
bestDim = mid;
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return (bestParts, bestDim);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat(engine): add BinarySearchFill helper for exact-quantity search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `FillExact` public method to NestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs` (add public method after the existing `Fill` overloads, before `BinarySearchFill`)
|
||||
|
||||
- [ ] **Step 1: Add the FillExact method**
|
||||
|
||||
Add between the `Fill(NestItem, Box, IProgress, CancellationToken)` method and `BinarySearchFill`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts.
|
||||
/// Uses binary search on both orientations and picks the tightest fit.
|
||||
/// Falls through to standard Fill for unlimited (0) or single (1) quantities.
|
||||
/// </summary>
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
// Early exits: unlimited or single quantity — no benefit from area search.
|
||||
if (item.Quantity <= 1)
|
||||
return Fill(item, workArea, progress, token);
|
||||
|
||||
// Full fill to establish upper bound.
|
||||
var fullResult = Fill(item, workArea, progress, token);
|
||||
|
||||
if (fullResult.Count <= item.Quantity)
|
||||
return fullResult;
|
||||
|
||||
// Binary search: try shrinking each dimension.
|
||||
var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token);
|
||||
var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token);
|
||||
|
||||
// Pick winner by smallest test box area. Tie-break: prefer shrink-length.
|
||||
List<Part> winner;
|
||||
Box winnerBox;
|
||||
|
||||
var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue;
|
||||
var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue;
|
||||
|
||||
if (lengthParts != null && lengthArea <= widthArea)
|
||||
{
|
||||
winner = lengthParts;
|
||||
winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim);
|
||||
}
|
||||
else if (widthParts != null)
|
||||
{
|
||||
winner = widthParts;
|
||||
winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither search found the exact quantity — return full fill truncated.
|
||||
return fullResult.Take(item.Quantity).ToList();
|
||||
}
|
||||
|
||||
// Re-run the winner with progress so PhaseResults/WinnerPhase are correct
|
||||
// and the progress form shows the final result.
|
||||
var finalResult = Fill(item, winnerBox, progress, token);
|
||||
|
||||
if (finalResult.Count >= item.Quantity)
|
||||
return finalResult.Count > item.Quantity
|
||||
? finalResult.Take(item.Quantity).ToList()
|
||||
: finalResult;
|
||||
|
||||
// Fallback: return the binary search result if the re-run produced fewer.
|
||||
return winner;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat(engine): add FillExact method for exact-quantity nesting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Compactor class to Engine
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Compactor.cs`
|
||||
|
||||
- [ ] **Step 1: Create the Compactor class**
|
||||
|
||||
Create `OpenNest.Engine/Compactor.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a group of parts left and down to close gaps after placement.
|
||||
/// Uses the same directional-distance logic as PlateView.PushSelected
|
||||
/// but operates on Part objects directly.
|
||||
/// </summary>
|
||||
public static class Compactor
|
||||
{
|
||||
private const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Compacts movingParts toward the bottom-left of the plate work area.
|
||||
/// Everything already on the plate (excluding movingParts) is treated
|
||||
/// as stationary obstacles.
|
||||
/// </summary>
|
||||
public static void Compact(List<Part> movingParts, Plate plate)
|
||||
{
|
||||
if (movingParts == null || movingParts.Count == 0)
|
||||
return;
|
||||
|
||||
Push(movingParts, plate, PushDirection.Left);
|
||||
Push(movingParts, plate, PushDirection.Down);
|
||||
}
|
||||
|
||||
private static void Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||
{
|
||||
var stationaryParts = plate.Parts
|
||||
.Where(p => !movingParts.Contains(p))
|
||||
.ToList();
|
||||
|
||||
var stationaryBoxes = new Box[stationaryParts.Count];
|
||||
|
||||
for (var i = 0; i < stationaryParts.Count; i++)
|
||||
stationaryBoxes[i] = stationaryParts[i].BoundingBox;
|
||||
|
||||
var stationaryLines = new List<Line>[stationaryParts.Count];
|
||||
var opposite = Helper.OppositeDirection(direction);
|
||||
var halfSpacing = plate.PartSpacing / 2;
|
||||
var isHorizontal = Helper.IsHorizontalDirection(direction);
|
||||
var workArea = plate.WorkArea();
|
||||
|
||||
foreach (var moving in movingParts)
|
||||
{
|
||||
var distance = double.MaxValue;
|
||||
var movingBox = moving.BoundingBox;
|
||||
|
||||
// Plate edge distance.
|
||||
var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction);
|
||||
if (edgeDist > 0 && edgeDist < distance)
|
||||
distance = edgeDist;
|
||||
|
||||
List<Line> movingLines = null;
|
||||
|
||||
for (var i = 0; i < stationaryBoxes.Length; i++)
|
||||
{
|
||||
var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction);
|
||||
if (gap < 0 || gap >= distance)
|
||||
continue;
|
||||
|
||||
var perpOverlap = isHorizontal
|
||||
? movingBox.IsHorizontalTo(stationaryBoxes[i], out _)
|
||||
: movingBox.IsVerticalTo(stationaryBoxes[i], out _);
|
||||
|
||||
if (!perpOverlap)
|
||||
continue;
|
||||
|
||||
movingLines ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||
: Helper.GetPartLines(moving, direction, ChordTolerance);
|
||||
|
||||
stationaryLines[i] ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance)
|
||||
: Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance);
|
||||
|
||||
var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction);
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
}
|
||||
|
||||
if (distance < double.MaxValue && distance > 0)
|
||||
{
|
||||
var offset = Helper.DirectionToOffset(direction, distance);
|
||||
moving.Offset(offset);
|
||||
|
||||
// Update this part's bounding box in the stationary set for
|
||||
// subsequent moving parts to collide against correctly.
|
||||
// (Parts already pushed become obstacles for the next part.)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Compactor.cs
|
||||
git commit -m "feat(engine): add Compactor for post-fill gravity compaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Integration
|
||||
|
||||
### Task 4: Integrate FillExact and Compactor into AutoNest (MainForm)
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.cs` (RunAutoNest_Click, around lines 797-815)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
In `RunAutoNest_Click`, change the Fill call and the block after it (around lines 799-815). Replace:
|
||||
|
||||
```csharp
|
||||
var parts = await Task.Run(() =>
|
||||
engine.Fill(item, workArea, progress, token));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
var parts = await Task.Run(() =>
|
||||
engine.FillExact(item, workArea, progress, token));
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` and before `ComputeRemainderStrip`, add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
activeForm.PlateView.Invalidate();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.sln --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat(ui): use FillExact + Compactor in AutoNest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integrate FillExact and Compactor into Console app
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Console/Program.cs` (around lines 346-360)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
Change the Fill call (around line 352) from:
|
||||
|
||||
```csharp
|
||||
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Console/Program.cs
|
||||
git commit -m "feat(console): use FillExact + Compactor in --autonest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integrate FillExact and Compactor into MCP server
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` (around lines 255-264)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
Change the Fill call (around line 256) from:
|
||||
|
||||
```csharp
|
||||
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "feat(mcp): use FillExact in autonest_plate for tighter packing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Verification
|
||||
|
||||
### Task 7: End-to-end test via Console
|
||||
|
||||
- [ ] **Step 1: Run AutoNest with qty > 1 and verify tighter packing**
|
||||
|
||||
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --quantity 10 --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
|
||||
|
||||
Verify:
|
||||
- Completes without error
|
||||
- Parts placed count is reasonable (not 0, not wildly over-placed)
|
||||
- Utilization is reported
|
||||
|
||||
- [ ] **Step 2: Run with qty=1 to verify fallback path**
|
||||
|
||||
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
|
||||
|
||||
Verify:
|
||||
- Completes quickly (qty=1 goes through Pack, no binary search)
|
||||
- Parts placed > 0
|
||||
|
||||
- [ ] **Step 3: Build full solution one final time**
|
||||
|
||||
Run: `dotnet build OpenNest.sln --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
@@ -1,350 +0,0 @@
|
||||
# Helper Class Decomposition
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes.
|
||||
|
||||
**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring.
|
||||
|
||||
**Tech Stack:** .NET 8, C# 12
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| New File | Namespace | Responsibility | Methods Moved |
|
||||
|----------|-----------|----------------|---------------|
|
||||
| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` |
|
||||
| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` |
|
||||
| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` |
|
||||
| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads |
|
||||
| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` |
|
||||
| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) |
|
||||
|
||||
**Files modified (call-site updates):**
|
||||
|
||||
| File | Methods Referenced |
|
||||
|------|--------------------|
|
||||
| `OpenNest.Core/Plate.cs` | `RoundUpToNearest` → `Rounding.RoundUpToNearest` |
|
||||
| `OpenNest.IO/DxfImporter.cs` | `Optimize` → `GeometryOptimizer.Optimize` |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | `Optimize` → `GeometryOptimizer.Optimize`, `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Drawing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Timing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Circle.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Line.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest/LayoutPart.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection` → `SpatialQuery.*` |
|
||||
| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder
|
||||
|
||||
### Task 1: Extract Rounding to OpenNest.Math
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Math/Rounding.cs`
|
||||
- Modify: `OpenNest.Core/Plate.cs:415-416`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 14–45)
|
||||
|
||||
- [ ] **Step 1: Create `Rounding.cs`**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
public static class Rounding
|
||||
{
|
||||
public static double RoundDownToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundUpToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update call site in `Plate.cs`**
|
||||
|
||||
Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present.
|
||||
|
||||
- [ ] **Step 3: Remove three rounding methods from `Helper.cs`**
|
||||
|
||||
Delete lines 14–45 (the three methods and their XML doc comments).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Rounding from Helper to OpenNest.Math
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract GeometryOptimizer
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs`
|
||||
- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 47–237)
|
||||
|
||||
- [ ] **Step 1: Create `GeometryOptimizer.cs`**
|
||||
|
||||
Move these 6 methods (preserving exact code):
|
||||
- `Optimize(IList<Arc>)`
|
||||
- `Optimize(IList<Line>)`
|
||||
- `TryJoinLines`
|
||||
- `TryJoinArcs`
|
||||
- `GetCollinearLines` (private extension method)
|
||||
- `GetCoradialArs` (private extension method)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `DxfImporter.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`.
|
||||
- `Shape.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed.
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract GeometryOptimizer from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract ShapeBuilder
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs`
|
||||
- Modify: 11 files (see call-site table above for `GetShapes` callers)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 239–378)
|
||||
|
||||
- [ ] **Step 1: Create `ShapeBuilder.cs`**
|
||||
|
||||
Move these 2 methods:
|
||||
- `GetShapes(IEnumerable<Entity>)` — public
|
||||
- `GetConnected(Vector, IEnumerable<Entity>)` — internal
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update all call sites**
|
||||
|
||||
Replace `Helper.GetShapes` → `ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace.
|
||||
|
||||
Files to update:
|
||||
- `OpenNest.Core/Drawing.cs`
|
||||
- `OpenNest.Core/Timing.cs`
|
||||
- `OpenNest.Core/Converters/ConvertGeometry.cs`
|
||||
- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace)
|
||||
- `OpenNest/LayoutPart.cs`
|
||||
- `OpenNest/Actions/ActionSetSequence.cs`
|
||||
- `OpenNest.Gpu/PartBitmap.cs`
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs`
|
||||
- `OpenNest.Engine/RotationAnalysis.cs`
|
||||
- `OpenNest.Engine/BestFit/BestFitFinder.cs`
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract ShapeBuilder from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Intersect + PartGeometry
|
||||
|
||||
### Task 4: Extract Intersect
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/Intersect.cs`
|
||||
- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 380–742)
|
||||
|
||||
- [ ] **Step 1: Create `Intersect.cs`**
|
||||
|
||||
Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`.
|
||||
|
||||
All methods keep their existing access modifiers (`internal` for most, none are `public`).
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites in geometry types**
|
||||
|
||||
All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects` → `Intersect.Intersects` in:
|
||||
- `Arc.cs` (10 calls)
|
||||
- `Circle.cs` (10 calls)
|
||||
- `Line.cs` (8 calls)
|
||||
- `Shape.cs` (12 calls, including the internal offset usage at line 537)
|
||||
- `Polygon.cs` (10 calls)
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Intersect from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extract PartGeometry
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/PartGeometry.cs`
|
||||
- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 744–858)
|
||||
|
||||
- [ ] **Step 1: Create `PartGeometry.cs`**
|
||||
|
||||
Move these 5 methods:
|
||||
- `GetPartLines(Part, double)` — public
|
||||
- `GetPartLines(Part, PushDirection, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, PushDirection, double)` — public
|
||||
- `GetDirectionalLines(Polygon, PushDirection)` — private
|
||||
|
||||
Namespace: `OpenNest`. Class: `public static class PartGeometry`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines` → `PartGeometry.*`
|
||||
- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines` → `PartGeometry.GetOffsetPartLines`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract PartGeometry from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: SpatialQuery + Cleanup
|
||||
|
||||
### Task 6: Extract SpatialQuery
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/SpatialQuery.cs`
|
||||
- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 860–1462, all remaining methods)
|
||||
|
||||
- [ ] **Step 1: Create `SpatialQuery.cs`**
|
||||
|
||||
Move all remaining methods (14 total):
|
||||
- `RayEdgeDistance(Vector, Line, PushDirection)` — private
|
||||
- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]`
|
||||
- `DirectionalDistance(List<Line>, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance(List<Line>, double, double, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `FlattenLines(List<Line>)` — public
|
||||
- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `OppositeDirection(PushDirection)` — public
|
||||
- `IsHorizontalDirection(PushDirection)` — public
|
||||
- `EdgeDistance(Box, Box, PushDirection)` — public
|
||||
- `DirectionToOffset(PushDirection, double)` — public
|
||||
- `DirectionalGap(Box, Box, PushDirection)` — public
|
||||
- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods)
|
||||
- `GetLargestBoxVertically/Horizontally` — public (2 methods)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
Replace `Helper.*` → `SpatialQuery.*` and add `using OpenNest.Geometry;` where needed:
|
||||
- `OpenNest.Engine/Compactor.cs` — `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset`
|
||||
- `OpenNest.Engine/FillLinear.cs` — `DirectionalDistance`, `OppositeDirection`
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — `FlattenLines`, `OppositeDirection`, `OneWayDistance`
|
||||
- `OpenNest/Actions/ActionClone.cs` — `GetLargestBoxVertically`, `GetLargestBoxHorizontally`
|
||||
- `OpenNest/Actions/ActionSelectArea.cs` — `GetLargestBoxHorizontally`, `GetLargestBoxVertically`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
At this point `Helper.cs` should be empty (just the class wrapper and usings).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract SpatialQuery from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Delete Helper.cs
|
||||
|
||||
**Files:**
|
||||
- Delete: `OpenNest.Core/Helper.cs`
|
||||
|
||||
- [ ] **Step 1: Delete the empty `Helper.cs` file**
|
||||
|
||||
- [ ] **Step 2: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with zero errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
refactor: remove empty Helper class
|
||||
```
|
||||
@@ -1,588 +0,0 @@
|
||||
# Strip Nester Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a strip-based multi-drawing nesting strategy as a `NestEngineBase` subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings.
|
||||
|
||||
**Architecture:** `StripNestEngine` extends `NestEngineBase`, uses `DefaultNestEngine` internally (composition) for individual fills. Registered in `NestEngineRegistry`. For single-item fills, delegates to `DefaultNestEngine`. For multi-drawing nesting, orchestrates the strip+remnant strategy. The MCP `autonest_plate` tool always runs `StripNestEngine` as a competitor alongside the current sequential approach, picking the denser result.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md`
|
||||
|
||||
**Depends on:** `docs/superpowers/plans/2026-03-15-abstract-nest-engine.md` (must be implemented first — provides `NestEngineBase`, `DefaultNestEngine`, `NestEngineRegistry`)
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Core StripNestEngine
|
||||
|
||||
### Task 1: Create StripDirection enum
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripDirection.cs`
|
||||
|
||||
- [ ] **Step 1: Create the enum file**
|
||||
|
||||
```csharp
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum StripDirection
|
||||
{
|
||||
Bottom,
|
||||
Left
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripDirection.cs
|
||||
git commit -m "feat: add StripDirection enum"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create StripNestResult internal class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripNestResult.cs`
|
||||
|
||||
- [ ] **Step 1: Create the result class**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal class StripNestResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public Box StripBox { get; set; }
|
||||
public Box RemnantBox { get; set; }
|
||||
public FillScore Score { get; set; }
|
||||
public StripDirection Direction { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestResult.cs
|
||||
git commit -m "feat: add StripNestResult internal class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create StripNestEngine — class skeleton with selection and estimation helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
This task creates the class extending `NestEngineBase`, with `Name`/`Description` overrides, the single-item `Fill` override that delegates to `DefaultNestEngine`, and the helper methods for strip item selection and dimension estimation. The main `Nest` method is added in the next task.
|
||||
|
||||
- [ ] **Step 1: Create StripNestEngine with skeleton and helpers**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class StripNestEngine : NestEngineBase
|
||||
{
|
||||
private const int MaxShrinkIterations = 20;
|
||||
|
||||
public StripNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Strip-based nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the item that consumes the most plate area (bounding box area x quantity).
|
||||
/// Returns the index into the items list.
|
||||
/// </summary>
|
||||
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
|
||||
{
|
||||
var bestIndex = 0;
|
||||
var bestArea = 0.0;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var bbox = items[i].Drawing.Program.BoundingBox();
|
||||
var qty = items[i].Quantity > 0
|
||||
? items[i].Quantity
|
||||
: (int)(workArea.Area() / bbox.Area());
|
||||
var totalArea = bbox.Area() * qty;
|
||||
|
||||
if (totalArea > bestArea)
|
||||
{
|
||||
bestArea = totalArea;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the strip dimension (height for bottom, width for left) needed
|
||||
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
|
||||
/// This is only an estimate for the shrink loop starting point — the actual fill
|
||||
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
|
||||
/// </summary>
|
||||
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
var qty = item.Quantity > 0
|
||||
? item.Quantity
|
||||
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
|
||||
|
||||
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
|
||||
var perRow0 = (int)(stripLength / bbox.Width);
|
||||
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
|
||||
var dim0 = rows0 * bbox.Length;
|
||||
|
||||
// At 90 deg: rotated bounding box (Width and Length swap)
|
||||
var perRow90 = (int)(stripLength / bbox.Length);
|
||||
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
|
||||
var dim90 = rows90 * bbox.Width;
|
||||
|
||||
var estimate = System.Math.Min(dim0, dim90);
|
||||
|
||||
// Clamp to available dimension
|
||||
return System.Math.Min(estimate, maxDimension);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat: add StripNestEngine skeleton with Fill delegate and estimation helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add the Nest method and TryOrientation
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
This is the main multi-drawing algorithm: tries both orientations, fills strip + remnant, compares results. Uses `DefaultNestEngine` internally for all fill operations (composition pattern per the abstract engine spec).
|
||||
|
||||
Key detail: The remnant fill shrinks the remnant box after each item fill using `ComputeRemainderWithin` to prevent overlapping placements.
|
||||
|
||||
- [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods**
|
||||
|
||||
Add these methods to the `StripNestEngine` class, after the `EstimateStripDimension` method:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Multi-drawing strip nesting strategy.
|
||||
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
|
||||
/// in both bottom and left orientations, fills remnants with remaining drawings,
|
||||
/// and returns the denser result.
|
||||
/// </summary>
|
||||
public List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Select which item gets the strip treatment.
|
||||
var stripIndex = SelectStripItemIndex(items, workArea);
|
||||
var stripItem = items[stripIndex];
|
||||
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
|
||||
|
||||
// Try both orientations.
|
||||
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token);
|
||||
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token);
|
||||
|
||||
// Pick the better result.
|
||||
if (bottomResult.Score >= leftResult.Score)
|
||||
return bottomResult.Parts;
|
||||
|
||||
return leftResult.Parts;
|
||||
}
|
||||
|
||||
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
|
||||
List<NestItem> remainderItems, Box workArea, CancellationToken token)
|
||||
{
|
||||
var result = new StripNestResult { Direction = direction };
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return result;
|
||||
|
||||
// Estimate initial strip dimension.
|
||||
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
|
||||
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
|
||||
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
|
||||
|
||||
// Create the initial strip box.
|
||||
var stripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
|
||||
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
|
||||
|
||||
// Initial fill using DefaultNestEngine (composition, not inheritance).
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var stripParts = inner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
stripBox, null, token);
|
||||
|
||||
if (stripParts == null || stripParts.Count == 0)
|
||||
return result;
|
||||
|
||||
// Measure actual strip dimension from placed parts.
|
||||
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
|
||||
var actualDim = direction == StripDirection.Bottom
|
||||
? placedBox.Top - workArea.Y
|
||||
: placedBox.Right - workArea.X;
|
||||
|
||||
var bestParts = stripParts;
|
||||
var bestDim = actualDim;
|
||||
var targetCount = stripParts.Count;
|
||||
|
||||
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
|
||||
for (var i = 0; i < MaxShrinkIterations; i++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var trialDim = bestDim - Plate.PartSpacing;
|
||||
if (trialDim <= 0)
|
||||
break;
|
||||
|
||||
var trialBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
|
||||
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
|
||||
|
||||
var trialInner = new DefaultNestEngine(Plate);
|
||||
var trialParts = trialInner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
trialBox, null, token);
|
||||
|
||||
if (trialParts == null || trialParts.Count < targetCount)
|
||||
break;
|
||||
|
||||
// Same count in a tighter strip — keep going.
|
||||
bestParts = trialParts;
|
||||
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
|
||||
bestDim = direction == StripDirection.Bottom
|
||||
? trialPlacedBox.Top - workArea.Y
|
||||
: trialPlacedBox.Right - workArea.X;
|
||||
}
|
||||
|
||||
// Build remnant box with spacing gap.
|
||||
var spacing = Plate.PartSpacing;
|
||||
var remnantBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y + bestDim + spacing,
|
||||
workArea.Width, workArea.Length - bestDim - spacing)
|
||||
: new Box(workArea.X + bestDim + spacing, workArea.Y,
|
||||
workArea.Width - bestDim - spacing, workArea.Length);
|
||||
|
||||
// Collect all parts.
|
||||
var allParts = new List<Part>(bestParts);
|
||||
|
||||
// If strip item was only partially placed, add leftovers to remainder.
|
||||
var placed = bestParts.Count;
|
||||
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
|
||||
var effectiveRemainder = new List<NestItem>(remainderItems);
|
||||
|
||||
if (leftover > 0)
|
||||
{
|
||||
effectiveRemainder.Add(new NestItem
|
||||
{
|
||||
Drawing = stripItem.Drawing,
|
||||
Quantity = leftover
|
||||
});
|
||||
}
|
||||
|
||||
// Sort remainder by descending bounding box area x quantity.
|
||||
effectiveRemainder = effectiveRemainder
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Fill remnant with remainder items, shrinking the available area after each.
|
||||
if (remnantBox.Width > 0 && remnantBox.Length > 0)
|
||||
{
|
||||
var currentRemnant = remnantBox;
|
||||
|
||||
foreach (var item in effectiveRemainder)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0)
|
||||
break;
|
||||
|
||||
var remnantInner = new DefaultNestEngine(Plate);
|
||||
var remnantParts = remnantInner.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
currentRemnant, null, token);
|
||||
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(remnantParts);
|
||||
|
||||
// Shrink remnant to avoid overlap with next item.
|
||||
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
|
||||
currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Parts = allParts;
|
||||
result.StripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
|
||||
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
|
||||
result.RemnantBox = remnantBox;
|
||||
result.Score = FillScore.Compute(allParts, workArea);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the largest usable remainder within a work area after a portion has been used.
|
||||
/// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above.
|
||||
/// </summary>
|
||||
private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
|
||||
{
|
||||
var hWidth = workArea.Right - usedBox.Right - spacing;
|
||||
var hStrip = hWidth > 0
|
||||
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
|
||||
: Box.Empty;
|
||||
|
||||
var vHeight = workArea.Top - usedBox.Top - spacing;
|
||||
var vStrip = vHeight > 0
|
||||
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
|
||||
: Box.Empty;
|
||||
|
||||
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat: add StripNestEngine.Nest with strip fill, shrink loop, and remnant fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Register StripNestEngine in NestEngineRegistry
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineRegistry.cs`
|
||||
|
||||
- [ ] **Step 1: Add Strip registration**
|
||||
|
||||
In `NestEngineRegistry.cs`, add the strip engine registration in the static constructor, after the Default registration:
|
||||
|
||||
```csharp
|
||||
Register("Strip",
|
||||
"Strip-based nesting for mixed-drawing layouts",
|
||||
plate => new StripNestEngine(plate));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineRegistry.cs
|
||||
git commit -m "feat: register StripNestEngine in NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: MCP Integration
|
||||
|
||||
### Task 6: Integrate StripNestEngine into autonest_plate MCP tool
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
|
||||
|
||||
Run the strip nester alongside the existing sequential approach. Both use side-effect-free fills (4-arg `Fill` returning `List<Part>`), then the winner's parts are added to the plate.
|
||||
|
||||
Note: After the abstract engine migration, callsites already use `NestEngineRegistry.Create(plate)`. The `autonest_plate` tool creates a `StripNestEngine` directly for the strip strategy competition (it's always tried, regardless of active engine selection).
|
||||
|
||||
- [ ] **Step 1: Refactor AutoNestPlate to run both strategies**
|
||||
|
||||
In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (the section after the items list is built) with a strategy competition.
|
||||
|
||||
Replace the fill/pack logic with:
|
||||
|
||||
```csharp
|
||||
// Strategy 1: Strip nesting
|
||||
var stripEngine = new StripNestEngine(plate);
|
||||
var stripResult = stripEngine.Nest(items, null, CancellationToken.None);
|
||||
var stripScore = FillScore.Compute(stripResult, plate.WorkArea());
|
||||
|
||||
// Strategy 2: Current sequential fill
|
||||
var seqResult = SequentialFill(plate, items);
|
||||
var seqScore = FillScore.Compute(seqResult, plate.WorkArea());
|
||||
|
||||
// Pick winner and apply to plate.
|
||||
var winner = stripScore >= seqScore ? stripResult : seqResult;
|
||||
var winnerName = stripScore >= seqScore ? "strip" : "sequential";
|
||||
plate.Parts.AddRange(winner);
|
||||
var totalPlaced = winner.Count;
|
||||
```
|
||||
|
||||
Update the output section:
|
||||
|
||||
```csharp
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}");
|
||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||
sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}");
|
||||
sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}");
|
||||
|
||||
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
|
||||
foreach (var group in groups)
|
||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
||||
|
||||
return sb.ToString();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the SequentialFill helper method**
|
||||
|
||||
Add this private method to `NestingTools`. It mirrors the existing sequential fill phase using side-effect-free fills.
|
||||
|
||||
```csharp
|
||||
private static List<Part> SequentialFill(Plate plate, List<NestItem> items)
|
||||
{
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
var workArea = plate.WorkArea();
|
||||
var allParts = new List<Part>();
|
||||
|
||||
foreach (var item in fillItems)
|
||||
{
|
||||
if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0)
|
||||
continue;
|
||||
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var parts = engine.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(parts);
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add required using statement**
|
||||
|
||||
Add `using System.Threading;` to the top of `NestingTools.cs` if not already present.
|
||||
|
||||
- [ ] **Step 4: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "feat: integrate StripNestEngine into autonest_plate MCP tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Publish and Test
|
||||
|
||||
### Task 7: Publish MCP server and test with real parts
|
||||
|
||||
**Files:**
|
||||
- No code changes — publish and manual testing
|
||||
|
||||
- [ ] **Step 1: Publish OpenNest.Mcp**
|
||||
|
||||
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
|
||||
Expected: Build and publish succeeded
|
||||
|
||||
- [ ] **Step 2: Test with SULLYS parts**
|
||||
|
||||
Using the MCP tools, test the strip nester with the SULLYS-001 and SULLYS-002 parts:
|
||||
|
||||
1. Load the test nest file or import the DXF files
|
||||
2. Create a 60x120 plate
|
||||
3. Run `autonest_plate` with both drawings at qty 10
|
||||
4. Verify the output reports which strategy won (strip vs sequential)
|
||||
5. Verify the output shows scores for both strategies
|
||||
6. Check plate info for part placement and utilization
|
||||
|
||||
- [ ] **Step 3: Compare with current results**
|
||||
|
||||
Verify the strip nester produces a result matching or improving on the target layout from screenshot 190519 (all 20 parts on one 60x120 plate with organized strip arrangement).
|
||||
|
||||
- [ ] **Step 4: Commit any fixes**
|
||||
|
||||
If issues are found during testing, fix and commit with descriptive messages.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,570 +0,0 @@
|
||||
# Polylabel Part Label Positioning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Position part ID labels at the visual center of each part using the polylabel (pole of inaccessibility) algorithm, so labels are readable and don't overlap adjacent parts.
|
||||
|
||||
**Architecture:** Add a `PolyLabel` static class in `OpenNest.Geometry` that finds the point inside a polygon farthest from all edges (including holes). `LayoutPart` caches this point in program-local coordinates and transforms it each frame for rendering.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, WinForms (System.Drawing)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Polylabel Algorithm
|
||||
|
||||
### Task 1: PolyLabel — square polygon test + implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/PolyLabel.cs`
|
||||
- Create: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for a square polygon**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Tests/PolyLabelTests.cs
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PolyLabelTests
|
||||
{
|
||||
private static Polygon Square(double size)
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(size, 0));
|
||||
p.Vertices.Add(new Vector(size, size));
|
||||
p.Vertices.Add(new Vector(0, size));
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Square_ReturnsCenterPoint()
|
||||
{
|
||||
var poly = Square(100);
|
||||
|
||||
var result = PolyLabel.Find(poly);
|
||||
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(50, result.Y, 1.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: FAIL — `PolyLabel` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement PolyLabel.Find**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Core/Geometry/PolyLabel.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5)
|
||||
{
|
||||
if (outer.Vertices.Count < 3)
|
||||
return outer.Vertices.Count > 0
|
||||
? outer.Vertices[0]
|
||||
: new Vector();
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
for (var i = 0; i < outer.Vertices.Count; i++)
|
||||
{
|
||||
var v = outer.Vertices[i];
|
||||
if (v.X < minX) minX = v.X;
|
||||
if (v.Y < minY) minY = v.Y;
|
||||
if (v.X > maxX) maxX = v.X;
|
||||
if (v.Y > maxY) maxY = v.Y;
|
||||
}
|
||||
|
||||
var width = maxX - minX;
|
||||
var height = maxY - minY;
|
||||
var cellSize = System.Math.Min(width, height);
|
||||
|
||||
if (cellSize == 0)
|
||||
return new Vector((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
|
||||
var halfCell = cellSize / 2;
|
||||
|
||||
var queue = new List<Cell>();
|
||||
|
||||
for (var x = minX; x < maxX; x += cellSize)
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
|
||||
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
|
||||
var bestCell = GetCentroidCell(outer, holes);
|
||||
|
||||
for (var i = 0; i < queue.Count; i++)
|
||||
if (queue[i].Dist > bestCell.Dist)
|
||||
{
|
||||
bestCell = queue[i];
|
||||
break;
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var cell = queue[0];
|
||||
queue.RemoveAt(0);
|
||||
|
||||
if (cell.Dist > bestCell.Dist)
|
||||
bestCell = cell;
|
||||
|
||||
if (cell.MaxDist - bestCell.Dist <= precision)
|
||||
continue;
|
||||
|
||||
halfCell = cell.HalfSize / 2;
|
||||
|
||||
var newCells = new[]
|
||||
{
|
||||
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
};
|
||||
|
||||
for (var i = 0; i < newCells.Length; i++)
|
||||
{
|
||||
if (newCells[i].MaxDist > bestCell.Dist + precision)
|
||||
InsertSorted(queue, newCells[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(bestCell.X, bestCell.Y);
|
||||
}
|
||||
|
||||
private static void InsertSorted(List<Cell> list, Cell cell)
|
||||
{
|
||||
var idx = 0;
|
||||
while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
|
||||
idx++;
|
||||
list.Insert(idx, cell);
|
||||
}
|
||||
|
||||
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var area = 0.0;
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
var verts = outer.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
var cross = a.X * b.Y - b.X * a.Y;
|
||||
cx += (a.X + b.X) * cross;
|
||||
cy += (a.Y + b.Y) * cross;
|
||||
area += cross;
|
||||
}
|
||||
|
||||
area *= 0.5;
|
||||
|
||||
if (System.Math.Abs(area) < 1e-10)
|
||||
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes);
|
||||
|
||||
cx /= (6 * area);
|
||||
cy /= (6 * area);
|
||||
|
||||
return new Cell(cx, cy, 0, outer, holes);
|
||||
}
|
||||
|
||||
private static double PointToPolygonDist(double x, double y, Polygon polygon)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var verts = polygon.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
|
||||
if (dx != 0 || dy != 0)
|
||||
{
|
||||
var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy);
|
||||
|
||||
if (t > 1)
|
||||
{
|
||||
a = b;
|
||||
}
|
||||
else if (t > 0)
|
||||
{
|
||||
a = new Vector(a.X + dx * t, a.Y + dy * t);
|
||||
}
|
||||
}
|
||||
|
||||
var segDx = x - a.X;
|
||||
var segDy = y - a.Y;
|
||||
var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy);
|
||||
|
||||
if (dist < minDist)
|
||||
minDist = dist;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private sealed class Cell
|
||||
{
|
||||
public readonly double X;
|
||||
public readonly double Y;
|
||||
public readonly double HalfSize;
|
||||
public readonly double Dist;
|
||||
public readonly double MaxDist;
|
||||
|
||||
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
HalfSize = halfSize;
|
||||
|
||||
var pt = new Vector(x, y);
|
||||
var inside = outer.ContainsPoint(pt);
|
||||
|
||||
if (inside && holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
if (holes[i].ContainsPoint(pt))
|
||||
{
|
||||
inside = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dist = PointToAllEdgesDist(x, y, outer, holes);
|
||||
|
||||
if (!inside)
|
||||
Dist = -Dist;
|
||||
|
||||
MaxDist = Dist + HalfSize * System.Math.Sqrt(2);
|
||||
}
|
||||
}
|
||||
|
||||
private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var minDist = PointToPolygonDist(x, y, outer);
|
||||
|
||||
if (holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
var d = PointToPolygonDist(x, y, holes[i]);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/PolyLabel.cs OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "feat(geometry): add PolyLabel algorithm with square test"
|
||||
```
|
||||
|
||||
### Task 2: PolyLabel — additional shape tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add tests for L-shape, triangle, thin rectangle, C-shape, hole, and degenerate**
|
||||
|
||||
```csharp
|
||||
// Append to PolyLabelTests.cs
|
||||
|
||||
[Fact]
|
||||
public void Triangle_ReturnsIncenter()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(50, 86.6));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
// Incenter of equilateral triangle is at (50, ~28.9)
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(28.9, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LShape_ReturnsPointInBottomLobe()
|
||||
{
|
||||
// L-shape: 100x100 with 50x50 cut from top-right
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 50));
|
||||
p.Vertices.Add(new Vector(50, 50));
|
||||
p.Vertices.Add(new Vector(50, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// The bottom 100x50 lobe is the widest region
|
||||
Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThinRectangle_CenteredOnBothAxes()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(200, 0));
|
||||
p.Vertices.Add(new Vector(200, 10));
|
||||
p.Vertices.Add(new Vector(0, 10));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(100, result.X, 1.0);
|
||||
Assert.Equal(5, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquareWithLargeHole_AvoidsHole()
|
||||
{
|
||||
var outer = Square(100);
|
||||
|
||||
var hole = new Polygon();
|
||||
hole.Vertices.Add(new Vector(20, 20));
|
||||
hole.Vertices.Add(new Vector(80, 20));
|
||||
hole.Vertices.Add(new Vector(80, 80));
|
||||
hole.Vertices.Add(new Vector(20, 80));
|
||||
|
||||
var result = PolyLabel.Find(outer, new[] { hole });
|
||||
|
||||
// Point should be inside outer but outside hole
|
||||
Assert.True(outer.ContainsPoint(result));
|
||||
Assert.False(hole.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CShape_ReturnsPointInLeftBar()
|
||||
{
|
||||
// C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 20));
|
||||
p.Vertices.Add(new Vector(20, 20));
|
||||
p.Vertices.Add(new Vector(20, 80));
|
||||
p.Vertices.Add(new Vector(100, 80));
|
||||
p.Vertices.Add(new Vector(100, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// Label should be in the left vertical bar (x < 20), not at bbox center (50, 50)
|
||||
Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DegeneratePolygon_ReturnsFallback()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(5, 5));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(5, result.X, 0.01);
|
||||
Assert.Equal(5, result.Y, 0.01);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all PolyLabel tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "test(geometry): add PolyLabel tests for L, C, triangle, thin rect, hole"
|
||||
```
|
||||
|
||||
## Chunk 2: Label Rendering
|
||||
|
||||
### Task 3: Update LayoutPart label positioning
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/LayoutPart.cs`
|
||||
|
||||
- [ ] **Step 1: Add cached label point field and computation method**
|
||||
|
||||
Add a `Vector? _labelPoint` field and a method to compute it from the part's geometry. Uses `ShapeProfile` to identify the outer contour and holes.
|
||||
|
||||
```csharp
|
||||
// Add field near the top of the class (after the existing private fields):
|
||||
private Vector? _labelPoint;
|
||||
|
||||
// Add method:
|
||||
private Vector ComputeLabelPoint()
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(BasePart.Program);
|
||||
var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
|
||||
if (nonRapid.Count == 0)
|
||||
{
|
||||
var bbox = BasePart.Program.BoundingBox();
|
||||
return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2);
|
||||
}
|
||||
|
||||
var profile = new ShapeProfile(nonRapid);
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.1);
|
||||
|
||||
List<Polygon> holes = null;
|
||||
|
||||
if (profile.Cutouts.Count > 0)
|
||||
{
|
||||
holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
holes.Add(cutout.ToPolygonWithTolerance(0.1));
|
||||
}
|
||||
|
||||
return PolyLabel.Find(outer, holes);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Invalidate the cache when IsDirty is set**
|
||||
|
||||
Modify the `IsDirty` property to clear `_labelPoint`:
|
||||
|
||||
```csharp
|
||||
// Replace:
|
||||
internal bool IsDirty { get; set; }
|
||||
|
||||
// With:
|
||||
private bool _isDirty;
|
||||
internal bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set
|
||||
{
|
||||
_isDirty = value;
|
||||
if (value) _labelPoint = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add screen-space label point field and compute it in Update()**
|
||||
|
||||
Compute the polylabel in program-local coordinates (cached, expensive) and transform to screen space in `Update()` (cheap, runs on every zoom/pan).
|
||||
|
||||
```csharp
|
||||
// Add field:
|
||||
private PointF _labelScreenPoint;
|
||||
|
||||
// Replace existing Update():
|
||||
public void Update(DrawControl plateView)
|
||||
{
|
||||
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
|
||||
Path.Transform(plateView.Matrix);
|
||||
|
||||
_labelPoint ??= ComputeLabelPoint();
|
||||
var labelPt = new PointF(
|
||||
(float)(_labelPoint.Value.X + BasePart.Location.X),
|
||||
(float)(_labelPoint.Value.Y + BasePart.Location.Y));
|
||||
var pts = new[] { labelPt };
|
||||
plateView.Matrix.TransformPoints(pts);
|
||||
_labelScreenPoint = pts[0];
|
||||
|
||||
IsDirty = false;
|
||||
}
|
||||
```
|
||||
|
||||
Note: setting `IsDirty = false` at the end of `Update()` will NOT clear `_labelPoint` because the setter only clears when `value` is `true`.
|
||||
|
||||
- [ ] **Step 4: Update Draw(Graphics g, string id) to use the cached screen point**
|
||||
|
||||
```csharp
|
||||
// Replace the existing Draw(Graphics g, string id) method body.
|
||||
// Old code (lines 85-101 of LayoutPart.cs):
|
||||
// if (IsSelected) { ... } else { ... }
|
||||
// var pt = Path.PointCount > 0 ? Path.PathPoints[0] : PointF.Empty;
|
||||
// g.DrawString(id, programIdFont, Brushes.Black, pt.X, pt.Y);
|
||||
|
||||
// New code:
|
||||
public void Draw(Graphics g, string id)
|
||||
{
|
||||
if (IsSelected)
|
||||
{
|
||||
g.FillPath(selectedBrush, Path);
|
||||
g.DrawPath(selectedPen, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.FillPath(brush, Path);
|
||||
g.DrawPath(pen, Path);
|
||||
}
|
||||
|
||||
using var sf = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Center,
|
||||
LineAlignment = StringAlignment.Center
|
||||
};
|
||||
g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/LayoutPart.cs
|
||||
git commit -m "feat(ui): position part labels at polylabel center"
|
||||
```
|
||||
|
||||
### Task 4: Manual visual verification
|
||||
|
||||
- [ ] **Step 1: Run the application and verify labels**
|
||||
|
||||
Run the OpenNest application, load a nest with multiple parts, and verify:
|
||||
- Labels appear centered inside each part.
|
||||
- Labels don't overlap adjacent part edges.
|
||||
- Labels stay centered when zooming and panning.
|
||||
- Parts with holes have labels placed in the solid material, not in the hole.
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Final commit if any tweaks needed**
|
||||
@@ -1,998 +0,0 @@
|
||||
# Remnant Finder Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract remnant detection from the nesting engine into a standalone `RemnantFinder` class that finds all rectangular empty regions via edge projection, and visualize the active work area on the plate view.
|
||||
|
||||
**Architecture:** `RemnantFinder` is a mutable class in `OpenNest.Engine` that takes a work area + obstacle boxes and uses edge projection to find empty rectangles. The remainder phase is removed from `DefaultNestEngine`, making `Fill()` single-pass. `FillScore` drops remnant tracking. `PlateView` gains a dashed orange rectangle overlay for the active work area. `NestProgress` carries `ActiveWorkArea` so callers can show which region is currently being filled.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, xUnit, WinForms (GDI+)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-remnant-finder-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: RemnantFinder Core
|
||||
|
||||
### Task 1: RemnantFinder — failing tests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/RemnantFinderTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class RemnantFinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPlate_ReturnsWholeWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCorner_FindsLShapedRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find at least the right strip (60x100) and top strip (40x60)
|
||||
Assert.True(remnants.Count >= 2);
|
||||
|
||||
// Largest remnant should be the right strip
|
||||
var largest = remnants[0];
|
||||
Assert.Equal(60 * 100, largest.Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCenter_FindsFourRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(30, 30, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find remnants on all four sides
|
||||
Assert.True(remnants.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinDimension_FiltersSmallRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Obstacle leaves a 5-wide strip on the right
|
||||
finder.AddObstacle(new Box(0, 0, 95, 100));
|
||||
var all = finder.FindRemnants(0);
|
||||
var filtered = finder.FindRemnants(10);
|
||||
|
||||
Assert.True(all.Count > filtered.Count);
|
||||
foreach (var r in filtered)
|
||||
{
|
||||
Assert.True(r.Width >= 10);
|
||||
Assert.True(r.Length >= 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultsSortedByAreaDescending()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
for (var i = 1; i < remnants.Count; i++)
|
||||
Assert.True(remnants[i - 1].Area() >= remnants[i].Area());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddObstacle_UpdatesResults()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var before = finder.FindRemnants();
|
||||
Assert.Single(before);
|
||||
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var after = finder.FindRemnants();
|
||||
Assert.True(after.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearObstacles_ResetsToFullWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
finder.ClearObstacles();
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyCovered_ReturnsEmpty()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Empty(remnants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleObstacles_FindsGapBetween()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Two obstacles with a 20-wide gap in the middle
|
||||
finder.AddObstacle(new Box(0, 0, 40, 100));
|
||||
finder.AddObstacle(new Box(60, 0, 40, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find the 20x100 gap between the two obstacles
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1 &&
|
||||
r.Length >= 99.9);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPlate_CreatesFinderWithPartsAsObstacles()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 20));
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should have remnants around the 20x20 part
|
||||
Assert.True(remnants.Count >= 1);
|
||||
// Largest remnant area should be less than full plate work area
|
||||
Assert.True(remnants[0].Area() < plate.WorkArea().Area());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: FAIL — `RemnantFinder` class does not exist
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/RemnantFinderTests.cs
|
||||
git commit -m "test: add RemnantFinder tests (red)"
|
||||
```
|
||||
|
||||
### Task 2: RemnantFinder — implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/RemnantFinder.cs`
|
||||
|
||||
- [ ] **Step 1: Implement RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RemnantFinder
|
||||
{
|
||||
private readonly Box workArea;
|
||||
|
||||
public List<Box> Obstacles { get; } = new();
|
||||
|
||||
public RemnantFinder(Box workArea, List<Box> obstacles = null)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
|
||||
if (obstacles != null)
|
||||
Obstacles.AddRange(obstacles);
|
||||
}
|
||||
|
||||
public void AddObstacle(Box obstacle) => Obstacles.Add(obstacle);
|
||||
|
||||
public void AddObstacles(IEnumerable<Box> obstacles) => Obstacles.AddRange(obstacles);
|
||||
|
||||
public void ClearObstacles() => Obstacles.Clear();
|
||||
|
||||
public List<Box> FindRemnants(double minDimension = 0)
|
||||
{
|
||||
// Step 1-2: Collect unique X and Y coordinates
|
||||
var xs = new SortedSet<double> { workArea.Left, workArea.Right };
|
||||
var ys = new SortedSet<double> { workArea.Bottom, workArea.Top };
|
||||
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
var clipped = ClipToWorkArea(obs);
|
||||
if (clipped.Width <= 0 || clipped.Length <= 0)
|
||||
continue;
|
||||
|
||||
xs.Add(clipped.Left);
|
||||
xs.Add(clipped.Right);
|
||||
ys.Add(clipped.Bottom);
|
||||
ys.Add(clipped.Top);
|
||||
}
|
||||
|
||||
var xList = xs.ToList();
|
||||
var yList = ys.ToList();
|
||||
|
||||
// Step 3-4: Build grid cells and mark empty ones
|
||||
var cols = xList.Count - 1;
|
||||
var rows = yList.Count - 1;
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Box>();
|
||||
|
||||
var empty = new bool[rows, cols];
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
var cell = new Box(xList[c], yList[r],
|
||||
xList[c + 1] - xList[c], yList[r + 1] - yList[r]);
|
||||
|
||||
empty[r, c] = !OverlapsAnyObstacle(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Merge adjacent empty cells into larger rectangles
|
||||
var merged = MergeCells(empty, xList, yList, rows, cols);
|
||||
|
||||
// Step 6: Filter by minDimension
|
||||
var results = new List<Box>();
|
||||
|
||||
foreach (var box in merged)
|
||||
{
|
||||
if (box.Width >= minDimension && box.Length >= minDimension)
|
||||
results.Add(box);
|
||||
}
|
||||
|
||||
// Step 7: Sort by area descending
|
||||
results.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static RemnantFinder FromPlate(Plate plate)
|
||||
{
|
||||
var obstacles = new List<Box>(plate.Parts.Count);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
obstacles.Add(part.BoundingBox.Offset(plate.PartSpacing));
|
||||
|
||||
return new RemnantFinder(plate.WorkArea(), obstacles);
|
||||
}
|
||||
|
||||
private Box ClipToWorkArea(Box obs)
|
||||
{
|
||||
var left = System.Math.Max(obs.Left, workArea.Left);
|
||||
var bottom = System.Math.Max(obs.Bottom, workArea.Bottom);
|
||||
var right = System.Math.Min(obs.Right, workArea.Right);
|
||||
var top = System.Math.Min(obs.Top, workArea.Top);
|
||||
|
||||
if (right <= left || top <= bottom)
|
||||
return Box.Empty;
|
||||
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
private bool OverlapsAnyObstacle(Box cell)
|
||||
{
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
var clipped = ClipToWorkArea(obs);
|
||||
|
||||
if (clipped.Width <= 0 || clipped.Length <= 0)
|
||||
continue;
|
||||
|
||||
if (cell.Left < clipped.Right &&
|
||||
cell.Right > clipped.Left &&
|
||||
cell.Bottom < clipped.Top &&
|
||||
cell.Top > clipped.Bottom)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Box> MergeCells(bool[,] empty, List<double> xList, List<double> yList, int rows, int cols)
|
||||
{
|
||||
var used = new bool[rows, cols];
|
||||
var results = new List<Box>();
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
if (!empty[r, c] || used[r, c])
|
||||
continue;
|
||||
|
||||
// Expand right as far as possible
|
||||
var maxC = c;
|
||||
while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1])
|
||||
maxC++;
|
||||
|
||||
// Expand down as far as possible
|
||||
var maxR = r;
|
||||
while (maxR + 1 < rows)
|
||||
{
|
||||
var rowOk = true;
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
{
|
||||
if (!empty[maxR + 1, cc] || used[maxR + 1, cc])
|
||||
{
|
||||
rowOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowOk) break;
|
||||
maxR++;
|
||||
}
|
||||
|
||||
// Mark cells as used
|
||||
for (var rr = r; rr <= maxR; rr++)
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
used[rr, cc] = true;
|
||||
|
||||
var box = new Box(
|
||||
xList[c], yList[r],
|
||||
xList[maxC + 1] - xList[c],
|
||||
yList[maxR + 1] - yList[r]);
|
||||
|
||||
results.Add(box);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/RemnantFinder.cs
|
||||
git commit -m "feat: add RemnantFinder with edge projection algorithm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: FillScore Simplification and Remnant Cleanup
|
||||
|
||||
### Task 3: Simplify FillScore — remove remnant tracking
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/FillScore.cs`
|
||||
|
||||
- [ ] **Step 1: Remove remnant-related members from FillScore**
|
||||
|
||||
Remove `MinRemnantDimension`, `UsableRemnantArea`, `ComputeUsableRemnantArea()`. Simplify constructor and `Compute()`. Update `CompareTo` to compare count then density (no remnant area).
|
||||
|
||||
New `FillScore.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double density)
|
||||
{
|
||||
Count = count;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a fill score from placed parts and the work area they were placed in.
|
||||
/// </summary>
|
||||
public static FillScore Compute(List<Part> parts, Box workArea)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
return new FillScore(parts.Count, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return Density.CompareTo(other.Density);
|
||||
}
|
||||
|
||||
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
|
||||
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
|
||||
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
|
||||
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit (build will not pass yet — remaining UsableRemnantArea references fixed in Tasks 4-5)**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillScore.cs
|
||||
git commit -m "refactor: simplify FillScore to count + density, remove remnant tracking"
|
||||
```
|
||||
|
||||
### Task 4: Update DefaultNestEngine debug logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:456-459`
|
||||
|
||||
- [ ] **Step 1: Update FillWithPairs debug log**
|
||||
|
||||
At line 456, change:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
```
|
||||
|
||||
Also update the file-based debug log at lines 457-459 — change `bestScore.UsableRemnantArea` references similarly. If the file log references `UsableRemnantArea`, remove that interpolation.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "fix: update FillWithPairs debug logging after FillScore simplification"
|
||||
```
|
||||
|
||||
### Task 5: Remove NestProgress.UsableRemnantArea and UI references
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:44`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:232`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:40`
|
||||
|
||||
- [ ] **Step 1: Remove UsableRemnantArea from NestProgress**
|
||||
|
||||
In `NestProgress.cs`, remove line 44:
|
||||
```csharp
|
||||
public double UsableRemnantArea { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove UsableRemnantArea from ReportProgress**
|
||||
|
||||
In `NestEngineBase.cs` at line 232, remove:
|
||||
```csharp
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove remnant display from NestProgressForm**
|
||||
|
||||
In `NestProgressForm.cs` at line 40, remove:
|
||||
```csharp
|
||||
remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in";
|
||||
```
|
||||
|
||||
Also remove the `remnantValue` label and its corresponding "Remnant:" label from the form's Designer file (or set them to display something else if desired). If simpler, just remove the line that sets the text — the label will remain but show its default empty text.
|
||||
|
||||
- [ ] **Step 4: Build to verify all UsableRemnantArea references are resolved**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds — all `UsableRemnantArea` references are now removed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove UsableRemnantArea from NestProgress and UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Remove Remainder Phase from Engine
|
||||
|
||||
### Task 6: Remove remainder phase from DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
- [ ] **Step 1: Remove TryRemainderImprovement calls from Fill() overrides**
|
||||
|
||||
In the first `Fill()` override (line 31), remove lines 40-53 (the remainder improvement block after `FindBestFill`):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
var remainderSw = Stopwatch.StartNew();
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
// ... through to the closing brace
|
||||
}
|
||||
```
|
||||
|
||||
In the second `Fill()` override (line 118), remove lines 165-174 (the remainder improvement block inside the `if (groupParts.Count == 1)` block):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove TryRemainderImprovement, TryStripRefill, ClusterParts methods**
|
||||
|
||||
Remove the three private methods (lines 563-694):
|
||||
- `TryRemainderImprovement`
|
||||
- `TryStripRefill`
|
||||
- `ClusterParts`
|
||||
|
||||
- [ ] **Step 3: Update Description property**
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "refactor: remove remainder phase from DefaultNestEngine"
|
||||
```
|
||||
|
||||
### Task 7: Remove NestPhase.Remainder and cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:11`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:314`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:100`
|
||||
|
||||
- [ ] **Step 1: Remove Remainder from NestPhase enum**
|
||||
|
||||
In `NestProgress.cs`, remove `Remainder` from the enum.
|
||||
|
||||
- [ ] **Step 2: Remove Remainder case from FormatPhaseName**
|
||||
|
||||
In `NestEngineBase.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove Remainder case from FormatPhase**
|
||||
|
||||
In `NestProgressForm.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Filling remainder...";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove NestPhase.Remainder enum value and switch cases"
|
||||
```
|
||||
|
||||
### Task 8: Remove ComputeRemainderWithin and update Nest()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:92,120-133`
|
||||
|
||||
- [ ] **Step 1: Replace ComputeRemainderWithin usage in Nest()**
|
||||
|
||||
At line 91-92, change:
|
||||
```csharp
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var placedObstacles = parts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList();
|
||||
var finder = new RemnantFinder(workArea, placedObstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
if (remnants.Count == 0)
|
||||
break;
|
||||
workArea = remnants[0]; // Largest remnant
|
||||
```
|
||||
|
||||
Note: This is a behavioral improvement — the old code used a single merged bounding box and picked one strip. The new code finds per-part obstacles and discovers all gaps, using the largest. This may produce different (better) results for non-rectangular layouts.
|
||||
|
||||
- [ ] **Step 2: Remove ComputeRemainderWithin method**
|
||||
|
||||
Delete lines 120-133 (the `ComputeRemainderWithin` static method).
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "refactor: replace ComputeRemainderWithin with RemnantFinder in Nest()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Remove Old Remnant Code and Update Callers
|
||||
|
||||
### Task 9: Remove Plate.GetRemnants()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Plate.cs:477-557`
|
||||
|
||||
- [ ] **Step 1: Remove GetRemnants method**
|
||||
|
||||
Delete the `GetRemnants()` method (lines 477-557, the XML doc comment through the closing brace).
|
||||
|
||||
- [ ] **Step 2: Build to check for remaining references**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Errors in `NestingTools.cs` and `InspectionTools.cs` (fixed in next task)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Plate.cs
|
||||
git commit -m "refactor: remove Plate.GetRemnants(), replaced by RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 10: Update MCP callers
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs:105`
|
||||
- Modify: `OpenNest.Mcp/Tools/InspectionTools.cs:31`
|
||||
|
||||
- [ ] **Step 1: Update NestingTools.FillRemnants**
|
||||
|
||||
At line 105, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update InspectionTools.GetPlateInfo**
|
||||
|
||||
At line 31, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs OpenNest.Mcp/Tools/InspectionTools.cs
|
||||
git commit -m "refactor: update MCP tools to use RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 11: Remove StripNestResult.RemnantBox
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestResult.cs:10`
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs:301`
|
||||
|
||||
- [ ] **Step 1: Remove RemnantBox property from StripNestResult**
|
||||
|
||||
In `StripNestResult.cs`, remove line 10:
|
||||
```csharp
|
||||
public Box RemnantBox { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove RemnantBox assignment in StripNestEngine**
|
||||
|
||||
In `StripNestEngine.cs` at line 301, remove:
|
||||
```csharp
|
||||
result.RemnantBox = remnantBox;
|
||||
```
|
||||
|
||||
Also check if the local `remnantBox` variable is now unused — if so, remove its declaration and computation too.
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "refactor: remove StripNestResult.RemnantBox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: PlateView Active Work Area Visualization
|
||||
|
||||
### Task 12: Add ActiveWorkArea to NestProgress
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property to NestProgress**
|
||||
|
||||
`Box` is a reference type (class), so use `Box` directly (not `Box?`):
|
||||
|
||||
```csharp
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
```
|
||||
|
||||
`NestProgress.cs` already has `using OpenNest.Geometry;` via the `Box` usage in existing properties. If not, add it.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs
|
||||
git commit -m "feat: add ActiveWorkArea property to NestProgress"
|
||||
```
|
||||
|
||||
### Task 13: Draw active work area on PlateView
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property**
|
||||
|
||||
Add a field and property to `PlateView`:
|
||||
```csharp
|
||||
private Box activeWorkArea;
|
||||
|
||||
public Box ActiveWorkArea
|
||||
{
|
||||
get => activeWorkArea;
|
||||
set
|
||||
{
|
||||
activeWorkArea = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add DrawActiveWorkArea method**
|
||||
|
||||
Add a private method to draw the dashed orange rectangle, using the same coordinate transform pattern as `DrawBox` (line 591-601):
|
||||
|
||||
```csharp
|
||||
private void DrawActiveWorkArea(Graphics g)
|
||||
{
|
||||
if (activeWorkArea == null)
|
||||
return;
|
||||
|
||||
var rect = new RectangleF
|
||||
{
|
||||
Location = PointWorldToGraph(activeWorkArea.Location),
|
||||
Width = LengthWorldToGui(activeWorkArea.Width),
|
||||
Height = LengthWorldToGui(activeWorkArea.Length)
|
||||
};
|
||||
rect.Y -= rect.Height;
|
||||
|
||||
using var pen = new Pen(Color.Orange, 2f)
|
||||
{
|
||||
DashStyle = DashStyle.Dash
|
||||
};
|
||||
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call DrawActiveWorkArea in OnPaint**
|
||||
|
||||
In `OnPaint` (line 363-364), add the call after `DrawParts`:
|
||||
```csharp
|
||||
DrawPlate(e.Graphics);
|
||||
DrawParts(e.Graphics);
|
||||
DrawActiveWorkArea(e.Graphics);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs
|
||||
git commit -m "feat: draw active work area as dashed orange rectangle on PlateView"
|
||||
```
|
||||
|
||||
### Task 14: Wire ActiveWorkArea through progress callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs:828-829`
|
||||
- Modify: `OpenNest\Forms\MainForm.cs:760-761,895-896,955-956`
|
||||
|
||||
The `PlateView` and `MainForm` both have progress callbacks that already set `SetTemporaryParts`. Add `ActiveWorkArea` alongside those.
|
||||
|
||||
- [ ] **Step 1: Update PlateView.FillWithProgress callback**
|
||||
|
||||
At `PlateView.cs` line 828-829, the callback currently does:
|
||||
```csharp
|
||||
progressForm.UpdateProgress(p);
|
||||
SetTemporaryParts(p.BestParts);
|
||||
```
|
||||
|
||||
Add after `SetTemporaryParts`:
|
||||
```csharp
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MainForm progress callbacks**
|
||||
|
||||
There are three progress callback sites in `MainForm.cs`. At each one, after the `SetTemporaryParts` call, add:
|
||||
|
||||
At line 761 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 896 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 956 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Clear ActiveWorkArea when nesting completes**
|
||||
|
||||
In each nesting method's completion/cleanup path, clear the work area overlay. In `PlateView.cs` after the fill task completes (near `progressForm.ShowCompleted()`), add:
|
||||
```csharp
|
||||
ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
Similarly in each `MainForm` nesting method's completion path:
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat: wire ActiveWorkArea from NestProgress to PlateView"
|
||||
```
|
||||
|
||||
### Task 15: Set ActiveWorkArea in Nest() method
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs` (the `Nest()` method updated in Task 8)
|
||||
|
||||
- [ ] **Step 1: Report ActiveWorkArea in Nest() progress**
|
||||
|
||||
In the `Nest()` method, after picking the largest remnant as the next work area (Task 8's change), set `ActiveWorkArea` on the progress report. Find the `ReportProgress` call inside or near the fill loop and ensure the progress object carries the current `workArea`.
|
||||
|
||||
The simplest approach: pass the work area through `ReportProgress`. In `NestEngineBase.ReportProgress` (the static helper), add `ActiveWorkArea = workArea` to the `NestProgress` initializer:
|
||||
|
||||
In `ReportProgress`, add to the `new NestProgress { ... }` block:
|
||||
```csharp
|
||||
ActiveWorkArea = workArea,
|
||||
```
|
||||
|
||||
This ensures every progress report includes the current work area being filled.
|
||||
|
||||
- [ ] **Step 2: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "feat: report ActiveWorkArea in NestProgress from ReportProgress"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6: Final Verification
|
||||
|
||||
### Task 16: Full build and test
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: 0 errors, 0 warnings related to remnant code
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: All tests pass including new `RemnantFinderTests`
|
||||
|
||||
- [ ] **Step 3: Verify no stale references**
|
||||
|
||||
Run: `grep -rn "GetRemnants\|ComputeRemainderWithin\|TryRemainderImprovement\|MinRemnantDimension\|UsableRemnantArea" --include="*.cs" .`
|
||||
Expected: No matches in source files (only in docs/specs/plans)
|
||||
|
||||
- [ ] **Step 4: Final commit if any fixups needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final cleanup after remnant finder extraction"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,877 +0,0 @@
|
||||
# Pattern Tile Layout Window Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a tool window where the user selects two drawings, arranges them as a unit cell, and tiles the pattern across a configurable plate with an option to apply the result.
|
||||
|
||||
**Architecture:** A `PatternTileForm` dialog with a horizontal `SplitContainer` — left panel is a `PlateView` for unit cell editing (plate outline hidden via zero-size plate), right panel is a read-only `PlateView` for tile preview. A `PatternTiler` helper in Engine handles the tiling math (deliberate addition beyond the spec for separation of concerns — the spec says "no engine changes" but the tiler is pure logic with no side-effects). The form is opened from the Tools menu and returns a result to `EditNestForm` for placement.
|
||||
|
||||
**Tech Stack:** WinForms (.NET 8), existing `PlateView`/`DrawControl` controls, `Compactor.Push` (angle-based overload), `Part.CloneAtOffset`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-pattern-tile-layout-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PatternTiler — tiling algorithm in Engine
|
||||
|
||||
The pure logic component that takes a unit cell (list of parts) and tiles it across a plate work area. No UI dependency.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/PatternTiler.cs`
|
||||
- Test: `OpenNest.Tests/PatternTilerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for PatternTiler**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Tests/PatternTilerTests.cs
|
||||
using OpenNest;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PatternTilerTests
|
||||
{
|
||||
private static Drawing MakeSquareDrawing(double size)
|
||||
{
|
||||
var pgm = new CNC.Program();
|
||||
pgm.Add(new CNC.LinearMove(size, 0));
|
||||
pgm.Add(new CNC.LinearMove(size, size));
|
||||
pgm.Add(new CNC.LinearMove(0, size));
|
||||
pgm.Add(new CNC.LinearMove(0, 0));
|
||||
return new Drawing("square", pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_SinglePart_FillsGrid()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
// Size(width=X, length=Y) — 30 wide, 20 tall
|
||||
var plateSize = new Size(30, 20);
|
||||
var partSpacing = 0.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// 3 columns (30/10) x 2 rows (20/10) = 6 parts
|
||||
Assert.Equal(6, result.Count);
|
||||
|
||||
// Verify all parts are within plate bounds
|
||||
foreach (var part in result)
|
||||
{
|
||||
Assert.True(part.BoundingBox.Right <= plateSize.Width + 0.001);
|
||||
Assert.True(part.BoundingBox.Top <= plateSize.Length + 0.001);
|
||||
Assert.True(part.BoundingBox.Left >= -0.001);
|
||||
Assert.True(part.BoundingBox.Bottom >= -0.001);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_TwoParts_TilesUnitCell()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var partA = Part.CreateAtOrigin(drawing);
|
||||
var partB = Part.CreateAtOrigin(drawing);
|
||||
partB.Offset(10, 0); // side by side, cell = 20x10
|
||||
|
||||
var cell = new List<Part> { partA, partB };
|
||||
var plateSize = new Size(40, 20);
|
||||
var partSpacing = 0.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// 2 columns (40/20) x 2 rows (20/10) = 4 cells x 2 parts = 8
|
||||
Assert.Equal(8, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_WithSpacing_ReducesCount()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
var plateSize = new Size(30, 20);
|
||||
var partSpacing = 2.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// cell width = 10 + 2 = 12, cols = floor(30/12) = 2
|
||||
// cell height = 10 + 2 = 12, rows = floor(20/12) = 1
|
||||
// 2 x 1 = 2 parts
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_EmptyCell_ReturnsEmpty()
|
||||
{
|
||||
var result = PatternTiler.Tile(new List<Part>(), new Size(100, 100), 0);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_NonSquarePlate_CorrectAxes()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
// Wide plate: 50 in X (Width), 10 in Y (Length) — should fit 5x1
|
||||
var plateSize = new Size(50, 10);
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
||||
// Verify parts span the X axis, not Y
|
||||
var maxRight = result.Max(p => p.BoundingBox.Right);
|
||||
var maxTop = result.Max(p => p.BoundingBox.Top);
|
||||
Assert.True(maxRight <= 50.001);
|
||||
Assert.True(maxTop <= 10.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_CellLargerThanPlate_ReturnsSingleCell()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(50);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
var plateSize = new Size(30, 30);
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||
|
||||
// Cell doesn't fit at all — 0 parts
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||
Expected: Build error — `PatternTiler` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement PatternTiler**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Engine/PatternTiler.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public static class PatternTiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Tiles a unit cell across a plate, returning cloned parts.
|
||||
/// </summary>
|
||||
/// <param name="cell">The unit cell parts (positioned relative to each other).</param>
|
||||
/// <param name="plateSize">The plate size to tile across.</param>
|
||||
/// <param name="partSpacing">Spacing to add around each cell.</param>
|
||||
/// <returns>List of cloned parts filling the plate.</returns>
|
||||
public static List<Part> Tile(List<Part> cell, Size plateSize, double partSpacing)
|
||||
{
|
||||
if (cell == null || cell.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var cellBox = cell.GetBoundingBox();
|
||||
var halfSpacing = partSpacing / 2;
|
||||
|
||||
var cellWidth = cellBox.Width + partSpacing;
|
||||
var cellHeight = cellBox.Length + partSpacing;
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Size.Width = X-axis, Size.Length = Y-axis
|
||||
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
|
||||
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Offset to normalize cell origin to (halfSpacing, halfSpacing)
|
||||
var cellOrigin = cellBox.Location;
|
||||
var baseOffset = new Vector(halfSpacing - cellOrigin.X, halfSpacing - cellOrigin.Y);
|
||||
|
||||
var result = new List<Part>(cols * rows * cell.Count);
|
||||
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
|
||||
|
||||
foreach (var part in cell)
|
||||
{
|
||||
result.Add(part.CloneAtOffset(tileOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||
Expected: All 6 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/PatternTiler.cs OpenNest.Tests/PatternTilerTests.cs
|
||||
git commit -m "feat(engine): add PatternTiler for unit cell tiling across plates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PatternTileForm — the dialog window
|
||||
|
||||
The WinForms dialog with split layout, drawing pickers, plate size controls, and the two `PlateView` panels. This task builds the form shell and layout — interaction logic comes in Task 3.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest/Forms/PatternTileForm.cs`
|
||||
- Create: `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||
|
||||
**Key reference files:**
|
||||
- `OpenNest/Forms/BestFitViewerForm.cs` — similar standalone tool form pattern
|
||||
- `OpenNest/Controls/PlateView.cs` — the control used in both panels
|
||||
- `OpenNest/Forms/EditPlateForm.cs` — plate size input pattern
|
||||
|
||||
- [ ] **Step 1: Create PatternTileForm.Designer.cs**
|
||||
|
||||
```csharp
|
||||
// OpenNest/Forms/PatternTileForm.Designer.cs
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class PatternTileForm
|
||||
{
|
||||
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.topPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
this.lblDrawingA = new System.Windows.Forms.Label();
|
||||
this.cboDrawingA = new System.Windows.Forms.ComboBox();
|
||||
this.lblDrawingB = new System.Windows.Forms.Label();
|
||||
this.cboDrawingB = new System.Windows.Forms.ComboBox();
|
||||
this.lblPlateSize = new System.Windows.Forms.Label();
|
||||
this.txtPlateSize = new System.Windows.Forms.TextBox();
|
||||
this.lblPartSpacing = new System.Windows.Forms.Label();
|
||||
this.nudPartSpacing = new System.Windows.Forms.NumericUpDown();
|
||||
this.btnAutoArrange = new System.Windows.Forms.Button();
|
||||
this.btnApply = new System.Windows.Forms.Button();
|
||||
this.splitContainer = new System.Windows.Forms.SplitContainer();
|
||||
this.topPanel.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).BeginInit();
|
||||
this.splitContainer.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// topPanel — FlowLayoutPanel for correct left-to-right ordering
|
||||
//
|
||||
this.topPanel.Controls.Add(this.lblDrawingA);
|
||||
this.topPanel.Controls.Add(this.cboDrawingA);
|
||||
this.topPanel.Controls.Add(this.lblDrawingB);
|
||||
this.topPanel.Controls.Add(this.cboDrawingB);
|
||||
this.topPanel.Controls.Add(this.lblPlateSize);
|
||||
this.topPanel.Controls.Add(this.txtPlateSize);
|
||||
this.topPanel.Controls.Add(this.lblPartSpacing);
|
||||
this.topPanel.Controls.Add(this.nudPartSpacing);
|
||||
this.topPanel.Controls.Add(this.btnAutoArrange);
|
||||
this.topPanel.Controls.Add(this.btnApply);
|
||||
this.topPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.topPanel.Height = 36;
|
||||
this.topPanel.Padding = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.topPanel.WrapContents = false;
|
||||
//
|
||||
// lblDrawingA
|
||||
//
|
||||
this.lblDrawingA.Text = "Drawing A:";
|
||||
this.lblDrawingA.AutoSize = true;
|
||||
this.lblDrawingA.Margin = new System.Windows.Forms.Padding(3, 5, 0, 0);
|
||||
//
|
||||
// cboDrawingA
|
||||
//
|
||||
this.cboDrawingA.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.cboDrawingA.Width = 130;
|
||||
//
|
||||
// lblDrawingB
|
||||
//
|
||||
this.lblDrawingB.Text = "Drawing B:";
|
||||
this.lblDrawingB.AutoSize = true;
|
||||
this.lblDrawingB.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// cboDrawingB
|
||||
//
|
||||
this.cboDrawingB.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.cboDrawingB.Width = 130;
|
||||
//
|
||||
// lblPlateSize
|
||||
//
|
||||
this.lblPlateSize.Text = "Plate Size:";
|
||||
this.lblPlateSize.AutoSize = true;
|
||||
this.lblPlateSize.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// txtPlateSize
|
||||
//
|
||||
this.txtPlateSize.Width = 80;
|
||||
//
|
||||
// lblPartSpacing
|
||||
//
|
||||
this.lblPartSpacing.Text = "Spacing:";
|
||||
this.lblPartSpacing.AutoSize = true;
|
||||
this.lblPartSpacing.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// nudPartSpacing
|
||||
//
|
||||
this.nudPartSpacing.Width = 60;
|
||||
this.nudPartSpacing.DecimalPlaces = 2;
|
||||
this.nudPartSpacing.Increment = 0.25m;
|
||||
this.nudPartSpacing.Maximum = 100;
|
||||
this.nudPartSpacing.Minimum = 0;
|
||||
//
|
||||
// btnAutoArrange
|
||||
//
|
||||
this.btnAutoArrange.Text = "Auto-Arrange";
|
||||
this.btnAutoArrange.Width = 100;
|
||||
this.btnAutoArrange.Margin = new System.Windows.Forms.Padding(10, 0, 0, 0);
|
||||
//
|
||||
// btnApply
|
||||
//
|
||||
this.btnApply.Text = "Apply...";
|
||||
this.btnApply.Width = 80;
|
||||
//
|
||||
// splitContainer
|
||||
//
|
||||
this.splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.splitContainer.SplitterDistance = 350;
|
||||
//
|
||||
// PatternTileForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(900, 550);
|
||||
this.Controls.Add(this.splitContainer);
|
||||
this.Controls.Add(this.topPanel);
|
||||
this.MinimumSize = new System.Drawing.Size(700, 400);
|
||||
this.Name = "PatternTileForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Pattern Tile Layout";
|
||||
this.topPanel.ResumeLayout(false);
|
||||
this.topPanel.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).EndInit();
|
||||
this.splitContainer.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.FlowLayoutPanel topPanel;
|
||||
private System.Windows.Forms.Label lblDrawingA;
|
||||
private System.Windows.Forms.ComboBox cboDrawingA;
|
||||
private System.Windows.Forms.Label lblDrawingB;
|
||||
private System.Windows.Forms.ComboBox cboDrawingB;
|
||||
private System.Windows.Forms.Label lblPlateSize;
|
||||
private System.Windows.Forms.TextBox txtPlateSize;
|
||||
private System.Windows.Forms.Label lblPartSpacing;
|
||||
private System.Windows.Forms.NumericUpDown nudPartSpacing;
|
||||
private System.Windows.Forms.Button btnAutoArrange;
|
||||
private System.Windows.Forms.Button btnApply;
|
||||
private System.Windows.Forms.SplitContainer splitContainer;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create PatternTileForm.cs — form shell with PlateViews**
|
||||
|
||||
```csharp
|
||||
// OpenNest/Forms/PatternTileForm.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
public enum PatternTileTarget
|
||||
{
|
||||
CurrentPlate,
|
||||
NewPlate
|
||||
}
|
||||
|
||||
public class PatternTileResult
|
||||
{
|
||||
public List<Part> Parts { get; set; }
|
||||
public PatternTileTarget Target { get; set; }
|
||||
public Size PlateSize { get; set; }
|
||||
}
|
||||
|
||||
public partial class PatternTileForm : Form
|
||||
{
|
||||
private readonly Nest nest;
|
||||
private readonly PlateView cellView;
|
||||
private readonly PlateView previewView;
|
||||
|
||||
public PatternTileResult Result { get; private set; }
|
||||
|
||||
public PatternTileForm(Nest nest)
|
||||
{
|
||||
this.nest = nest;
|
||||
InitializeComponent();
|
||||
|
||||
// Unit cell editor — plate outline hidden via zero-size plate
|
||||
cellView = new PlateView();
|
||||
cellView.Plate.Size = new Size(0, 0);
|
||||
cellView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||
cellView.DrawOrigin = false;
|
||||
cellView.DrawBounds = false; // hide selection bounding box overlay
|
||||
cellView.Dock = DockStyle.Fill;
|
||||
splitContainer.Panel1.Controls.Add(cellView);
|
||||
|
||||
// Tile preview — plate visible, read-only
|
||||
previewView = new PlateView();
|
||||
previewView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||
previewView.AllowSelect = false;
|
||||
previewView.AllowDrop = false;
|
||||
previewView.DrawBounds = false;
|
||||
previewView.Dock = DockStyle.Fill;
|
||||
splitContainer.Panel2.Controls.Add(previewView);
|
||||
|
||||
// Populate drawing dropdowns
|
||||
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||
cboDrawingA.Items.Add("(none)");
|
||||
cboDrawingB.Items.Add("(none)");
|
||||
foreach (var d in drawings)
|
||||
{
|
||||
cboDrawingA.Items.Add(d);
|
||||
cboDrawingB.Items.Add(d);
|
||||
}
|
||||
cboDrawingA.SelectedIndex = 0;
|
||||
cboDrawingB.SelectedIndex = 0;
|
||||
|
||||
// Default plate size from nest defaults
|
||||
var defaults = nest.PlateDefaults;
|
||||
txtPlateSize.Text = defaults.Size.ToString();
|
||||
nudPartSpacing.Value = (decimal)defaults.PartSpacing;
|
||||
|
||||
// Wire events
|
||||
cboDrawingA.SelectedIndexChanged += OnDrawingChanged;
|
||||
cboDrawingB.SelectedIndexChanged += OnDrawingChanged;
|
||||
txtPlateSize.TextChanged += OnPlateSettingsChanged;
|
||||
nudPartSpacing.ValueChanged += OnPlateSettingsChanged;
|
||||
btnAutoArrange.Click += OnAutoArrangeClick;
|
||||
btnApply.Click += OnApplyClick;
|
||||
cellView.MouseUp += OnCellMouseUp;
|
||||
}
|
||||
|
||||
private Drawing SelectedDrawingA =>
|
||||
cboDrawingA.SelectedItem as Drawing;
|
||||
|
||||
private Drawing SelectedDrawingB =>
|
||||
cboDrawingB.SelectedItem as Drawing;
|
||||
|
||||
private double PartSpacing =>
|
||||
(double)nudPartSpacing.Value;
|
||||
|
||||
private bool TryGetPlateSize(out Size size)
|
||||
{
|
||||
return Size.TryParse(txtPlateSize.Text, out size);
|
||||
}
|
||||
|
||||
private void OnDrawingChanged(object sender, EventArgs e)
|
||||
{
|
||||
RebuildCell();
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void OnPlateSettingsChanged(object sender, EventArgs e)
|
||||
{
|
||||
UpdatePreviewPlateSize();
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void OnCellMouseUp(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button == MouseButtons.Left && cellView.Plate.Parts.Count == 2)
|
||||
{
|
||||
CompactCellParts();
|
||||
}
|
||||
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void RebuildCell()
|
||||
{
|
||||
cellView.Plate.Parts.Clear();
|
||||
|
||||
var drawingA = SelectedDrawingA;
|
||||
var drawingB = SelectedDrawingB;
|
||||
|
||||
if (drawingA == null && drawingB == null)
|
||||
return;
|
||||
|
||||
if (drawingA != null)
|
||||
{
|
||||
var partA = Part.CreateAtOrigin(drawingA);
|
||||
cellView.Plate.Parts.Add(partA);
|
||||
}
|
||||
|
||||
if (drawingB != null)
|
||||
{
|
||||
var partB = Part.CreateAtOrigin(drawingB);
|
||||
|
||||
// Place B to the right of A (or at origin if A is null)
|
||||
if (drawingA != null && cellView.Plate.Parts.Count > 0)
|
||||
{
|
||||
var aBox = cellView.Plate.Parts[0].BoundingBox;
|
||||
partB.Offset(aBox.Right + PartSpacing, 0);
|
||||
}
|
||||
|
||||
cellView.Plate.Parts.Add(partB);
|
||||
}
|
||||
|
||||
cellView.ZoomToFit();
|
||||
}
|
||||
|
||||
private void CompactCellParts()
|
||||
{
|
||||
var parts = cellView.Plate.Parts.ToList();
|
||||
if (parts.Count < 2)
|
||||
return;
|
||||
|
||||
var combinedBox = parts.GetBoundingBox();
|
||||
var centroid = combinedBox.Center;
|
||||
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||
|
||||
for (var iteration = 0; iteration < 10; iteration++)
|
||||
{
|
||||
var totalMoved = 0.0;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var partCenter = part.BoundingBox.Center;
|
||||
var dx = centroid.X - partCenter.X;
|
||||
var dy = centroid.Y - partCenter.Y;
|
||||
var dist = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 0.01)
|
||||
continue;
|
||||
|
||||
var angle = System.Math.Atan2(dy, dx);
|
||||
var single = new List<Part> { part };
|
||||
var obstacles = parts.Where(p => p != part).ToList();
|
||||
|
||||
totalMoved += Compactor.Push(single, obstacles,
|
||||
syntheticWorkArea, PartSpacing, angle);
|
||||
}
|
||||
|
||||
if (totalMoved < 0.01)
|
||||
break;
|
||||
}
|
||||
|
||||
cellView.Refresh();
|
||||
}
|
||||
|
||||
private void UpdatePreviewPlateSize()
|
||||
{
|
||||
if (TryGetPlateSize(out var size))
|
||||
previewView.Plate.Size = size;
|
||||
}
|
||||
|
||||
private void RebuildPreview()
|
||||
{
|
||||
previewView.Plate.Parts.Clear();
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
previewView.Plate.Size = plateSize;
|
||||
previewView.Plate.PartSpacing = PartSpacing;
|
||||
|
||||
var cellParts = cellView.Plate.Parts.ToList();
|
||||
if (cellParts.Count == 0)
|
||||
return;
|
||||
|
||||
var tiled = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||
|
||||
foreach (var part in tiled)
|
||||
previewView.Plate.Parts.Add(part);
|
||||
|
||||
previewView.ZoomToFit();
|
||||
}
|
||||
|
||||
private void OnAutoArrangeClick(object sender, EventArgs e)
|
||||
{
|
||||
var drawingA = SelectedDrawingA;
|
||||
var drawingB = SelectedDrawingB;
|
||||
|
||||
if (drawingA == null || drawingB == null)
|
||||
return;
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
Cursor = Cursors.WaitCursor;
|
||||
try
|
||||
{
|
||||
var angles = new[] { 0.0, Math.Angle.ToRadians(90), Math.Angle.ToRadians(180), Math.Angle.ToRadians(270) };
|
||||
var bestCell = (List<Part>)null;
|
||||
var bestArea = double.MaxValue;
|
||||
|
||||
foreach (var angleA in angles)
|
||||
{
|
||||
foreach (var angleB in angles)
|
||||
{
|
||||
var partA = Part.CreateAtOrigin(drawingA, angleA);
|
||||
var partB = Part.CreateAtOrigin(drawingB, angleB);
|
||||
partB.Offset(partA.BoundingBox.Right + PartSpacing, 0);
|
||||
|
||||
var cell = new List<Part> { partA, partB };
|
||||
|
||||
// Compact toward center
|
||||
var box = cell.GetBoundingBox();
|
||||
var centroid = box.Center;
|
||||
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var moved = 0.0;
|
||||
foreach (var part in cell)
|
||||
{
|
||||
var pc = part.BoundingBox.Center;
|
||||
var dx = centroid.X - pc.X;
|
||||
var dy = centroid.Y - pc.Y;
|
||||
if (System.Math.Sqrt(dx * dx + dy * dy) < 0.01)
|
||||
continue;
|
||||
|
||||
var angle = System.Math.Atan2(dy, dx);
|
||||
var single = new List<Part> { part };
|
||||
var obstacles = cell.Where(p => p != part).ToList();
|
||||
moved += Compactor.Push(single, obstacles, syntheticWorkArea, PartSpacing, angle);
|
||||
}
|
||||
if (moved < 0.01) break;
|
||||
}
|
||||
|
||||
var finalBox = cell.GetBoundingBox();
|
||||
var area = finalBox.Width * finalBox.Length;
|
||||
|
||||
if (area < bestArea)
|
||||
{
|
||||
bestArea = area;
|
||||
bestCell = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCell != null)
|
||||
{
|
||||
cellView.Plate.Parts.Clear();
|
||||
foreach (var part in bestCell)
|
||||
cellView.Plate.Parts.Add(part);
|
||||
cellView.ZoomToFit();
|
||||
RebuildPreview();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Cursor = Cursors.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplyClick(object sender, EventArgs e)
|
||||
{
|
||||
if (previewView.Plate.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
var choice = MessageBox.Show(
|
||||
"Apply pattern to current plate?\n\nYes = Current plate (clears existing parts)\nNo = New plate",
|
||||
"Apply Pattern",
|
||||
MessageBoxButtons.YesNoCancel,
|
||||
MessageBoxIcon.Question);
|
||||
|
||||
if (choice == DialogResult.Cancel)
|
||||
return;
|
||||
|
||||
// Rebuild a fresh set of tiled parts for the caller
|
||||
var cellParts = cellView.Plate.Parts.ToList();
|
||||
var tiledParts = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||
|
||||
Result = new PatternTileResult
|
||||
{
|
||||
Parts = tiledParts,
|
||||
Target = choice == DialogResult.Yes
|
||||
? PatternTileTarget.CurrentPlate
|
||||
: PatternTileTarget.NewPlate,
|
||||
PlateSize = plateSize
|
||||
};
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/PatternTileForm.cs OpenNest/Forms/PatternTileForm.Designer.cs
|
||||
git commit -m "feat(ui): add PatternTileForm dialog with unit cell editor and tile preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire up menu entry and apply logic in MainForm
|
||||
|
||||
Add a "Pattern Tile" menu item under Tools, wire it to open `PatternTileForm`, and handle the result by placing parts on the target plate.
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.Designer.cs` — add menu item
|
||||
- Modify: `OpenNest/Forms/MainForm.cs` — add click handler and apply logic
|
||||
|
||||
- [ ] **Step 1: Add menu item to MainForm.Designer.cs**
|
||||
|
||||
In the `InitializeComponent` method:
|
||||
|
||||
1. Add field declaration at end of class (near the other `mnuTools*` fields):
|
||||
```csharp
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
|
||||
```
|
||||
|
||||
2. In `InitializeComponent`, add initialization (near the other `mnuTools*` instantiations):
|
||||
```csharp
|
||||
this.mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
|
||||
```
|
||||
|
||||
3. Add to the `mnuTools.DropDownItems` array — insert `mnuToolsPatternTile` after `mnuToolsBestFitViewer`:
|
||||
```csharp
|
||||
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign,
|
||||
toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement,
|
||||
toolStripMenuItem15, mnuToolsOptions });
|
||||
```
|
||||
|
||||
4. Add configuration block:
|
||||
```csharp
|
||||
// mnuToolsPatternTile
|
||||
this.mnuToolsPatternTile.Name = "mnuToolsPatternTile";
|
||||
this.mnuToolsPatternTile.Size = new System.Drawing.Size(214, 22);
|
||||
this.mnuToolsPatternTile.Text = "Pattern Tile";
|
||||
this.mnuToolsPatternTile.Click += PatternTile_Click;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add click handler and apply logic to MainForm.cs**
|
||||
|
||||
Add in the `#region Tools Menu Events` section, after `BestFitViewer_Click`:
|
||||
|
||||
```csharp
|
||||
private void PatternTile_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
if (activeForm.Nest.Drawings.Count == 0)
|
||||
{
|
||||
MessageBox.Show("No drawings available.", "Pattern Tile",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
using (var form = new PatternTileForm(activeForm.Nest))
|
||||
{
|
||||
if (form.ShowDialog(this) != DialogResult.OK || form.Result == null)
|
||||
return;
|
||||
|
||||
var result = form.Result;
|
||||
|
||||
if (result.Target == PatternTileTarget.CurrentPlate)
|
||||
{
|
||||
activeForm.PlateView.Plate.Parts.Clear();
|
||||
|
||||
foreach (var part in result.Parts)
|
||||
activeForm.PlateView.Plate.Parts.Add(part);
|
||||
|
||||
activeForm.PlateView.ZoomToFit();
|
||||
}
|
||||
else
|
||||
{
|
||||
var plate = activeForm.Nest.CreatePlate();
|
||||
plate.Size = result.PlateSize;
|
||||
|
||||
foreach (var part in result.Parts)
|
||||
plate.Parts.Add(part);
|
||||
|
||||
activeForm.LoadLastPlate();
|
||||
}
|
||||
|
||||
activeForm.Nest.UpdateDrawingQuantities();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and manually test**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds. Launch the app, create a nest, import some DXFs, then Tools > Pattern Tile opens the form.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
|
||||
git commit -m "feat(ui): wire Pattern Tile menu item and apply logic in MainForm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manual testing and polish
|
||||
|
||||
Final integration testing and any adjustments.
|
||||
|
||||
**Files:**
|
||||
- Possibly modify: `OpenNest/Forms/PatternTileForm.cs`, `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||
|
||||
- [ ] **Step 1: End-to-end test workflow**
|
||||
|
||||
1. Launch the app, create a new nest
|
||||
2. Import 2+ DXF drawings
|
||||
3. Open Tools > Pattern Tile
|
||||
4. Select Drawing A and Drawing B
|
||||
5. Verify parts appear in left panel, can be dragged
|
||||
6. Verify compaction on mouse release closes gaps
|
||||
7. Verify tile preview updates on the right
|
||||
8. Change plate size — verify preview updates
|
||||
9. Change spacing — verify preview updates
|
||||
10. Click Auto-Arrange — verify it picks a tight arrangement
|
||||
11. Click Apply > Yes (current plate) — verify parts placed
|
||||
12. Reopen, Apply > No (new plate) — verify new plate created with parts
|
||||
13. Test single drawing only (one dropdown set, other on "(none)")
|
||||
14. Test same drawing in both dropdowns
|
||||
|
||||
- [ ] **Step 2: Fix any issues found during testing**
|
||||
|
||||
Address any layout, interaction, or rendering issues discovered.
|
||||
|
||||
- [ ] **Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(ui): polish PatternTileForm after manual testing"
|
||||
```
|
||||
@@ -1,917 +0,0 @@
|
||||
# Pluggable Fill Strategies Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract the four hard-wired fill phases from `DefaultNestEngine.FindBestFill` into pluggable `IFillStrategy` implementations behind a pipeline orchestrator.
|
||||
|
||||
**Architecture:** Each fill phase (Pairs, RectBestFit, Extents, Linear) becomes a stateless `IFillStrategy` adapter around its existing filler class. A `FillContext` carries inputs and pipeline state. `FillStrategyRegistry` discovers strategies via reflection. `DefaultNestEngine.FindBestFill` is replaced by `RunPipeline` which iterates strategies in order.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, xUnit (OpenNest.Tests)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-pluggable-fill-strategies-design.md`
|
||||
|
||||
**Deliberate behavioral change:** The phase execution order changes from Pairs/Linear/RectBestFit/Extents to Pairs/RectBestFit/Extents/Linear. Linear is moved last because it is the most expensive phase and rarely wins. The final result is equivalent (the pipeline always picks the globally best result), but intermediate progress reports during the fill will differ.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `NestPhase.Custom` enum value
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:6-13`
|
||||
|
||||
- [ ] **Step 1: Add Custom to NestPhase enum**
|
||||
|
||||
In `OpenNest.Engine/NestProgress.cs`, add `Custom` after `Extents`:
|
||||
|
||||
```csharp
|
||||
public enum NestPhase
|
||||
{
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Extents,
|
||||
Custom
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Custom to FormatPhaseName in NestEngineBase**
|
||||
|
||||
In `OpenNest.Engine/NestEngineBase.cs`, add a case in `FormatPhaseName`:
|
||||
|
||||
```csharp
|
||||
case NestPhase.Custom: return "Custom";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Run existing tests to verify no regression**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add NestPhase.Custom for plugin fill strategies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `IFillStrategy` and `FillContext`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/IFillStrategy.cs`
|
||||
- Create: `OpenNest.Engine/Strategies/FillContext.cs`
|
||||
|
||||
- [ ] **Step 1: Create the Strategies directory**
|
||||
|
||||
Verify `OpenNest.Engine/Strategies/` exists (create if needed).
|
||||
|
||||
- [ ] **Step 2: Write IFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/IFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IFillStrategy
|
||||
{
|
||||
string Name { get; }
|
||||
NestPhase Phase { get; }
|
||||
int Order { get; }
|
||||
List<Part> Fill(FillContext context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write FillContext.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillContext.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class FillContext
|
||||
{
|
||||
public NestItem Item { get; init; }
|
||||
public Box WorkArea { get; init; }
|
||||
public Plate Plate { get; init; }
|
||||
public int PlateNumber { get; init; }
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
|
||||
public List<Part> CurrentBest { get; set; }
|
||||
public FillScore CurrentBestScore { get; set; }
|
||||
public NestPhase WinnerPhase { get; set; }
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public Dictionary<string, object> SharedState { get; } = new();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add IFillStrategy interface and FillContext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `FillStrategyRegistry`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`
|
||||
|
||||
- [ ] **Step 1: Write FillStrategyRegistry.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class FillStrategyRegistry
|
||||
{
|
||||
private static readonly List<IFillStrategy> strategies = new();
|
||||
private static List<IFillStrategy> sorted;
|
||||
|
||||
static FillStrategyRegistry()
|
||||
{
|
||||
LoadFrom(typeof(FillStrategyRegistry).Assembly);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= strategies.OrderBy(s => s.Order).ToList();
|
||||
|
||||
public static void LoadFrom(Assembly assembly)
|
||||
{
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || type.IsInterface || !typeof(IFillStrategy).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(Type.EmptyTypes);
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Skipping {type.Name}: no parameterless constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var instance = (IFillStrategy)ctor.Invoke(null);
|
||||
|
||||
if (strategies.Any(s => s.Name.Equals(instance.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Duplicate strategy '{instance.Name}' skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
strategies.Add(instance);
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Registered: {instance.Name} (Order={instance.Order})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
LoadFrom(assembly);
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Loaded plugin assembly: {Path.GetFileName(dll)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Failed to load {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded (no strategies registered yet — static constructor finds nothing)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add FillStrategyRegistry with reflection-based discovery
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `FillHelpers` from `DefaultNestEngine`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/FillHelpers.cs`
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs` (remove `BuildRotatedPattern` and `FillPattern`)
|
||||
- Modify: `OpenNest.Engine/PairFiller.cs` (update references)
|
||||
|
||||
- [ ] **Step 1: Create FillHelpers.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillHelpers.cs` with the two static methods moved from `DefaultNestEngine`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class FillHelpers
|
||||
{
|
||||
public static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||
{
|
||||
var pattern = new Pattern();
|
||||
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
||||
|
||||
foreach (var part in groupParts)
|
||||
{
|
||||
var clone = (Part)part.Clone();
|
||||
clone.UpdateBounds();
|
||||
|
||||
if (!angle.IsEqualTo(0))
|
||||
clone.Rotate(angle, center);
|
||||
|
||||
pattern.Parts.Add(clone);
|
||||
}
|
||||
|
||||
pattern.UpdateBounds();
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||
|
||||
Parallel.ForEach(angles, angle =>
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > 0)
|
||||
results.Add((h, FillScore.Compute(h, workArea)));
|
||||
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
if (v != null && v.Count > 0)
|
||||
results.Add((v, FillScore.Compute(v, workArea)));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var res in results)
|
||||
{
|
||||
if (best == null || res.Score > bestScore)
|
||||
{
|
||||
best = res.Parts;
|
||||
bestScore = res.Score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update DefaultNestEngine to delegate to FillHelpers**
|
||||
|
||||
In `OpenNest.Engine/DefaultNestEngine.cs`:
|
||||
- Change `BuildRotatedPattern` and `FillPattern` to forward to `FillHelpers`:
|
||||
|
||||
```csharp
|
||||
internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||
=> FillHelpers.BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
=> FillHelpers.FillPattern(engine, groupParts, angles, workArea);
|
||||
```
|
||||
|
||||
This preserves the existing `internal static` API so `PairFiller` and `Fill(List<Part> groupParts, ...)` don't break.
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||
Expected: Build succeeded, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
refactor(engine): extract FillHelpers from DefaultNestEngine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create `PairsFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/PairsFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write PairsFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/PairsFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.BestFit;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PairsFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Pairs";
|
||||
public NestPhase Phase => NestPhase.Pairs;
|
||||
public int Order => 100;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
|
||||
// Cache hit — PairFiller already called GetOrCompute internally.
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
context.Item.Drawing, context.Plate.Size.Length,
|
||||
context.Plate.Size.Width, context.Plate.PartSpacing);
|
||||
context.SharedState["BestFits"] = bestFits;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded (strategy auto-registered by `FillStrategyRegistry`)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add PairsFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create `RectBestFitStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write RectBestFitStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.RectanglePacking;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RectBestFitStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "RectBestFit";
|
||||
public NestPhase Phase => NestPhase.RectBestFit;
|
||||
public int Order => 200;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
|
||||
|
||||
var engine = new FillBestFit(bin);
|
||||
engine.Fill(binItem);
|
||||
|
||||
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add RectBestFitStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Create `ExtentsFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write ExtentsFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class ExtentsFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Extents";
|
||||
public NestPhase Phase => NestPhase.Extents;
|
||||
public int Order => 300;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
||||
|
||||
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||
? (double)rot
|
||||
: RotationAnalysis.FindBestRotation(context.Item);
|
||||
|
||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
var bestFits = context.SharedState.TryGetValue("BestFits", out var cached)
|
||||
? (List<BestFitResult>)cached
|
||||
: null;
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
var result = filler.Fill(context.Item.Drawing, angle,
|
||||
context.PlateNumber, context.Token, context.Progress, bestFits);
|
||||
if (result != null && result.Count > 0)
|
||||
{
|
||||
var score = FillScore.Compute(result, context.WorkArea);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = result;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add ExtentsFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Create `LinearFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/LinearFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write LinearFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/LinearFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class LinearFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Linear";
|
||||
public NestPhase Phase => NestPhase.Linear;
|
||||
public int Order => 400;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var angles = context.SharedState.TryGetValue("AngleCandidates", out var cached)
|
||||
? (List<double>)cached
|
||||
: new List<double> { 0, Angle.HalfPI };
|
||||
|
||||
var workArea = context.WorkArea;
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
for (var ai = 0; ai < angles.Count; ai++)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var angle = angles[ai];
|
||||
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
|
||||
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
|
||||
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
|
||||
|
||||
var angleDeg = Angle.ToDegrees(angle);
|
||||
|
||||
if (h != null && h.Count > 0)
|
||||
{
|
||||
var scoreH = FillScore.Compute(h, workArea);
|
||||
context.AngleResults.Add(new AngleResult
|
||||
{
|
||||
AngleDeg = angleDeg,
|
||||
Direction = NestDirection.Horizontal,
|
||||
PartCount = h.Count
|
||||
});
|
||||
|
||||
if (best == null || scoreH > bestScore)
|
||||
{
|
||||
best = h;
|
||||
bestScore = scoreH;
|
||||
}
|
||||
}
|
||||
|
||||
if (v != null && v.Count > 0)
|
||||
{
|
||||
var scoreV = FillScore.Compute(v, workArea);
|
||||
context.AngleResults.Add(new AngleResult
|
||||
{
|
||||
AngleDeg = angleDeg,
|
||||
Direction = NestDirection.Vertical,
|
||||
PartCount = v.Count
|
||||
});
|
||||
|
||||
if (best == null || scoreV > bestScore)
|
||||
{
|
||||
best = v;
|
||||
bestScore = scoreV;
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||
context.PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add LinearFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire `RunPipeline` into `DefaultNestEngine`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
This is the key refactoring step. Replace `FindBestFill` with `RunPipeline` and delete dead code.
|
||||
|
||||
- [ ] **Step 1: Replace FindBestFill with RunPipeline**
|
||||
|
||||
Delete the entire `FindBestFill` method (the large `private List<Part> FindBestFill(...)` method). Replace with:
|
||||
|
||||
```csharp
|
||||
private void RunPipeline(FillContext context)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
|
||||
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||
context.SharedState["AngleCandidates"] = angles;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = strategy.Fill(context);
|
||||
sw.Stop();
|
||||
|
||||
var phaseResult = new PhaseResult(
|
||||
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds);
|
||||
context.PhaseResults.Add(phaseResult);
|
||||
|
||||
// Keep engine's PhaseResults in sync so BuildProgressSummary() works
|
||||
// during progress reporting.
|
||||
PhaseResults.Add(phaseResult);
|
||||
|
||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||
result, context.WorkArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||
}
|
||||
|
||||
angleBuilder.RecordProductive(context.AngleResults);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update Fill(NestItem, ...) to use RunPipeline**
|
||||
|
||||
Replace the body of the `Fill(NestItem item, Box workArea, ...)` override:
|
||||
|
||||
```csharp
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
PhaseResults.Clear();
|
||||
AngleResults.Clear();
|
||||
|
||||
var context = new FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = Plate,
|
||||
PlateNumber = PlateNumber,
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
};
|
||||
|
||||
RunPipeline(context);
|
||||
|
||||
// PhaseResults already synced during RunPipeline.
|
||||
AngleResults.AddRange(context.AngleResults);
|
||||
WinnerPhase = context.WinnerPhase;
|
||||
|
||||
var best = context.CurrentBest ?? new List<Part>();
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete FillRectangleBestFit**
|
||||
|
||||
Remove the private `FillRectangleBestFit` method entirely. It is now inside `RectBestFitStrategy`.
|
||||
|
||||
Note: `Fill(List<Part> groupParts, ...)` also calls `FillRectangleBestFit` at line 125. Inline the logic there:
|
||||
|
||||
```csharp
|
||||
var binItem = BinConverter.ToItem(nestItem, Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
||||
var rectEngine = new FillBestFit(bin);
|
||||
rectEngine.Fill(binItem);
|
||||
var rectResult = BinConverter.ToParts(bin, new List<NestItem> { nestItem });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete QuickFillCount**
|
||||
|
||||
Remove the `QuickFillCount` method entirely (dead code, zero callers).
|
||||
|
||||
- [ ] **Step 5: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||
Expected: Build succeeded, **all existing tests pass** — this is the critical regression gate.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
refactor(engine): replace FindBestFill with strategy pipeline
|
||||
|
||||
DefaultNestEngine.Fill(NestItem, ...) now delegates to RunPipeline
|
||||
which iterates FillStrategyRegistry.Strategies in order.
|
||||
|
||||
Removed: FindBestFill, FillRectangleBestFit, QuickFillCount.
|
||||
Kept: AngleCandidateBuilder, ForceFullAngleSweep, group-fill overload.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Add pipeline-specific tests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`
|
||||
- Create: `OpenNest.Tests/Strategies/FillPipelineTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write FillStrategyRegistryTests.cs**
|
||||
|
||||
Create `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`:
|
||||
|
||||
```csharp
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
|
||||
public class FillStrategyRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_DiscoversBuiltInStrategies()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
|
||||
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
|
||||
Assert.Contains(strategies, s => s.Name == "Pairs");
|
||||
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
||||
Assert.Contains(strategies, s => s.Name == "Extents");
|
||||
Assert.Contains(strategies, s => s.Name == "Linear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_StrategiesAreOrderedByOrder()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
|
||||
for (var i = 1; i < strategies.Count; i++)
|
||||
Assert.True(strategies[i].Order >= strategies[i - 1].Order,
|
||||
$"Strategy '{strategies[i].Name}' (Order={strategies[i].Order}) should not precede '{strategies[i - 1].Name}' (Order={strategies[i - 1].Order})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_LinearIsLast()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
var last = strategies[strategies.Count - 1];
|
||||
|
||||
Assert.Equal("Linear", last.Name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write FillPipelineTests.cs**
|
||||
|
||||
Create `OpenNest.Tests/Strategies/FillPipelineTests.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
|
||||
public class FillPipelineTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_PopulatesPhaseResults()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
|
||||
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(engine.PhaseResults.Count >= 4,
|
||||
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_SetsWinnerPhase()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(parts.Count > 0);
|
||||
// WinnerPhase should be set to one of the built-in phases
|
||||
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||
engine.WinnerPhase == NestPhase.Linear ||
|
||||
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||
engine.WinnerPhase == NestPhase.Extents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_RespectsCancellation()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Pre-cancelled token should return empty or partial results without throwing
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, cts.Token);
|
||||
|
||||
// Should not throw — graceful degradation
|
||||
Assert.NotNull(parts);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass (old and new)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
test(engine): add FillStrategyRegistry and pipeline tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v normal`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Build entire solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, no warnings from Engine project
|
||||
|
||||
- [ ] **Step 3: Verify EngineRefactorSmokeTests still pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter EngineRefactorSmokeTests`
|
||||
Expected: All 5 smoke tests pass (DefaultEngine_FillNestItem, DefaultEngine_FillGroupParts, DefaultEngine_ForceFullAngleSweep, StripEngine_Nest, BruteForceRunner_StillWorks)
|
||||
|
||||
- [ ] **Step 4: Verify file layout matches spec**
|
||||
|
||||
Confirm these files exist under `OpenNest.Engine/Strategies/`:
|
||||
- `IFillStrategy.cs`
|
||||
- `FillContext.cs`
|
||||
- `FillStrategyRegistry.cs`
|
||||
- `FillHelpers.cs`
|
||||
- `PairsFillStrategy.cs`
|
||||
- `RectBestFitStrategy.cs`
|
||||
- `ExtentsFillStrategy.cs`
|
||||
- `LinearFillStrategy.cs`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,361 +0,0 @@
|
||||
# Refactor Compactor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Prune dead code from Compactor and deduplicate the Push overloads into a single scanning core.
|
||||
|
||||
**Architecture:** Delete 6 unused methods (Compact, CompactLoop, SavePositions, RestorePositions, CompactIndividual, CompactIndividualLoop). Unify the `Push(... PushDirection)` core overload to convert its PushDirection to a unit Vector and delegate to the `Push(... Vector)` overload, eliminating ~60 lines of duplicated obstacle scanning logic. PushBoundingBox stays separate since it's a fundamentally different algorithm (no geometry lines).
|
||||
|
||||
**Tech Stack:** C# / .NET 8
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Write Compactor Push tests as a safety net
|
||||
|
||||
No Compactor tests exist. Before changing anything, add tests for the public Push methods that have live callers: `Push(parts, obstacles, workArea, spacing, PushDirection)` and `Push(parts, obstacles, workArea, spacing, angle)`.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/CompactorTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write tests for Push with PushDirection**
|
||||
|
||||
```csharp
|
||||
using OpenNest;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Tests
|
||||
{
|
||||
public class CompactorTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h)
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("rect", pgm);
|
||||
}
|
||||
|
||||
private static Part MakeRectPart(double x, double y, double w, double h)
|
||||
{
|
||||
var drawing = MakeRectDrawing(w, h);
|
||||
var part = new Part(drawing) { Location = new Vector(x, y) };
|
||||
part.UpdateBounds();
|
||||
return part;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_Left_MovesPartTowardEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
var distance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||
|
||||
Assert.True(distance > 0);
|
||||
Assert.True(part.BoundingBox.Left < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_Left_StopsAtObstacle()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var obstacle = MakeRectPart(0, 0, 10, 10);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part> { obstacle };
|
||||
|
||||
Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||
|
||||
Assert.True(part.BoundingBox.Left >= obstacle.BoundingBox.Right - 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_Down_MovesPartTowardEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(0, 50, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
var distance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Down);
|
||||
|
||||
Assert.True(distance > 0);
|
||||
Assert.True(part.BoundingBox.Bottom < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_ReturnsZero_WhenAlreadyAtEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(0, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
var distance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||
|
||||
Assert.Equal(0, distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_WithSpacing_MaintainsGap()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var obstacle = MakeRectPart(0, 0, 10, 10);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part> { obstacle };
|
||||
|
||||
Compactor.Push(moving, obstacles, workArea, 2, PushDirection.Left);
|
||||
|
||||
Assert.True(part.BoundingBox.Left >= obstacle.BoundingBox.Right + 2 - 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write tests for Push with angle (Vector-based)**
|
||||
|
||||
Add to the same file:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Push_AngleLeft_MovesPartTowardEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
// angle = π = push left
|
||||
var distance = Compactor.Push(moving, obstacles, workArea, 0, System.Math.PI);
|
||||
|
||||
Assert.True(distance > 0);
|
||||
Assert.True(part.BoundingBox.Left < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_AngleDown_MovesPartTowardEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(0, 50, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
// angle = 3π/2 = push down
|
||||
var distance = Compactor.Push(moving, obstacles, workArea, 0, 3 * System.Math.PI / 2);
|
||||
|
||||
Assert.True(distance > 0);
|
||||
Assert.True(part.BoundingBox.Bottom < 1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write tests for PushBoundingBox**
|
||||
|
||||
Add to the same file:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void PushBoundingBox_Left_MovesPartTowardEdge()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part>();
|
||||
|
||||
var distance = Compactor.PushBoundingBox(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||
|
||||
Assert.True(distance > 0);
|
||||
Assert.True(part.BoundingBox.Left < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PushBoundingBox_StopsAtObstacle()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var obstacle = MakeRectPart(0, 0, 10, 10);
|
||||
var part = MakeRectPart(50, 0, 10, 10);
|
||||
var moving = new List<Part> { part };
|
||||
var obstacles = new List<Part> { obstacle };
|
||||
|
||||
Compactor.PushBoundingBox(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||
|
||||
Assert.True(part.BoundingBox.Left >= obstacle.BoundingBox.Right - 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~CompactorTests" -v n`
|
||||
Expected: All tests PASS (these test existing behavior before refactoring)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/CompactorTests.cs
|
||||
git commit -m "test: add Compactor safety-net tests before refactor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Delete dead code
|
||||
|
||||
Remove the 6 methods that have zero live callers: `Compact`, `CompactLoop`, `SavePositions`, `RestorePositions`, `CompactIndividual`, `CompactIndividualLoop`. Also remove the unused `contactGap` variable (line 181) and the misplaced XML doc comment above `RepeatThreshold` (lines 16-20, describes the deleted `Compact` method).
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/Compactor.cs`
|
||||
|
||||
- [ ] **Step 1: Delete SavePositions and RestorePositions**
|
||||
|
||||
Delete `SavePositions` (lines 64-70) and `RestorePositions` (lines 72-76). These are only used by `Compact` and `CompactIndividual`.
|
||||
|
||||
- [ ] **Step 2: Delete Compact and CompactLoop**
|
||||
|
||||
Delete the `Compact` method (lines 24-44) and `CompactLoop` method (lines 46-62). Zero callers.
|
||||
|
||||
- [ ] **Step 3: Delete CompactIndividual and CompactIndividualLoop**
|
||||
|
||||
Delete `CompactIndividual` (lines 312-332) and `CompactIndividualLoop` (lines 334-360). Only caller is a commented-out line in `StripNestEngine.cs:189`.
|
||||
|
||||
- [ ] **Step 4: Remove the commented-out caller in StripNestEngine**
|
||||
|
||||
In `OpenNest.Engine/StripNestEngine.cs`, delete the entire commented-out block (lines 186-194):
|
||||
```csharp
|
||||
// TODO: Compact strip parts individually to close geometry-based gaps.
|
||||
// Disabled pending investigation — remnant finder picks up gaps created
|
||||
// by compaction and scatters parts into them.
|
||||
// Compactor.CompactIndividual(bestParts, workArea, Plate.PartSpacing);
|
||||
//
|
||||
// var compactedBox = bestParts.Cast<IBoundable>().GetBoundingBox();
|
||||
// bestDim = direction == StripDirection.Bottom
|
||||
// ? compactedBox.Top - workArea.Y
|
||||
// : compactedBox.Right - workArea.X;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Clean up stale doc comment and dead variable**
|
||||
|
||||
Remove the orphaned XML doc comment above `RepeatThreshold` (lines 16-20 — it describes the deleted `Compact` method). Remove the `RepeatThreshold` and `MaxIterations` constants (only used by the deleted loop methods). Remove the unused `contactGap` variable from the `Push(... PushDirection)` method (line 181).
|
||||
|
||||
- [ ] **Step 6: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~CompactorTests" -v n`
|
||||
Expected: All tests PASS (deleted code was unused)
|
||||
|
||||
- [ ] **Step 7: Build full solution to verify no compilation errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/Compactor.cs OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "refactor(compactor): remove dead code — Compact, CompactIndividual, and helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deduplicate Push overloads
|
||||
|
||||
The `Push(... PushDirection)` core overload (lines 166-238) duplicates the obstacle scanning loop from `Push(... Vector)` (lines 102-164). Convert `PushDirection` to a unit `Vector` and delegate.
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/Compactor.cs`
|
||||
|
||||
- [ ] **Step 1: Replace the Push(... PushDirection) core overload**
|
||||
|
||||
Replace the full body of `Push(List<Part> movingParts, List<Part> obstacleParts, Box workArea, double partSpacing, PushDirection direction)` with a delegation to the Vector overload:
|
||||
|
||||
```csharp
|
||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||
Box workArea, double partSpacing, PushDirection direction)
|
||||
{
|
||||
var vector = SpatialQuery.DirectionToOffset(direction, 1.0);
|
||||
return Push(movingParts, obstacleParts, workArea, partSpacing, vector);
|
||||
}
|
||||
```
|
||||
|
||||
This works because `DirectionToOffset(Left, 1.0)` returns `(-1, 0)`, which is the unit vector for "push left" — exactly what `new Vector(Math.Cos(π), Math.Sin(π))` produces. The Vector overload already handles edge distance, obstacle scanning, geometry lines, and offset application identically.
|
||||
|
||||
- [ ] **Step 2: Update the angle-based Push to accept Vector directly**
|
||||
|
||||
Rename the existing `Push(... double angle)` core overload to accept a `Vector` direction instead of computing it internally. This avoids a redundant cos/sin when the PushDirection overload already provides a unit vector.
|
||||
|
||||
Change the signature from:
|
||||
```csharp
|
||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||
Box workArea, double partSpacing, double angle)
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||
Box workArea, double partSpacing, Vector direction)
|
||||
```
|
||||
|
||||
Remove the `var direction = new Vector(...)` line from the body since `direction` is now a parameter.
|
||||
|
||||
- [ ] **Step 3: Update the angle convenience overload to convert**
|
||||
|
||||
The convenience overload `Push(List<Part> movingParts, Plate plate, double angle)` must now convert the angle to a Vector before calling the core:
|
||||
|
||||
```csharp
|
||||
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
||||
{
|
||||
var obstacleParts = plate.Parts
|
||||
.Where(p => !movingParts.Contains(p))
|
||||
.ToList();
|
||||
|
||||
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~CompactorTests" -v n`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 5: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors. All callers in FillExtents, ActionClone, PlateView, PatternTileForm compile without changes — their call signatures are unchanged.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/Compactor.cs
|
||||
git commit -m "refactor(compactor): deduplicate Push — PushDirection delegates to Vector overload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final cleanup and verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/Compactor.cs` (if needed)
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v n`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 2: Verify Compactor is clean**
|
||||
|
||||
The final Compactor should have 6 public methods:
|
||||
1. `Push(parts, plate, PushDirection)` — convenience, extracts plate fields
|
||||
2. `Push(parts, plate, angle)` — convenience, converts angle to Vector
|
||||
3. `Push(parts, obstacles, workArea, spacing, PushDirection)` — converts to Vector, delegates
|
||||
4. `Push(parts, obstacles, workArea, spacing, Vector)` — the single scanning core
|
||||
5. `PushBoundingBox(parts, plate, direction)` — convenience
|
||||
6. `PushBoundingBox(parts, obstacles, workArea, spacing, direction)` — BB-only core
|
||||
|
||||
Plus one constant: `ChordTolerance`.
|
||||
|
||||
File should be ~110-120 lines, down from 362.
|
||||
@@ -1,660 +0,0 @@
|
||||
# Two-Bucket Preview Parts Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Split PlateView nesting preview into stationary (overall best) and active (current strategy) layers so the preview never regresses and acceptance always uses the engine's result.
|
||||
|
||||
**Architecture:** Add `IsOverallBest` flag to `NestProgress` so the engine can distinguish overall-best reports from strategy-local progress. PlateView maintains two `List<LayoutPart>` buckets drawn at different opacities. Acceptance uses the engine's returned parts directly, decoupling preview from acceptance.
|
||||
|
||||
**Tech Stack:** C# / .NET 8 / WinForms / System.Drawing
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-two-bucket-preview-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add IsOverallBest to NestProgress and ReportProgress
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:37-49`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:188-236`
|
||||
|
||||
- [ ] **Step 1: Add property to NestProgress**
|
||||
|
||||
In `OpenNest.Engine/NestProgress.cs`, add after line 48 (`ActiveWorkArea`):
|
||||
|
||||
```csharp
|
||||
public bool IsOverallBest { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add parameter to ReportProgress**
|
||||
|
||||
In `OpenNest.Engine/NestEngineBase.cs`, change the `ReportProgress` signature (line 188) to:
|
||||
|
||||
```csharp
|
||||
internal static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description,
|
||||
bool isOverallBest = false)
|
||||
```
|
||||
|
||||
In the same method, add `IsOverallBest = isOverallBest` to the `NestProgress` initializer (after line 235 `ActiveWorkArea = workArea`):
|
||||
|
||||
```csharp
|
||||
IsOverallBest = isOverallBest,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded, 0 errors. Existing callers use the default `false`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add IsOverallBest flag to NestProgress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Flag overall-best reports in DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:55-58` (final report in Fill(NestItem))
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:83-85` (final report in Fill(List<Part>))
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:132-139` (RunPipeline strategy loop)
|
||||
|
||||
- [ ] **Step 1: Update RunPipeline — replace conditional report with unconditional overall-best report**
|
||||
|
||||
In `RunPipeline` (line 132-139), change from:
|
||||
|
||||
```csharp
|
||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||
result, context.WorkArea, BuildProgressSummary());
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
}
|
||||
|
||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
{
|
||||
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
|
||||
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Flag final report in Fill(NestItem, Box, ...)**
|
||||
|
||||
In `Fill(NestItem item, Box workArea, ...)` (line 58), change:
|
||||
|
||||
```csharp
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Flag final report in Fill(List<Part>, Box, ...)**
|
||||
|
||||
In `Fill(List<Part> groupParts, Box workArea, ...)` (line 85), change:
|
||||
|
||||
```csharp
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(engine): flag overall-best progress reports in DefaultNestEngine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add active preview style to ColorScheme
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/ColorScheme.cs:58-61` (pen/brush declarations)
|
||||
- Modify: `OpenNest/ColorScheme.cs:160-176` (PreviewPartColor setter)
|
||||
|
||||
- [ ] **Step 1: Add pen/brush declarations**
|
||||
|
||||
In `ColorScheme.cs`, after line 60 (`PreviewPartBrush`), add:
|
||||
|
||||
```csharp
|
||||
public Pen ActivePreviewPartPen { get; private set; }
|
||||
|
||||
public Brush ActivePreviewPartBrush { get; private set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create resources in PreviewPartColor setter**
|
||||
|
||||
In the `PreviewPartColor` setter (lines 160-176), change from:
|
||||
|
||||
```csharp
|
||||
set
|
||||
{
|
||||
previewPartColor = value;
|
||||
|
||||
if (PreviewPartPen != null)
|
||||
PreviewPartPen.Dispose();
|
||||
|
||||
if (PreviewPartBrush != null)
|
||||
PreviewPartBrush.Dispose();
|
||||
|
||||
PreviewPartPen = new Pen(value, 1);
|
||||
PreviewPartBrush = new SolidBrush(Color.FromArgb(60, value));
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
set
|
||||
{
|
||||
previewPartColor = value;
|
||||
|
||||
if (PreviewPartPen != null)
|
||||
PreviewPartPen.Dispose();
|
||||
|
||||
if (PreviewPartBrush != null)
|
||||
PreviewPartBrush.Dispose();
|
||||
|
||||
if (ActivePreviewPartPen != null)
|
||||
ActivePreviewPartPen.Dispose();
|
||||
|
||||
if (ActivePreviewPartBrush != null)
|
||||
ActivePreviewPartBrush.Dispose();
|
||||
|
||||
PreviewPartPen = new Pen(value, 1);
|
||||
PreviewPartBrush = new SolidBrush(Color.FromArgb(60, value));
|
||||
ActivePreviewPartPen = new Pen(Color.FromArgb(128, value), 1);
|
||||
ActivePreviewPartBrush = new SolidBrush(Color.FromArgb(30, value));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest/OpenNest.csproj`
|
||||
Expected: Build succeeded, 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(ui): add active preview brush/pen to ColorScheme
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Two-bucket preview parts in PlateView
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Controls/PlateView.cs`
|
||||
|
||||
This task replaces the single `temporaryParts` list with `stationaryParts` and `activeParts`, updates the public API, drawing, and all internal references.
|
||||
|
||||
- [ ] **Step 1: Replace field and add new list**
|
||||
|
||||
Change line 34:
|
||||
|
||||
```csharp
|
||||
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
private List<LayoutPart> stationaryParts = new List<LayoutPart>();
|
||||
private List<LayoutPart> activeParts = new List<LayoutPart>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SetPlate (line 152-153)**
|
||||
|
||||
Change:
|
||||
|
||||
```csharp
|
||||
temporaryParts.Clear();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update Refresh (line 411)**
|
||||
|
||||
Change:
|
||||
|
||||
```csharp
|
||||
temporaryParts.ForEach(p => p.Update(this));
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
stationaryParts.ForEach(p => p.Update(this));
|
||||
activeParts.ForEach(p => p.Update(this));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update UpdateMatrix (line 1085)**
|
||||
|
||||
Change:
|
||||
|
||||
```csharp
|
||||
temporaryParts.ForEach(p => p.Update(this));
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
stationaryParts.ForEach(p => p.Update(this));
|
||||
activeParts.ForEach(p => p.Update(this));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace the temporary parts drawing block in DrawParts (lines 506-522)**
|
||||
|
||||
Change:
|
||||
|
||||
```csharp
|
||||
// Draw temporary (preview) parts
|
||||
for (var i = 0; i < temporaryParts.Count; i++)
|
||||
{
|
||||
var temp = temporaryParts[i];
|
||||
|
||||
if (temp.IsDirty)
|
||||
temp.Update(this);
|
||||
|
||||
var path = temp.Path;
|
||||
var pathBounds = path.GetBounds();
|
||||
|
||||
if (!pathBounds.IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(ColorScheme.PreviewPartBrush, path);
|
||||
g.DrawPath(ColorScheme.PreviewPartPen, path);
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
// Draw stationary preview parts (overall best — full opacity)
|
||||
for (var i = 0; i < stationaryParts.Count; i++)
|
||||
{
|
||||
var part = stationaryParts[i];
|
||||
|
||||
if (part.IsDirty)
|
||||
part.Update(this);
|
||||
|
||||
var path = part.Path;
|
||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(ColorScheme.PreviewPartBrush, path);
|
||||
g.DrawPath(ColorScheme.PreviewPartPen, path);
|
||||
}
|
||||
|
||||
// Draw active preview parts (current strategy — reduced opacity)
|
||||
for (var i = 0; i < activeParts.Count; i++)
|
||||
{
|
||||
var part = activeParts[i];
|
||||
|
||||
if (part.IsDirty)
|
||||
part.Update(this);
|
||||
|
||||
var path = part.Path;
|
||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(ColorScheme.ActivePreviewPartBrush, path);
|
||||
g.DrawPath(ColorScheme.ActivePreviewPartPen, path);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Replace public API methods (lines 882-910)**
|
||||
|
||||
Replace `SetTemporaryParts`, `ClearTemporaryParts`, and `AcceptTemporaryParts` with:
|
||||
|
||||
```csharp
|
||||
public void SetStationaryParts(List<Part> parts)
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
|
||||
if (parts != null)
|
||||
{
|
||||
foreach (var part in parts)
|
||||
stationaryParts.Add(LayoutPart.Create(part, this));
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void SetActiveParts(List<Part> parts)
|
||||
{
|
||||
activeParts.Clear();
|
||||
|
||||
if (parts != null)
|
||||
{
|
||||
foreach (var part in parts)
|
||||
activeParts.Add(LayoutPart.Create(part, this));
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void ClearPreviewParts()
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void AcceptPreviewParts(List<Part> parts)
|
||||
{
|
||||
if (parts != null)
|
||||
{
|
||||
foreach (var part in parts)
|
||||
Plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update FillWithProgress (lines 912-957)**
|
||||
|
||||
Change the progress callback (lines 918-923):
|
||||
|
||||
```csharp
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
SetTemporaryParts(p.BestParts);
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
SetStationaryParts(p.BestParts);
|
||||
else
|
||||
SetActiveParts(p.BestParts);
|
||||
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
Change the acceptance block (lines 933-943):
|
||||
|
||||
```csharp
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
{
|
||||
SetTemporaryParts(parts);
|
||||
AcceptTemporaryParts();
|
||||
sw.Stop();
|
||||
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearTemporaryParts();
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
{
|
||||
AcceptPreviewParts(parts);
|
||||
sw.Stop();
|
||||
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearPreviewParts();
|
||||
}
|
||||
```
|
||||
|
||||
Change the catch block (line 949):
|
||||
|
||||
```csharp
|
||||
ClearTemporaryParts();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
ClearPreviewParts();
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest/OpenNest.csproj`
|
||||
Expected: Build errors in `MainForm.cs` (still references old API). That is expected — Task 5 fixes it.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```
|
||||
feat(ui): two-bucket preview parts in PlateView
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update MainForm progress callbacks and acceptance
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.cs`
|
||||
|
||||
Three progress callback sites and their acceptance points need updating.
|
||||
|
||||
- [ ] **Step 1: Update auto-nest callback (RunAutoNest_Click, line 827)**
|
||||
|
||||
Change:
|
||||
|
||||
```csharp
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
activeForm.PlateView.SetStationaryParts(p.BestParts);
|
||||
else
|
||||
activeForm.PlateView.SetActiveParts(p.BestParts);
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
Change `ClearTemporaryParts()` on line 866 to `ClearPreviewParts()`.
|
||||
|
||||
Change `ClearTemporaryParts()` in the catch block (line 884) to `ClearPreviewParts()`.
|
||||
|
||||
- [ ] **Step 2: Update fill-plate callback (FillPlate_Click, line 962)**
|
||||
|
||||
Replace the progress setup (lines 962-976):
|
||||
|
||||
```csharp
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: false);
|
||||
var highWaterMark = 0;
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.BestParts != null && p.BestPartCount >= highWaterMark)
|
||||
{
|
||||
highWaterMark = p.BestPartCount;
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
}
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: false);
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
activeForm.PlateView.SetStationaryParts(p.BestParts);
|
||||
else
|
||||
activeForm.PlateView.SetActiveParts(p.BestParts);
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
Change acceptance (line 990-993):
|
||||
|
||||
```csharp
|
||||
if (parts.Count > 0)
|
||||
activeForm.PlateView.AcceptTemporaryParts();
|
||||
else
|
||||
activeForm.PlateView.ClearTemporaryParts();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
if (parts.Count > 0)
|
||||
activeForm.PlateView.AcceptPreviewParts(parts);
|
||||
else
|
||||
activeForm.PlateView.ClearPreviewParts();
|
||||
```
|
||||
|
||||
Change `ClearTemporaryParts()` in the catch block to `ClearPreviewParts()`.
|
||||
|
||||
- [ ] **Step 3: Update fill-area callback (FillArea_Click, line 1031)**
|
||||
|
||||
Replace the progress setup (lines 1031-1045):
|
||||
|
||||
```csharp
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: false);
|
||||
var highWaterMark = 0;
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.BestParts != null && p.BestPartCount >= highWaterMark)
|
||||
{
|
||||
highWaterMark = p.BestPartCount;
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
}
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: false);
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
activeForm.PlateView.SetStationaryParts(p.BestParts);
|
||||
else
|
||||
activeForm.PlateView.SetActiveParts(p.BestParts);
|
||||
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
```
|
||||
|
||||
Change the `onComplete` callback (lines 1047-1052):
|
||||
|
||||
```csharp
|
||||
Action<List<Part>> onComplete = parts =>
|
||||
{
|
||||
if (parts != null && parts.Count > 0)
|
||||
activeForm.PlateView.AcceptTemporaryParts();
|
||||
else
|
||||
activeForm.PlateView.ClearTemporaryParts();
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
Action<List<Part>> onComplete = parts =>
|
||||
{
|
||||
if (parts != null && parts.Count > 0)
|
||||
activeForm.PlateView.AcceptPreviewParts(parts);
|
||||
else
|
||||
activeForm.PlateView.ClearPreviewParts();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors (only pre-existing nullable warnings in OpenNest.Gpu).
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(ui): route progress to stationary/active buckets in MainForm
|
||||
```
|
||||
@@ -1,620 +0,0 @@
|
||||
# Iterative Shrink-Fill Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `StripNestEngine`'s single-strip approach with iterative shrink-fill — every multi-quantity drawing gets shrink-fitted into its tightest sub-region using dual-direction selection, with leftovers packed at the end.
|
||||
|
||||
**Architecture:** New `IterativeShrinkFiller` static class composes existing `RemnantFiller` + `ShrinkFiller` with a dual-direction wrapper closure. `StripNestEngine.Nest` becomes a thin orchestrator calling the new filler then packing leftovers. No changes to `NestEngineBase`, `DefaultNestEngine`, or UI.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, OpenNest.Engine
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
|
||||
|
||||
---
|
||||
|
||||
### File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | **New.** Static class + result type. Wraps a raw fill function with dual-direction `ShrinkFiller.Shrink`, passes the wrapper to `RemnantFiller.FillItems`. Returns placed parts + leftover items. |
|
||||
| `OpenNest.Engine/StripNestEngine.cs` | **Modify.** Rewrite `Nest` to separate items, call `IterativeShrinkFiller.Fill`, pack leftovers. Delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, `ShrinkFill`. |
|
||||
| `OpenNest.Engine/StripNestResult.cs` | **Delete.** No longer needed. |
|
||||
| `OpenNest.Engine/StripDirection.cs` | **Delete.** No longer needed. |
|
||||
| `OpenNest.Tests/IterativeShrinkFillerTests.cs` | **New.** Unit tests for the new filler. |
|
||||
| `OpenNest.Tests/EngineRefactorSmokeTests.cs` | **Verify.** Existing `StripEngine_Nest_ProducesResults` must still pass. |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: IterativeShrinkFiller — empty/null input
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
- Create: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for empty/null input**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class IterativeShrinkFillerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fill_NullItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_EmptyItems_ReturnsEmpty()
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) => new List<Part>();
|
||||
var result = IterativeShrinkFiller.Fill(new List<NestItem>(), new Box(0, 0, 100, 100), fillFunc, 1.0);
|
||||
|
||||
Assert.Empty(result.Parts);
|
||||
Assert.Empty(result.Leftovers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: Build error — `IterativeShrinkFiller` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
public class IterativeShrinkResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public List<NestItem> Leftovers { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class IterativeShrinkFiller
|
||||
{
|
||||
public static IterativeShrinkResult Fill(
|
||||
List<NestItem> items,
|
||||
Box workArea,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
double spacing,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new IterativeShrinkResult();
|
||||
|
||||
// TODO: dual-direction shrink logic
|
||||
return new IterativeShrinkResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/IterativeShrinkFillerTests.cs OpenNest.Engine/Fill/IterativeShrinkFiller.cs
|
||||
git commit -m "feat(engine): add IterativeShrinkFiller skeleton with empty/null tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: IterativeShrinkFiller — dual-direction shrink core logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`
|
||||
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
|
||||
**Context:** The core algorithm wraps the caller's `fillFunc` in a closure that calls `ShrinkFiller.Shrink` in both axis directions and picks the better `FillScore`, then passes this wrapper to `RemnantFiller.FillItems`.
|
||||
|
||||
- [ ] **Step 1: Write failing test — single item gets shrink-filled**
|
||||
|
||||
Add to `IterativeShrinkFillerTests.cs`:
|
||||
|
||||
```csharp
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_SingleItem_PlacesParts()
|
||||
{
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = drawing, Quantity = 5 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place parts");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests.Fill_SingleItem_PlacesParts"`
|
||||
Expected: FAIL — returns 0 parts (skeleton returns empty).
|
||||
|
||||
- [ ] **Step 3: Implement dual-direction shrink logic**
|
||||
|
||||
Replace the TODO in `IterativeShrinkFiller.Fill`:
|
||||
|
||||
```csharp
|
||||
public static IterativeShrinkResult Fill(
|
||||
List<NestItem> items,
|
||||
Box workArea,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
double spacing,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new IterativeShrinkResult();
|
||||
|
||||
// RemnantFiller.FillItems skips items with Quantity <= 0 (its localQty
|
||||
// check treats them as "done"). Convert unlimited items to an estimated
|
||||
// max capacity so they are actually processed.
|
||||
var workItems = new List<NestItem>(items.Count);
|
||||
var unlimitedDrawings = new HashSet<string>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
var estimatedMax = bbox.Area() > 0
|
||||
? (int)(workArea.Area() / bbox.Area()) * 2
|
||||
: 1000;
|
||||
|
||||
unlimitedDrawings.Add(item.Drawing.Name);
|
||||
workItems.Add(new NestItem
|
||||
{
|
||||
Drawing = item.Drawing,
|
||||
Quantity = System.Math.Max(1, estimatedMax),
|
||||
Priority = item.Priority,
|
||||
StepAngle = item.StepAngle,
|
||||
RotationStart = item.RotationStart,
|
||||
RotationEnd = item.RotationEnd
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
workItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
var filler = new RemnantFiller(workArea, spacing);
|
||||
|
||||
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
|
||||
{
|
||||
var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token);
|
||||
var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token);
|
||||
|
||||
var heightScore = FillScore.Compute(heightResult.Parts, box);
|
||||
var widthScore = FillScore.Compute(widthResult.Parts, box);
|
||||
|
||||
return widthScore > heightScore ? widthResult.Parts : heightResult.Parts;
|
||||
};
|
||||
|
||||
var placed = filler.FillItems(workItems, shrinkWrapper, token);
|
||||
|
||||
// Build leftovers: compare placed count to original quantities.
|
||||
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
|
||||
var leftovers = new List<NestItem>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var placedCount = placed.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
|
||||
if (item.Quantity <= 0)
|
||||
continue; // unlimited items are always "satisfied" — no leftover
|
||||
|
||||
var remaining = item.Quantity - placedCount;
|
||||
if (remaining > 0)
|
||||
{
|
||||
leftovers.Add(new NestItem
|
||||
{
|
||||
Drawing = item.Drawing,
|
||||
Quantity = remaining,
|
||||
Priority = item.Priority,
|
||||
StepAngle = item.StepAngle,
|
||||
RotationStart = item.RotationStart,
|
||||
RotationEnd = item.RotationEnd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers };
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `RemnantFiller.FillItems` skips items with `Quantity <= 0` (its `localQty` check treats them as done). To work around this without modifying `RemnantFiller`, unlimited items are converted to an estimated max capacity (`workArea / bboxArea * 2`) before being passed in.
|
||||
- `RemnantFiller.FillItems` does NOT mutate `NestItem.Quantity` — it tracks quantities internally via `localQty` dictionary (verified in `RemnantFillerTests2.FillItems_DoesNotMutateItemQuantities`).
|
||||
- The leftover calculation iterates the *original* items list (not `workItems`), so unlimited items are correctly skipped.
|
||||
- `FillScore` comparison: `widthScore > heightScore` uses the operator overload which is lexicographic (count first, then density).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/IterativeShrinkFiller.cs OpenNest.Tests/IterativeShrinkFillerTests.cs
|
||||
git commit -m "feat(engine): implement dual-direction shrink logic in IterativeShrinkFiller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: IterativeShrinkFiller — multiple items and leftovers
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/IterativeShrinkFillerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for multi-item and leftover scenarios**
|
||||
|
||||
Add to `IterativeShrinkFillerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Fill_MultipleItems_PlacesFromBoth()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 5 },
|
||||
new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
var largeCount = result.Parts.Count(p => p.BaseDrawing.Name == "large");
|
||||
var smallCount = result.Parts.Count(p => p.BaseDrawing.Name == "small");
|
||||
|
||||
Assert.True(largeCount > 0, "Should place large parts");
|
||||
Assert.True(smallCount > 0, "Should place small parts in remaining space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnfilledQuantity_ReturnsLeftovers()
|
||||
{
|
||||
// Huge quantity that can't all fit on a small plate
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 1000 },
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 60, 30), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Should place some parts");
|
||||
Assert.True(result.Leftovers.Count > 0, "Should have leftovers");
|
||||
Assert.True(result.Leftovers[0].Quantity > 0, "Leftover quantity should be positive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_UnlimitedQuantity_PlacesParts()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 0 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var plate = new Plate(b.Width, b.Length);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0);
|
||||
|
||||
Assert.True(result.Parts.Count > 0, "Unlimited qty items should still be placed");
|
||||
Assert.Empty(result.Leftovers); // unlimited items never produce leftovers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_RespectsCancellation()
|
||||
{
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new NestItem { Drawing = MakeRectDrawing(20, 10), Quantity = 10 }
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 100, 100), fillFunc, 1.0, cts.Token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~IterativeShrinkFillerTests"`
|
||||
Expected: All 7 tests pass (these test existing behavior, no new code needed — they verify the implementation from Task 2 handles these cases).
|
||||
|
||||
If any fail, fix the implementation in `IterativeShrinkFiller.Fill` and re-run.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/IterativeShrinkFillerTests.cs
|
||||
git commit -m "test(engine): add multi-item, leftover, unlimited qty, and cancellation tests for IterativeShrinkFiller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite StripNestEngine.Nest
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
**Context:** Replace the current `Nest` implementation that does single-strip + remnant fill with the new iterative approach. Keep `Fill`, `Fill(groupParts)`, and `PackArea` overrides unchanged — they still delegate to `DefaultNestEngine`.
|
||||
|
||||
- [ ] **Step 1: Run existing smoke test to establish baseline**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
|
||||
Expected: PASS (current implementation works).
|
||||
|
||||
- [ ] **Step 2: Rewrite StripNestEngine.Nest**
|
||||
|
||||
Replace the `Nest` override and delete `SelectStripItemIndex`, `EstimateStripDimension`, `TryOrientation`, and `ShrinkFill` methods. The full file should become:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class StripNestEngine : NestEngineBase
|
||||
{
|
||||
public StripNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Iterative shrink-fill nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group-parts fill delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(groupParts, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack delegates to DefaultNestEngine.
|
||||
/// </summary>
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.PackArea(box, items, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-drawing iterative shrink-fill strategy.
|
||||
/// Each multi-quantity drawing gets shrink-filled into the tightest
|
||||
/// sub-region using dual-direction selection. Singles and leftovers
|
||||
/// are packed at the end.
|
||||
/// </summary>
|
||||
public override List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Separate multi-quantity from singles.
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
var packItems = items
|
||||
.Where(i => i.Quantity == 1)
|
||||
.ToList();
|
||||
|
||||
var allParts = new List<Part>();
|
||||
|
||||
// Phase 1: Iterative shrink-fill for multi-quantity items.
|
||||
if (fillItems.Count > 0)
|
||||
{
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = IterativeShrinkFiller.Fill(
|
||||
fillItems, workArea, fillFunc, Plate.PartSpacing, token);
|
||||
|
||||
allParts.AddRange(shrinkResult.Parts);
|
||||
|
||||
// Add unfilled items to pack list.
|
||||
packItems.AddRange(shrinkResult.Leftovers);
|
||||
}
|
||||
|
||||
// Phase 2: Pack singles + leftovers into remaining space.
|
||||
packItems = packItems.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (packItems.Count > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
// Reconstruct remaining area from placed parts.
|
||||
var packArea = workArea;
|
||||
if (allParts.Count > 0)
|
||||
{
|
||||
var obstacles = allParts
|
||||
.Select(p => p.BoundingBox.Offset(Plate.PartSpacing))
|
||||
.ToList();
|
||||
var finder = new RemnantFinder(workArea, obstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
packArea = remnants.Count > 0 ? remnants[0] : new Box(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (packArea.Width > 0 && packArea.Length > 0)
|
||||
{
|
||||
var packParts = PackArea(packArea, packItems, progress, token);
|
||||
allParts.AddRange(packParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run smoke test**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~StripEngine_Nest_ProducesResults"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run all engine tests to check for regressions**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~EngineRefactorSmokeTests|FullyQualifiedName~IterativeShrinkFillerTests|FullyQualifiedName~ShrinkFillerTests|FullyQualifiedName~RemnantFillerTests"`
|
||||
Expected: All pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat(engine): rewrite StripNestEngine.Nest with iterative shrink-fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delete obsolete files
|
||||
|
||||
**Files:**
|
||||
- Delete: `OpenNest.Engine/StripNestResult.cs`
|
||||
- Delete: `OpenNest.Engine/StripDirection.cs`
|
||||
|
||||
- [ ] **Step 1: Verify no remaining references**
|
||||
|
||||
Run: `grep -r "StripNestResult\|StripDirection" --include="*.cs" . | grep -v "\.md"`
|
||||
|
||||
Expected: No matches (all references were in the old `StripNestEngine.TryOrientation` which was deleted in Task 4).
|
||||
|
||||
- [ ] **Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
rm OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify no breakage**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -u OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripDirection.cs
|
||||
git commit -m "refactor(engine): delete obsolete StripNestResult and StripDirection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update spec and docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-03-19-iterative-shrink-fill-design.md`
|
||||
|
||||
- [ ] **Step 1: Update spec with "rotating calipers already included" note**
|
||||
|
||||
The spec was already updated during planning. Verify it reflects the final state — no `AngleCandidateBuilder` or `NestItem.CaliperAngle` changes listed.
|
||||
|
||||
- [ ] **Step 2: Commit if any changes**
|
||||
|
||||
```bash
|
||||
git add docs/
|
||||
git commit -m "docs: finalize iterative shrink-fill spec"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,354 +0,0 @@
|
||||
# Trim-to-Count Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the expensive iterative ShrinkFiller loop with a single fill + axis-aware edge-sorted trim, and replace the blind `Take(N)` in DefaultNestEngine.Fill with the same trim.
|
||||
|
||||
**Architecture:** Add `ShrinkFiller.TrimToCount` static method that sorts parts by trailing edge and keeps the N nearest to origin. Gut the shrink loop in `Shrink`, remove `maxIterations` parameter. Update `DefaultNestEngine.Fill` to call `TrimToCount` instead of `Take(N)`.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-19-trim-to-count-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- **Modify:** `OpenNest.Engine/Fill/ShrinkFiller.cs` — add `TrimToCount`, replace shrink loop, remove `maxIterations`
|
||||
- **Modify:** `OpenNest.Engine/DefaultNestEngine.cs:55-56` — replace `Take(N)` with `TrimToCount`
|
||||
- **Modify:** `OpenNest.Tests/ShrinkFillerTests.cs` — delete `Shrink_RespectsMaxIterations`, update existing tests, add `TrimToCount` tests
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add TrimToCount with tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/ShrinkFillerTests.cs`
|
||||
- Modify: `OpenNest.Engine/Fill/ShrinkFiller.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for TrimToCount**
|
||||
|
||||
Add these tests to the bottom of `OpenNest.Tests/ShrinkFillerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TrimToCount_Width_KeepsPartsNearestToOrigin()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5), // Right = 5
|
||||
TestHelpers.MakePartAt(10, 0, 5), // Right = 15
|
||||
TestHelpers.MakePartAt(20, 0, 5), // Right = 25
|
||||
TestHelpers.MakePartAt(30, 0, 5), // Right = 35
|
||||
};
|
||||
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
|
||||
|
||||
Assert.Equal(2, trimmed.Count);
|
||||
Assert.True(trimmed.All(p => p.BoundingBox.Right <= 15));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_Height_KeepsPartsNearestToOrigin()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5), // Top = 5
|
||||
TestHelpers.MakePartAt(0, 10, 5), // Top = 15
|
||||
TestHelpers.MakePartAt(0, 20, 5), // Top = 25
|
||||
TestHelpers.MakePartAt(0, 30, 5), // Top = 35
|
||||
};
|
||||
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Height);
|
||||
|
||||
Assert.Equal(2, trimmed.Count);
|
||||
Assert.True(trimmed.All(p => p.BoundingBox.Top <= 15));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_ReturnsInput_WhenCountAtOrBelowTarget()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
|
||||
var same = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Width);
|
||||
Assert.Same(parts, same);
|
||||
|
||||
var fewer = ShrinkFiller.TrimToCount(parts, 5, ShrinkAxis.Width);
|
||||
Assert.Same(parts, fewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_DoesNotMutateInput()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
TestHelpers.MakePartAt(20, 0, 5),
|
||||
};
|
||||
|
||||
var originalCount = parts.Count;
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 1, ShrinkAxis.Width);
|
||||
|
||||
Assert.Equal(originalCount, parts.Count);
|
||||
Assert.Equal(1, trimmed.Count);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "TrimToCount"`
|
||||
Expected: Build error — `ShrinkFiller` does not contain `TrimToCount`
|
||||
|
||||
- [ ] **Step 3: Implement TrimToCount**
|
||||
|
||||
Add this method to `OpenNest.Engine/Fill/ShrinkFiller.cs` inside the `ShrinkFiller` class, after the `Shrink` method. Note: `internal` visibility works because `OpenNest.Engine.csproj` has `<InternalsVisibleTo Include="OpenNest.Tests" />`.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Keeps the <paramref name="targetCount"/> parts nearest to the origin
|
||||
/// along the given axis, discarding parts farthest from the origin.
|
||||
/// Returns the input list unchanged if count is already at or below target.
|
||||
/// </summary>
|
||||
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
|
||||
{
|
||||
if (parts == null || parts.Count <= targetCount)
|
||||
return parts;
|
||||
|
||||
return axis == ShrinkAxis.Width
|
||||
? parts.OrderBy(p => p.BoundingBox.Right).Take(targetCount).ToList()
|
||||
: parts.OrderBy(p => p.BoundingBox.Top).Take(targetCount).ToList();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "TrimToCount"`
|
||||
Expected: All 4 TrimToCount tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
|
||||
git commit -m "feat: add ShrinkFiller.TrimToCount for axis-aware edge trimming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace ShrinkFiller shrink loop with TrimToCount
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/Fill/ShrinkFiller.cs`
|
||||
- Modify: `OpenNest.Tests/ShrinkFillerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Delete `Shrink_RespectsMaxIterations` test and rename misleading test**
|
||||
|
||||
Remove the entire `Shrink_RespectsMaxIterations` test method (lines 63-79) from `OpenNest.Tests/ShrinkFillerTests.cs`.
|
||||
|
||||
Rename `Shrink_ReducesDimension_UntilCountDrops` to `Shrink_FillsAndReturnsDimension` since the iterative shrink behavior no longer exists.
|
||||
|
||||
- [ ] **Step 2: Replace the shrink loop in `Shrink`**
|
||||
|
||||
In `OpenNest.Engine/Fill/ShrinkFiller.cs`, replace the `Shrink` method signature and body. Remove the `maxIterations` parameter. Replace the shrink loop (lines 63-96) with trim + measure:
|
||||
|
||||
Before (lines 25-97):
|
||||
```csharp
|
||||
public static ShrinkResult Shrink(
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
NestItem item, Box box,
|
||||
double spacing,
|
||||
ShrinkAxis axis,
|
||||
CancellationToken token = default,
|
||||
int maxIterations = 20,
|
||||
int targetCount = 0,
|
||||
IProgress<NestProgress> progress = null,
|
||||
int plateNumber = 0,
|
||||
List<Part> placedParts = null)
|
||||
{
|
||||
// If a target count is specified, estimate a smaller starting box
|
||||
// to avoid an expensive full-area fill.
|
||||
var startBox = box;
|
||||
if (targetCount > 0)
|
||||
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
|
||||
|
||||
var parts = fillFunc(item, startBox);
|
||||
|
||||
// If estimate was too aggressive and we got fewer than target,
|
||||
// fall back to the full box.
|
||||
if (targetCount > 0 && startBox != box
|
||||
&& (parts == null || parts.Count < targetCount))
|
||||
{
|
||||
parts = fillFunc(item, box);
|
||||
}
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
|
||||
|
||||
// Shrink target: if a target count was given and we got at least that many,
|
||||
// shrink to fit targetCount (not the full count). This produces a tighter box.
|
||||
// If we got fewer than target, shrink to maintain what we have.
|
||||
var shrinkTarget = targetCount > 0
|
||||
? System.Math.Min(targetCount, parts.Count)
|
||||
: parts.Count;
|
||||
|
||||
var bestParts = parts;
|
||||
var bestDim = MeasureDimension(parts, box, axis);
|
||||
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, box, axis, bestDim);
|
||||
|
||||
for (var i = 0; i < maxIterations; i++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var trialDim = bestDim - spacing;
|
||||
if (trialDim <= 0)
|
||||
break;
|
||||
|
||||
var trialBox = axis == ShrinkAxis.Width
|
||||
? new Box(box.X, box.Y, trialDim, box.Length)
|
||||
: new Box(box.X, box.Y, box.Width, trialDim);
|
||||
|
||||
// Report the trial box before the fill so the UI updates the
|
||||
// work area border immediately rather than after the fill completes.
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, trialDim);
|
||||
|
||||
var trialParts = fillFunc(item, trialBox);
|
||||
|
||||
if (trialParts == null || trialParts.Count < shrinkTarget)
|
||||
break;
|
||||
|
||||
bestParts = trialParts;
|
||||
bestDim = MeasureDimension(trialParts, box, axis);
|
||||
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, bestParts, trialBox, axis, bestDim);
|
||||
}
|
||||
|
||||
return new ShrinkResult { Parts = bestParts, Dimension = bestDim };
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
```csharp
|
||||
public static ShrinkResult Shrink(
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
NestItem item, Box box,
|
||||
double spacing,
|
||||
ShrinkAxis axis,
|
||||
CancellationToken token = default,
|
||||
int targetCount = 0,
|
||||
IProgress<NestProgress> progress = null,
|
||||
int plateNumber = 0,
|
||||
List<Part> placedParts = null)
|
||||
{
|
||||
var startBox = box;
|
||||
if (targetCount > 0)
|
||||
startBox = EstimateStartBox(item, box, spacing, axis, targetCount);
|
||||
|
||||
var parts = fillFunc(item, startBox);
|
||||
|
||||
if (targetCount > 0 && startBox != box
|
||||
&& (parts == null || parts.Count < targetCount))
|
||||
{
|
||||
parts = fillFunc(item, box);
|
||||
}
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return new ShrinkResult { Parts = parts ?? new List<Part>(), Dimension = 0 };
|
||||
|
||||
var shrinkTarget = targetCount > 0
|
||||
? System.Math.Min(targetCount, parts.Count)
|
||||
: parts.Count;
|
||||
|
||||
if (parts.Count > shrinkTarget)
|
||||
parts = TrimToCount(parts, shrinkTarget, axis);
|
||||
|
||||
var dim = MeasureDimension(parts, box, axis);
|
||||
|
||||
ReportShrinkProgress(progress, plateNumber, placedParts, parts, box, axis, dim);
|
||||
|
||||
return new ShrinkResult { Parts = parts, Dimension = dim };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the class xmldoc**
|
||||
|
||||
Replace the `ShrinkFiller` class summary (line 18-22):
|
||||
|
||||
Before:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Fills a box then iteratively shrinks one axis by the spacing amount
|
||||
/// until the part count drops. Returns the tightest box that still fits
|
||||
/// the target number of parts.
|
||||
/// </summary>
|
||||
```
|
||||
|
||||
After:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Fills a box and trims excess parts by removing those farthest from
|
||||
/// the origin along the shrink axis.
|
||||
/// </summary>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all ShrinkFiller tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "ShrinkFiller"`
|
||||
Expected: All tests PASS (the `Shrink_RespectsMaxIterations` test was deleted, remaining tests still pass because the fill + trim produces valid results with positive counts and dimensions)
|
||||
|
||||
- [ ] **Step 5: Build the full solution to check for compilation errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds. No callers pass `maxIterations` by name except the deleted test. `IterativeShrinkFiller.cs` uses positional args and does not pass `maxIterations`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Fill/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
|
||||
git commit -m "refactor: replace ShrinkFiller shrink loop with TrimToCount"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Replace Take(N) in DefaultNestEngine.Fill
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:55-56`
|
||||
|
||||
- [ ] **Step 1: Replace the Take(N) call**
|
||||
|
||||
In `OpenNest.Engine/DefaultNestEngine.cs`, replace lines 55-56:
|
||||
|
||||
Before:
|
||||
```csharp
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
```
|
||||
|
||||
After:
|
||||
```csharp
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds. `ShrinkFiller` and `ShrinkAxis` are already available via the `using OpenNest.Engine.Fill;` import on line 1.
|
||||
|
||||
- [ ] **Step 3: Run all engine tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "refactor: use TrimToCount instead of blind Take(N) in DefaultNestEngine.Fill"
|
||||
```
|
||||
@@ -1,704 +0,0 @@
|
||||
# NFP Best-Fit Strategy Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the brute-force slide-based best-fit pair sampling with NFP-based candidate generation that produces mathematically exact interlocking positions.
|
||||
|
||||
**Architecture:** New `NfpSlideStrategy : IBestFitStrategy` generates `PairCandidate` offsets from NFP boundary vertices/edges. Shared polygon helper extracted from `AutoNester` to avoid duplication. `BestFitFinder.BuildStrategies` swaps to the new strategy. Everything downstream (evaluator, filter, tiling) stays unchanged.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, xunit, existing `NoFitPolygon` (Minkowski sum via Clipper2), `ShapeProfile`, `ConvertProgram`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-20-nfp-bestfit-strategy-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract `PolygonHelper` from `AutoNester`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/PolygonHelper.cs`
|
||||
- Modify: `OpenNest.Engine/Nfp/AutoNester.cs:204-343`
|
||||
- Test: `OpenNest.Tests/PolygonHelperTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add shared test helpers and write tests for `PolygonHelper`**
|
||||
|
||||
Add `MakeSquareDrawing` and `MakeLShapeDrawing` to `OpenNest.Tests/TestHelpers.cs`:
|
||||
|
||||
```csharp
|
||||
public static Drawing MakeSquareDrawing(double size = 10)
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("square", pgm);
|
||||
}
|
||||
|
||||
public static Drawing MakeLShapeDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("lshape", pgm);
|
||||
}
|
||||
```
|
||||
|
||||
Then create `OpenNest.Tests/PolygonHelperTests.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PolygonHelperTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
Assert.True(result.Polygon.Vertices.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing(10);
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
// Inflated polygon should have a larger bounding box.
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
var drawing = new Drawing("empty", pgm);
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.Null(result.Polygon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
|
||||
{
|
||||
// Square drawing: program bbox starts at (0,0) due to rapid move,
|
||||
// perimeter bbox also starts at (0,0) — correction should be near zero.
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
// For a simple square starting at origin, correction should be small.
|
||||
Assert.True(System.Math.Abs(result.Correction.X) < 1);
|
||||
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_AtZero_ReturnsSamePolygon()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
|
||||
Assert.Same(polygon, rotated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_At90Degrees_SwapsDimensions()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
|
||||
rotated.UpdateBounds();
|
||||
|
||||
// Width and height should swap (approximately).
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests"`
|
||||
Expected: Build error — `PolygonHelper` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Create `PolygonHelper.cs`**
|
||||
|
||||
Create `OpenNest.Engine/BestFit/PolygonHelper.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public static class PolygonHelper
|
||||
{
|
||||
public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Compute the perimeter bounding box before inflation for coordinate correction.
|
||||
perimeter.UpdateBounds();
|
||||
var perimeterBb = perimeter.BoundingBox;
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
Shape inflated;
|
||||
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left);
|
||||
inflated = offsetEntity as Shape ?? perimeter;
|
||||
}
|
||||
else
|
||||
{
|
||||
inflated = perimeter;
|
||||
}
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Compute correction: difference between program origin and perimeter origin.
|
||||
// Part.CreateAtOrigin normalizes to program bbox; polygon normalizes to perimeter bbox.
|
||||
var programBb = drawing.Program.BoundingBox();
|
||||
var correction = new Vector(
|
||||
perimeterBb.Left - programBb.Location.X,
|
||||
perimeterBb.Bottom - programBb.Location.Y);
|
||||
|
||||
// Normalize: move reference point to origin.
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return new PolygonExtractionResult(polygon, correction);
|
||||
}
|
||||
|
||||
public static Polygon RotatePolygon(Polygon polygon, double angle)
|
||||
{
|
||||
if (angle.IsEqualTo(0))
|
||||
return polygon;
|
||||
|
||||
var result = new Polygon();
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
result.Vertices.Add(new Vector(
|
||||
v.X * cos - v.Y * sin,
|
||||
v.X * sin + v.Y * cos));
|
||||
}
|
||||
|
||||
// Re-normalize to origin.
|
||||
result.UpdateBounds();
|
||||
var bb = result.BoundingBox;
|
||||
result.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record PolygonExtractionResult(Polygon Polygon, Vector Correction);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests"`
|
||||
Expected: All 6 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Update `AutoNester` to delegate to `PolygonHelper`**
|
||||
|
||||
In `OpenNest.Engine/Nfp/AutoNester.cs`, replace the private `ExtractPerimeterPolygon` and `RotatePolygon` methods (lines 204-343) with delegates to `PolygonHelper`:
|
||||
|
||||
```csharp
|
||||
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
|
||||
}
|
||||
|
||||
private static Polygon RotatePolygon(Polygon polygon, double angle)
|
||||
{
|
||||
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build solution to verify no regressions**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/BestFit/PolygonHelper.cs OpenNest.Tests/PolygonHelperTests.cs OpenNest.Tests/TestHelpers.cs OpenNest.Engine/Nfp/AutoNester.cs
|
||||
git commit -m "refactor: extract PolygonHelper from AutoNester for shared polygon operations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `NfpSlideStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`
|
||||
- Test: `OpenNest.Tests/NfpSlideStrategyTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write tests for `NfpSlideStrategy`**
|
||||
|
||||
Create `OpenNest.Tests/NfpSlideStrategyTests.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NfpSlideStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.NotEmpty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_Part1RotationIsAlwaysZero()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(Angle.HalfPI, 1, "90 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_Part2RotationMatchesStrategy()
|
||||
{
|
||||
var rotation = Angle.HalfPI;
|
||||
var strategy = new NfpSlideStrategy(rotation, 1, "90 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_NoDuplicateOffsets()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
|
||||
var uniqueOffsets = candidates
|
||||
.Select(c => (System.Math.Round(c.Part2Offset.X, 6), System.Math.Round(c.Part2Offset.Y, 6)))
|
||||
.Distinct()
|
||||
.Count();
|
||||
Assert.Equal(candidates.Count, uniqueOffsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var largeStep = strategy.GenerateCandidates(drawing, 0.25, 5.0);
|
||||
var smallStep = strategy.GenerateCandidates(drawing, 0.25, 0.5);
|
||||
// Smaller step should add more edge samples.
|
||||
Assert.True(smallStep.Count >= largeStep.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_ReturnsEmpty_ForEmptyDrawing()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var pgm = new Program();
|
||||
var drawing = new Drawing("empty", pgm);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_LShape_ProducesMoreCandidates_ThanSquare()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
||||
var square = TestHelpers.MakeSquareDrawing();
|
||||
var lshape = TestHelpers.MakeLShapeDrawing();
|
||||
|
||||
var squareCandidates = strategy.GenerateCandidates(square, 0.25, 0.25);
|
||||
var lshapeCandidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
|
||||
|
||||
// L-shape NFP has more vertices/edges than square NFP.
|
||||
Assert.True(lshapeCandidates.Count > squareCandidates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_At180Degrees_ProducesCandidates()
|
||||
{
|
||||
var strategy = new NfpSlideStrategy(System.Math.PI, 1, "180 deg NFP");
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.NotEmpty(candidates);
|
||||
Assert.All(candidates, c => Assert.Equal(System.Math.PI, c.Part2Rotation));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests"`
|
||||
Expected: Build error — `NfpSlideStrategy` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Create `NfpSlideStrategy.cs`**
|
||||
|
||||
Create `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class NfpSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
private readonly double _part2Rotation;
|
||||
|
||||
public NfpSlideStrategy(double part2Rotation, int type, string description)
|
||||
{
|
||||
_part2Rotation = part2Rotation;
|
||||
Type = type;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public int Type { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||
{
|
||||
var candidates = new List<PairCandidate>();
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
|
||||
// Extract stationary polygon (Part1 at rotation 0), with spacing applied.
|
||||
var stationaryResult = PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing);
|
||||
|
||||
if (stationaryResult.Polygon == null)
|
||||
return candidates;
|
||||
|
||||
var stationaryPoly = stationaryResult.Polygon;
|
||||
|
||||
// Extract orbiting polygon (Part2 at _part2Rotation).
|
||||
// Reuse stationary result if rotation is 0, otherwise rotate.
|
||||
var orbitingPoly = PolygonHelper.RotatePolygon(stationaryResult.Polygon, _part2Rotation);
|
||||
|
||||
// Compute NFP.
|
||||
var nfp = NoFitPolygon.Compute(stationaryPoly, orbitingPoly);
|
||||
|
||||
if (nfp == null || nfp.Vertices.Count < 3)
|
||||
return candidates;
|
||||
|
||||
// Coordinate correction: NFP offsets are in polygon-space.
|
||||
// Part.CreateAtOrigin uses program bbox origin.
|
||||
var correction = stationaryResult.Correction;
|
||||
|
||||
// Walk NFP boundary — vertices + edge samples.
|
||||
var verts = nfp.Vertices;
|
||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
var testNumber = 0;
|
||||
|
||||
for (var i = 0; i < vertCount; i++)
|
||||
{
|
||||
// Add vertex candidate.
|
||||
var offset = ApplyCorrection(verts[i], correction);
|
||||
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
|
||||
|
||||
// Add edge samples for long edges.
|
||||
var next = (i + 1) % vertCount;
|
||||
var dx = verts[next].X - verts[i].X;
|
||||
var dy = verts[next].Y - verts[i].Y;
|
||||
var edgeLength = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (edgeLength > stepSize)
|
||||
{
|
||||
var steps = (int)(edgeLength / stepSize);
|
||||
for (var s = 1; s < steps; s++)
|
||||
{
|
||||
var t = (double)s / steps;
|
||||
var sample = new Vector(
|
||||
verts[i].X + dx * t,
|
||||
verts[i].Y + dy * t);
|
||||
var sampleOffset = ApplyCorrection(sample, correction);
|
||||
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
|
||||
{
|
||||
return new Vector(nfpVertex.X + correction.X, nfpVertex.Y + correction.Y);
|
||||
}
|
||||
|
||||
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
|
||||
{
|
||||
return new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = _part2Rotation,
|
||||
Part2Offset = offset,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber,
|
||||
Spacing = spacing
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests"`
|
||||
Expected: All 9 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/BestFit/NfpSlideStrategy.cs OpenNest.Tests/NfpSlideStrategyTests.cs
|
||||
git commit -m "feat: add NfpSlideStrategy for NFP-based best-fit candidate generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire `NfpSlideStrategy` into `BestFitFinder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/BestFit/BestFitFinder.cs:78-91`
|
||||
- Test: `OpenNest.Tests/NfpBestFitIntegrationTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write integration test**
|
||||
|
||||
Create `OpenNest.Tests/NfpBestFitIntegrationTests.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NfpBestFitIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindBestFits_ReturnsKeptResults_ForSquare()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
Assert.NotEmpty(results);
|
||||
Assert.NotEmpty(results.Where(r => r.Keep));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_ResultsHaveValidDimensions()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
foreach (var result in results.Where(r => r.Keep))
|
||||
{
|
||||
Assert.True(result.BoundingWidth > 0);
|
||||
Assert.True(result.BoundingHeight > 0);
|
||||
Assert.True(result.RotatedArea > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_LShape_HasBetterUtilization_ThanBoundingBox()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeLShapeDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
// At least one kept result should have >50% utilization
|
||||
// (L-shapes interlock well, bounding box alone would be ~50%).
|
||||
var bestUtilization = results
|
||||
.Where(r => r.Keep)
|
||||
.Max(r => r.Utilization);
|
||||
Assert.True(bestUtilization > 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_NoOverlaps_InKeptResults()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
// All kept results should be non-overlapping (verified by PairEvaluator).
|
||||
Assert.All(results.Where(r => r.Keep), r =>
|
||||
Assert.Equal("Valid", r.Reason));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap `BuildStrategies` to use `NfpSlideStrategy`**
|
||||
|
||||
In `OpenNest.Engine/BestFit/BestFitFinder.cs`, replace the `BuildStrategies` method (lines 78-91):
|
||||
|
||||
```csharp
|
||||
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 = $"{Angle.ToDegrees(angle):F1} deg NFP";
|
||||
strategies.Add(new NfpSlideStrategy(angle, type++, desc));
|
||||
}
|
||||
|
||||
return strategies;
|
||||
}
|
||||
```
|
||||
|
||||
Add `using OpenNest.Math;` to the top of the file if not already present.
|
||||
|
||||
- [ ] **Step 3: Run integration tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpBestFitIntegrationTests"`
|
||||
Expected: All 4 tests PASS with the NFP pipeline.
|
||||
|
||||
- [ ] **Step 4: Run all tests to check for regressions**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 5: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/BestFit/BestFitFinder.cs OpenNest.Tests/NfpBestFitIntegrationTests.cs
|
||||
git commit -m "feat: wire NfpSlideStrategy into BestFitFinder pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove `AutoNester.Optimize` calls
|
||||
|
||||
The `Optimize` calls are dead weight for single-drawing fills (as discussed). Now that NFP is properly integrated via best-fit, remove the no-op optimization passes.
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:133-134`
|
||||
- Modify: `OpenNest.Engine/NfpNestEngine.cs:52-53`
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs:126-127`
|
||||
- Modify: `OpenNest/Controls/PlateView.cs` (the line calling `AutoNester.Optimize`)
|
||||
|
||||
- [ ] **Step 1: Remove `AutoNester.Optimize` call from `NestEngineBase.cs`**
|
||||
|
||||
In `OpenNest.Engine/NestEngineBase.cs`, remove lines 133-134:
|
||||
```csharp
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
allParts = AutoNester.Optimize(allParts, Plate);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove `AutoNester.Optimize` call from `NfpNestEngine.cs`**
|
||||
|
||||
In `OpenNest.Engine/NfpNestEngine.cs`, remove lines 52-53:
|
||||
```csharp
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
parts = AutoNester.Optimize(parts, Plate);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove `AutoNester.Optimize` call from `StripNestEngine.cs`**
|
||||
|
||||
In `OpenNest.Engine/StripNestEngine.cs`, remove lines 126-127:
|
||||
```csharp
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
allParts = AutoNester.Optimize(allParts, Plate);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove `AutoNester.Optimize` call from `PlateView.cs`**
|
||||
|
||||
In `OpenNest/Controls/PlateView.cs`, find the line calling `AutoNester.Optimize(result, workArea, spacing)` and replace:
|
||||
```csharp
|
||||
return AutoNester.Optimize(result, workArea, spacing);
|
||||
```
|
||||
with:
|
||||
```csharp
|
||||
return result;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 6: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs OpenNest.Engine/NfpNestEngine.cs OpenNest.Engine/StripNestEngine.cs OpenNest/Controls/PlateView.cs
|
||||
git commit -m "perf: remove no-op AutoNester.Optimize calls from fill pipelines"
|
||||
```
|
||||
Reference in New Issue
Block a user