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:
2026-03-21 00:13:01 -04:00
parent de527cd668
commit 4060430757
54 changed files with 4 additions and 25048 deletions

4
.gitignore vendored
View File

@@ -208,3 +208,7 @@ FakesAssemblies/
# Claude Code
.claude/
.superpowers/
# Launch settings
**/Properties/launchSettings.json

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 1445)
- [ ] **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 1445 (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 47237)
- [ ] **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 239378)
- [ ] **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 380742)
- [ ] **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 744858)
- [ ] **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 8601462, 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
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,89 +0,0 @@
# Fill Score Design
## Problem
The nesting engine compares fill results by raw part count, which causes it to reject denser pair patterns that would yield more parts after remainder filling.
**Concrete case:** Part 4980 A24 PT02 on a 60×120" plate:
- Wider pair (90°/270°): 5 rows × 9 = **45 parts** at grid stage, no room for remainder
- Tighter pair #1 (89.7°/269.7°): 4 rows × 9 = **36 parts** at grid stage, 15.7" remainder strip → **47 total possible**
The algorithm compares 45 vs 36 at the grid stage. Pattern #1 loses before its remainder strip is fully evaluated. Two contributing issues:
1. **Scoring:** `FillWithPairs` uses raw count (`count > best.Count`) with no tiebreaker for density or compactness
2. **Remainder rotation coverage:** `FillLinear.FillRemainingStrip` only tries rotations present in the seed pattern (89.7°/269.7°), missing the 0° rotation that fits 11 parts in the strip vs ~8 at the seed angles
## Design
### 1. FillScore Struct
A value type encapsulating fill quality with lexicographic comparison:
```
Priority 1: Part count (higher wins)
Priority 2: Usable remnant area (higher wins) — largest remnant with short side ≥ MinRemnantDimension
Priority 3: Density (higher wins) — sum of part areas / bounding box area of placed parts
```
**Location:** `OpenNest.Engine/FillScore.cs`
**Fields:**
- `int Count` — number of parts placed
- `double UsableRemnantArea` — area of the largest remnant whose short side ≥ threshold (0 if none)
- `double Density` — total part area / bounding box area of all placed parts
**Constants:**
- `MinRemnantDimension = 12.0` (inches) — minimum short side for a remnant to be considered usable
**Implements** `IComparable<FillScore>` with lexicographic ordering.
**Static factory:** `FillScore.Compute(List<Part> parts, Box workArea)` — computes all three metrics from a fill result. Remnant calculation uses the same edge-strip approach as `Plate.GetRemnants()` but against the work area box and placed part bounding boxes.
### 2. Replace Comparison Points
All six comparison locations switch from raw count to `FillScore`:
| Location | File | Current Logic |
|----------|------|---------------|
| `IsBetterFill` | NestEngine.cs:299 | Count, then bbox area tiebreaker |
| `FillWithPairs` inner loop | NestEngine.cs:226 | Count only |
| `TryStripRefill` | NestEngine.cs:424 | `stripParts.Count > lastCluster.Count` (keep as-is — threshold check, not quality comparison) |
| `FillRemainingStrip` | FillLinear.cs:436 | `h.Count > best.Count` (keep as-is — internal sub-fill, count is correct) |
| `FillPattern` | NestEngine.cs:492 | `IsBetterValidFill` (overlap + count) |
| `FindBestFill` | NestEngine.cs:95-118 | `IsBetterFill` (already covered) |
`IsBetterFill(candidate, current)` becomes a `FillScore` comparison. `IsBetterValidFill` keeps its overlap check, then delegates to score comparison.
`FillLinear.FillRemainingStrip` needs access to the work area (already available via `WorkArea` property) to compute scores.
### 3. Expanded Remainder Rotations
`FillLinear.FillRemainingStrip` currently only tries rotations from the seed pattern. Add 0° and 90° (cardinal orientations) to the rotation set:
```csharp
// Current: only seed rotations
foreach (var seedPart in seedPattern.Parts) { ... }
// New: also try 0° and 90°
var rotations = new List<double> { 0, Angle.HalfPI };
foreach (var seedPart in seedPattern.Parts)
if (!rotations.Any(r => r.IsEqualTo(seedPart.Rotation)))
rotations.Add(seedPart.Rotation);
```
This is the change that actually fixes the 45→47 case by allowing `FillRemainingStrip` to discover the 0° rotation for the remainder strip.
## What This Does NOT Change
- Part count remains the primary criterion — no trading parts for remnants
- `FillWithPairs` still evaluates top 50 candidates (no extra `TryRemainderImprovement` per candidate)
- `BestFitResult` ranking/filtering is unchanged
- No new UI or configuration (MinRemnantDimension is a constant for now)
## Expected Outcome
For the 4980 A24 PT02 case:
1. Pattern #1 grid produces 36 parts
2. Expanded remainder rotations try 0° in the 15.7" strip → 11 parts → **47 total**
3. Wider pair grid produces 45 parts with ~5" remainder → ~0 extra parts → **45 total**
4. FillScore comparison: 47 > 45 → pattern #1 wins

View File

@@ -1,263 +0,0 @@
# NFP-Based Autonesting Design
## Problem
OpenNest's current nesting engine handles single-drawing plate fills well (FillLinear, FillBestFit, FillWithPairs), but cannot produce mixed-part layouts — placing multiple different drawings together on a plate with true geometry-aware interlocking. The existing `PackBottomLeft` supports mixed parts but operates on bounding-box rectangles only, wasting the concave space around irregular shapes.
Commercial nesting software (e.g., PEP at $30k) produces mixed-part layouts but with mediocre results — significant gaps and poor material utilization.
## Goal
Build an NFP-based mixed-part autonester that:
- Places multiple different drawings on a plate with true geometry-aware collision avoidance
- Produces tighter layouts than bounding-box packing by allowing parts to interlock
- Uses simulated annealing to optimize part ordering and rotation
- Integrates cleanly alongside existing Fill/Pack methods in NestEngine
- Provides an abstracted optimizer interface for future GA/parallel upgrades
## Dependencies
**Clipper2** (NuGet: `Clipper2Lib`, MIT license) — provides polygon boolean operations (union, difference, intersection) and polygon offsetting. Required for feasible region computation and perimeter inflation. Added to `OpenNest.Core`.
## Architecture
Three layers, built bottom-up:
```
┌──────────────────────────────────┐
│ Simulated Annealing Optimizer │ Layer 3: searches orderings/rotations
│ (implements INestOptimizer) │
├──────────────────────────────────┤
│ NFP-Based Bottom-Left Fill │ Layer 2: places parts using feasible regions
│ (BLF placement engine) │
├──────────────────────────────────┤
│ NFP / IFP Computation + Cache │ Layer 1: geometric foundation
└──────────────────────────────────┘
```
## Layer 1: No-Fit Polygon (NFP) Computation
### What is an NFP?
The No-Fit Polygon between a stationary polygon A and an orbiting polygon B defines all positions where B's reference point would cause A and B to overlap. If B's reference point is:
- **Inside** the NFP → overlap
- **On the boundary** → touching
- **Outside** → no overlap
### Computation Method: Convex Decomposition
Use the **Minkowski sum** approach: NFP(A, B) = A ⊕ (-B), where -B is B reflected through its reference point.
For convex polygons this is a simple merge-sort of edge vectors — O(n+m) where n and m are vertex counts.
For concave polygons (most real CNC parts), use decomposition:
1. Decompose each concave polygon into convex sub-polygons using ear-clipping triangulation (produces O(n) triangles per polygon)
2. For each pair of convex pieces (one from A, one from -B), compute the convex Minkowski sum
3. Union all partial results using Clipper2 to produce the final NFP
The number of convex pair computations is O(t_A * t_B) where t_A and t_B are the triangle counts. Each convex Minkowski sum is O(n+m) ≈ O(6) for triangle pairs, so the cost is dominated by the Clipper2 union step.
### Input
Polygons come from the existing conversion chain:
```
Drawing.Program
→ ConvertProgram.ToGeometry() // recursively expands SubProgramCalls
→ Helper.GetShapes() // chains connected entities into Shapes
→ DefinedShape // separates perimeter from cutouts
→ .Perimeter // outer boundary
→ .OffsetEntity(halfSpacing) // inflate at Shape level (offset is implemented on Shape)
→ .ToPolygonWithTolerance(circumscribe) // polygon with arcs circumscribed
```
Only `DefinedShape.Perimeter` is used for NFP — cutouts do not affect part-to-part collision. Spacing is applied by inflating the perimeter shape by half-spacing on each part (so two adjacent parts have full spacing between them). Inflation is done at the `Shape` level via `Shape.OffsetEntity()` before polygon conversion, consistent with how `PartBoundary` works today.
### Polygon Vertex Budget
`Shape.ToPolygonWithTolerance` with tolerance 0.01 can produce hundreds of vertices for arc-heavy parts. For NFP computation, a coarser tolerance (e.g., 0.05-0.1) may be used to keep triangle counts reasonable. The exact tolerance should be tuned during implementation — start with the existing 0.01 and coarsen only if profiling shows NFP computation is a bottleneck.
### NFP Cache
NFPs are keyed by `(DrawingA.Id, RotationA, DrawingB.Id, RotationB)` and computed once per unique combination. During SA optimization, the same NFPs are reused thousands of times.
`Drawing.Id` is a new `int` property added to the `Drawing` class, auto-assigned on construction.
**Type:** `NfpCache` — dictionary-based lookup with lazy computation (compute on first access, store for reuse).
For N drawings with R candidate rotations, the cache holds up to N^2 * R^2 entries. With typical values (6 drawings, 10 rotations), that's ~3,600 entries — well within memory.
### New Types
| Type | Project | Namespace | Purpose |
|------|---------|-----------|---------|
| `NoFitPolygon` | Core | `OpenNest.Geometry` | Static methods for NFP computation between two polygons |
| `InnerFitPolygon` | Core | `OpenNest.Geometry` | Static methods for IFP (part inside plate boundary) |
| `ConvexDecomposition` | Core | `OpenNest.Geometry` | Ear-clipping triangulation of concave polygons |
| `NfpCache` | Engine | `OpenNest` | Caches computed NFPs keyed by drawing ID/rotation pairs |
## Layer 2: Inner-Fit Polygon (IFP)
The IFP answers "where can this part be placed inside the plate?" It is the NFP of the plate boundary with the part, but inverted — the feasible region is the interior.
For a rectangular plate and a convex part, the IFP is a smaller rectangle (plate shrunk by part dimensions). For concave parts the IFP boundary follows the plate edges inset by the part's profile.
### Feasible Region
For placing part P(i) given already-placed parts P(1)...P(i-1):
```
FeasibleRegion = IFP(plate, P(i)) minus union(NFP(P(1), P(i)), ..., NFP(P(i-1), P(i)))
```
Both the union and difference operations use Clipper2.
The placement point is the bottom-left-most point on the feasible region boundary.
## Layer 3: NFP-Based Bottom-Left Fill (BLF)
### Algorithm
```
BLF(sequence, plate, nfpCache):
placedParts = []
for each (drawing, rotation) in sequence:
polygon = getPerimeterPolygon(drawing, rotation)
ifp = InnerFitPolygon.Compute(plate.WorkArea, polygon)
nfps = [nfpCache.Get(placed, polygon) for placed in placedParts]
feasible = Clipper2.Difference(ifp, Clipper2.Union(nfps))
point = bottomLeftMost(feasible)
if point exists:
place part at point
placedParts.append(part)
return FillScore.Compute(placedParts, plate.WorkArea)
```
### Bottom-Left Point Selection
"Bottom-left" means: minimize Y first (lowest), then minimize X (leftmost). This is standard BLF convention in the nesting literature. The feasible region boundary is walked to find the point with the smallest Y coordinate, breaking ties by smallest X.
Note: the existing `PackBottomLeft.FindPointVertical` uses leftmost-first priority. The new BLF uses lowest-first to match established nesting algorithms. Both remain available.
### Replaces
This replaces `PackBottomLeft` for mixed-part scenarios. The existing rectangle-based `PackBottomLeft` remains available for pure-rectangle use cases.
## Layer 4: Simulated Annealing Optimizer
### Interface
```csharp
interface INestOptimizer
{
NestResult Optimize(List<NestItem> items, Plate plate, NfpCache cache,
CancellationToken cancellation = default);
}
```
This abstraction allows swapping SA for GA (or other meta-heuristics) in the future without touching the NFP or BLF layers. `CancellationToken` enables UI responsiveness and user-initiated cancellation for long-running optimizations.
### State Representation
The SA state is a sequence of `(DrawingId, RotationAngle)` tuples — one entry per physical part to place (respecting quantities from NestItem). The sequence determines placement order for BLF.
### Mutation Operators
Each iteration, one mutation is selected randomly:
| Operator | Description |
|----------|-------------|
| **Swap** | Exchange two parts' positions in the sequence |
| **Rotate** | Change one part's rotation to another candidate angle |
| **Segment reverse** | Reverse a contiguous subsequence |
### Cooling Schedule
- **Initial temperature:** Calibrated so ~80% of worse moves are accepted initially
- **Cooling rate:** Geometric (T = T * alpha, alpha ~0.995-0.999)
- **Termination:** Temperature below threshold OR no improvement for N consecutive iterations OR cancellation requested
- **Quality focus:** Since quality > speed, allow long runs (thousands of iterations)
### Candidate Rotations
Per drawing, candidate rotations are determined by existing logic:
- Hull-edge angles from `RotationAnalysis.FindHullEdgeAngles()` (needs a new overload accepting a `Drawing` or `Polygon` instead of `List<Part>`)
- 0 degrees baseline
- Fixed-increment sweep (e.g., 5 degrees) for small work areas
### Initial Solution
Generate the initial sequence by sorting parts largest-area-first (matches existing `PackBottomLeft` heuristic). This gives SA a reasonable starting point rather than random.
## Integration: NestEngine.AutoNest
New public entry point:
```csharp
public List<Part> AutoNest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
```
### Flow
1. Extract perimeter polygons via `DefinedShape` for each unique drawing
2. Inflate perimeters by half-spacing at the `Shape` level via `OffsetEntity()`
3. Convert to polygons via `ToPolygonWithTolerance(circumscribe: true)`
4. Compute candidate rotations per drawing
5. Pre-compute `NfpCache` for all (drawing, rotation) pair combinations
6. Run `INestOptimizer.Optimize()` → best sequence
7. Final BLF placement with best solution → placed `Part` instances
8. Return parts
### Error Handling
- Drawing produces no valid perimeter polygon → skip drawing, log warning
- No parts fit on plate → return empty list
- NFP produces degenerate polygon (zero area) → treat as non-overlapping (safe fallback)
### Coexistence with Existing Methods
| Method | Use Case | Status |
|--------|----------|--------|
| `Fill(NestItem)` | Single-drawing plate fill (tiling) | Unchanged |
| `Pack(List<NestItem>)` | Rectangle bin packing | Unchanged |
| `AutoNest(List<NestItem>, Plate)` | Mixed-part geometry-aware nesting | **New** |
## Future: Simplifying FillLinear with NFP
Once NFP infrastructure is in place, `FillLinear.FindCopyDistance` can be replaced with NFP projections:
- Copy distance along any axis = extreme point of NFP projected onto that axis
- Eliminates directional edge filtering, line-to-line sliding, and special-case handling in `PartBoundary`
- `BestFitFinder` pair evaluation simplifies to walking NFP boundaries
This refactor is deferred to a future phase to keep scope focused.
## Future: Nesting Inside Cutouts
`DefinedShape.Cutouts` are preserved but unused in Phase 1. Future optimization: for parts with large interior cutouts, compute IFP of the cutout boundary and attempt to place small parts inside. This requires:
- Minimum cutout size threshold
- Modified BLF that considers cutout interiors as additional placement regions
## Future: Parallel Optimization (Threadripper)
The `INestOptimizer` interface naturally supports parallelism:
- **SA parallelism:** Run multiple independent SA chains with different random seeds, take the best result
- **GA upgrade:** Population-based evaluation is embarrassingly parallel — evaluate N candidate orderings simultaneously on N threads
- NFP cache is read-only during optimization, so it's inherently thread-safe
## Future: Orbiting NFP Algorithm
If convex decomposition proves too slow for vertex-heavy parts, the orbiting (edge-tracing) method computes NFPs directly on concave polygons without decomposition. Deferred unless profiling identifies decomposition as a bottleneck.
## Scoring
Results are scored using existing `FillScore` (lexicographic: count → usable remnant area → density). `FillScore.Compute` takes `(List<Part>, Box workArea)` — pass `plate.WorkArea` as the Box. No changes to FillScore needed.
## Project Location
All new types go in existing projects:
- `NoFitPolygon`, `InnerFitPolygon`, `ConvexDecomposition``OpenNest.Core/Geometry/`
- `NfpCache`, BLF engine, SA optimizer, `INestOptimizer``OpenNest.Engine/`
- `AutoNest` method → `NestEngine.cs`
- `Drawing.Id` property → `OpenNest.Core/Drawing.cs`
- Clipper2 NuGet → `OpenNest.Core.csproj`

View File

@@ -1,134 +0,0 @@
# Contour Re-Indexing Design
## Overview
Add entity-splitting primitives and a `Shape.ReindexAt` method so that a closed contour can be reordered to start (and end) at an arbitrary point. Then wire this into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
All geometry additions live on existing classes in `OpenNest.Geometry`. The strategy wiring is a change to the existing `ContourCuttingStrategy` in `OpenNest.CNC.CuttingStrategy`.
## Entity Splitting Primitives
### Line.SplitAt(Vector point)
```csharp
public (Line first, Line second) SplitAt(Vector point)
```
- Returns two lines: `StartPoint → point` and `point → EndPoint`.
- If the point is at `StartPoint` (within `Tolerance.Epsilon` distance), `first` is null.
- If the point is at `EndPoint` (within `Tolerance.Epsilon` distance), `second` is null.
- The point is assumed to lie on the line (caller is responsible — it comes from `ClosestPointTo`).
### Arc.SplitAt(Vector point)
```csharp
public (Arc first, Arc second) SplitAt(Vector point)
```
- Computes `splitAngle = Center.AngleTo(point)`, normalized via `Angle.NormalizeRad`.
- First arc: same center, radius, direction — `StartAngle → splitAngle`.
- Second arc: same center, radius, direction — `splitAngle → EndAngle`.
- **Endpoint tolerance**: compare `point.DistanceTo(arc.StartPoint())` and `point.DistanceTo(arc.EndPoint())` rather than comparing angles directly. This avoids wrap-around issues at the 0/2π boundary.
- If the point is at `StartPoint()` (within `Tolerance.Epsilon` distance), `first` is null.
- If the point is at `EndPoint()` (within `Tolerance.Epsilon` distance), `second` is null.
### Circle — no conversion needed
Circles are kept as-is in `ReindexAt`. The `ConvertShapeToMoves` method handles circles directly by emitting an `ArcMove` from the start point back to itself (a full circle), matching the existing `ConvertGeometry.AddCircle` pattern. This avoids the problem of constructing a "full-sweep arc" where `StartAngle == EndAngle` would produce zero sweep.
## Shape.ReindexAt
```csharp
public Shape ReindexAt(Vector point, Entity entity)
```
- `point`: the start/end point for the reindexed contour (from `ClosestPointTo`).
- `entity`: the entity containing `point` (from `ClosestPointTo`'s `out` parameter).
- Returns a **new** Shape (does not modify the original). The new shape shares entity references with the original for unsplit entities — callers must not mutate either.
- Throws `ArgumentException` if `entity` is not found in `Entities`.
### Algorithm
1. If `entity` is a `Circle`:
- Return a new Shape with that single `Circle` entity and `point` stored for `ConvertShapeToMoves` to use as the start point.
2. Find the index `i` of `entity` in `Entities`. Throw `ArgumentException` if not found.
3. Split the entity at `point`:
- `Line``line.SplitAt(point)``(firstHalf, secondHalf)`
- `Arc``arc.SplitAt(point)``(firstHalf, secondHalf)`
4. Build the new entity list (skip null entries):
- `secondHalf` (if not null)
- `Entities[i+1]`, `Entities[i+2]`, ..., `Entities[count-1]` (after the split)
- `Entities[0]`, `Entities[1]`, ..., `Entities[i-1]` (before the split, wrapping around)
- `firstHalf` (if not null)
5. Return a new Shape with this entity list.
### Edge Cases
- **Point lands on entity boundary** (start/end of an entity): one half of the split is null. The reordering still works — it just starts from the next full entity.
- **Single-entity shape that is an Arc**: split produces two arcs, reorder is just `[secondHalf, firstHalf]`.
- **Single-entity Circle**: handled by step 1 — kept as Circle, converted to a full-circle ArcMove in `ConvertShapeToMoves`.
## Wiring into ContourCuttingStrategy
### Entity-to-ICode Conversion
Add a private method to `ContourCuttingStrategy`:
```csharp
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
```
The `startPoint` parameter is needed for the Circle case (to know where the full-circle ArcMove starts).
Iterates `shape.Entities` and converts each to cutting moves using **absolute coordinates** (consistent with `ConvertGeometry`):
- `Line``LinearMove(line.EndPoint)`
- `Arc``ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)`
- `Circle``ArcMove(startPoint, circle.Center, circle.Rotation)` — full circle from start point back to itself, matching `ConvertGeometry.AddCircle`
- Any other entity type → throw `InvalidOperationException`
No `RapidMove` between entities — they are contiguous in a reindexed shape. The lead-in already positions the head at the shape's start point.
### Replace NotImplementedException
In `ContourCuttingStrategy.Apply()`, replace the two `throw new NotImplementedException(...)` blocks:
**Cutout loop** (uses `cutout` shape variable):
```csharp
var reindexed = cutout.ReindexAt(closestPt, entity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
```
**Perimeter block** (uses `profile.Perimeter`):
```csharp
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
```
The full sequence for each contour becomes:
1. Lead-in codes (rapid to pierce point, cutting moves to contour start)
2. Contour body (reindexed entity moves from `ConvertShapeToMoves`)
3. Lead-out codes (overcut moves away from contour)
### MicrotabLeadOut Handling
When the lead-out is `MicrotabLeadOut`, the last cutting move must be trimmed by `GapSize`. This is a separate concern from re-indexing — stub it with a TODO comment for now. The trimming logic will shorten the last `LinearMove` or `ArcMove` in the contour body.
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Core/Geometry/Line.cs` | Add `SplitAt(Vector)` method |
| `OpenNest.Core/Geometry/Arc.cs` | Add `SplitAt(Vector)` method |
| `OpenNest.Core/Geometry/Shape.cs` | Add `ReindexAt(Vector, Entity)` method |
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add `ConvertShapeToMoves`, replace `NotImplementedException` blocks |
## Out of Scope
- **MicrotabLeadOut trimming** (trim last move by gap size — stubbed with TODO)
- **Tab insertion** (inserting tab codes mid-contour — already stubbed)
- **Lead-in editor UI** (interactive start point selection — separate feature)
- **Contour re-indexing for open shapes** (only closed contours supported)

View File

@@ -1,420 +0,0 @@
# CNC Cutting Strategy Design
## Overview
Add lead-in, lead-out, and tab classes to `OpenNest.Core` that generate `ICode` instructions for CNC cutting approach/exit geometry. The strategy runs at nest-time — `ContourCuttingStrategy.Apply()` produces a new `Program` with lead-ins, lead-outs, start points, and contour ordering baked in. This modified program is what gets saved to the nest file and later fed to the post-processor for machine-specific G-code translation. The original `Drawing.Program` stays untouched; the strategy output lives on the `Part`.
All new code lives in `OpenNest.Core/CNC/CuttingStrategy/`.
## File Structure
```
OpenNest.Core/CNC/CuttingStrategy/
├── LeadIns/
│ ├── LeadIn.cs
│ ├── NoLeadIn.cs
│ ├── LineLeadIn.cs
│ ├── LineArcLeadIn.cs
│ ├── ArcLeadIn.cs
│ ├── LineLineLeadIn.cs
│ └── CleanHoleLeadIn.cs
├── LeadOuts/
│ ├── LeadOut.cs
│ ├── NoLeadOut.cs
│ ├── LineLeadOut.cs
│ ├── ArcLeadOut.cs
│ └── MicrotabLeadOut.cs
├── Tabs/
│ ├── Tab.cs
│ ├── NormalTab.cs
│ ├── BreakerTab.cs
│ └── MachineTab.cs
├── ContourType.cs
├── CuttingParameters.cs
├── ContourCuttingStrategy.cs
├── SequenceParameters.cs
└── AssignmentParameters.cs
```
## Namespace
All classes use `namespace OpenNest.CNC.CuttingStrategy`.
## Type Mappings from Original Spec
The original spec used placeholder names. These are the correct codebase types:
| Spec type | Actual type | Notes |
|-----------|------------|-------|
| `PointD` | `Vector` | `OpenNest.Geometry.Vector` — struct with `X`, `Y` fields |
| `CircularMove` | `ArcMove` | Constructor: `ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation)` |
| `CircularDirection` | `RotationType` | Enum with `CW`, `CCW` |
| `value.ToRadians()` | `Angle.ToRadians(value)` | Static method on `OpenNest.Math.Angle` |
| `new Program(codes)` | Build manually | Create `Program()`, add to `.Codes` list |
## LeadIn Hierarchy
### Abstract Base: `LeadIn`
```csharp
public abstract class LeadIn
{
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
}
```
- `contourStartPoint`: where the contour cut begins (first point of the part profile).
- `contourNormalAngle`: normal angle (radians) at the contour start point, pointing **away from the part material** (outward from perimeter, into scrap for cutouts).
- `winding`: contour winding direction — arc-based lead-ins use this for their `ArcMove` rotation.
- `Generate` returns ICode instructions starting with a `RapidMove` to the pierce point, followed by cutting moves to reach the contour start.
- `GetPiercePoint` computes where the head rapids to before firing — useful for visualization and collision detection.
### NoLeadIn (Type 0)
Pierce directly on the contour start point. Returns a single `RapidMove(contourStartPoint)`.
### LineLeadIn (Type 1)
Straight line approach.
Properties:
- `Length` (double): distance from pierce point to contour start (inches)
- `ApproachAngle` (double): approach angle in degrees relative to contour tangent. 90 = perpendicular, 135 = acute angle (common for plasma). Default: 90.
Pierce point offset: `contourStartPoint + Length` along `contourNormalAngle + Angle.ToRadians(ApproachAngle)`.
Generates: `RapidMove(piercePoint)``LinearMove(contourStartPoint)`.
> **Note:** Properties are named `ApproachAngle` (not `Angle`) to avoid shadowing the `OpenNest.Math.Angle` static class. This applies to all lead-in/lead-out/tab classes.
### LineArcLeadIn (Type 2)
Line followed by tangential arc meeting the contour. Most common for plasma.
Properties:
- `LineLength` (double): straight approach segment length
- `ApproachAngle` (double): line angle relative to contour. Default: 135.
- `ArcRadius` (double): radius of tangential arc
Geometry: Pierce → [Line] → Arc start → [Arc] → Contour start. Arc center is at `contourStartPoint + ArcRadius` along normal. Arc rotation direction matches contour winding (CW for CW contours, CCW for CCW).
Generates: `RapidMove(piercePoint)``LinearMove(arcStart)``ArcMove(contourStartPoint, arcCenter, rotation)`.
### ArcLeadIn (Type 3)
Pure arc approach, no straight line segment.
Properties:
- `Radius` (double): arc radius
Pierce point is diametrically opposite the contour start on the arc circle. Arc center at `contourStartPoint + Radius` along normal.
Arc rotation direction matches contour winding.
Generates: `RapidMove(piercePoint)``ArcMove(contourStartPoint, arcCenter, rotation)`.
### LineLineLeadIn (Type 5)
Two-segment straight line approach.
Properties:
- `Length1` (double): first segment length
- `ApproachAngle1` (double): first segment angle. Default: 90.
- `Length2` (double): second segment length
- `ApproachAngle2` (double): direction change. Default: 90.
Generates: `RapidMove(piercePoint)``LinearMove(midPoint)``LinearMove(contourStartPoint)`.
### CleanHoleLeadIn
Specialized for precision circular holes. Same geometry as `LineArcLeadIn` but with hard-coded 135° angle and a `Kerf` property. The overcut (cutting past start to close the hole) is handled at the lead-out, not here.
Properties:
- `LineLength` (double)
- `ArcRadius` (double)
- `Kerf` (double)
## LeadOut Hierarchy
### Abstract Base: `LeadOut`
```csharp
public abstract class LeadOut
{
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
}
```
- `contourEndPoint`: where the contour cut ends. For closed contours, same as start.
- Returns ICode instructions appended after the contour's last cut point.
### NoLeadOut (Type 0)
Returns empty list. Cut ends exactly at contour end.
### LineLeadOut (Type 1)
Straight line overcut past contour end.
Properties:
- `Length` (double): overcut distance
- `ApproachAngle` (double): direction relative to contour tangent. Default: 90.
Generates: `LinearMove(endPoint)` where endPoint is offset from contourEndPoint.
### ArcLeadOut (Type 3)
Arc overcut curving away from the part.
Properties:
- `Radius` (double)
Arc center at `contourEndPoint + Radius` along normal. End point is a quarter turn away. Arc rotation direction matches contour winding.
Generates: `ArcMove(endPoint, arcCenter, rotation)`.
### MicrotabLeadOut (Type 4)
Stops short of contour end, leaving an uncut bridge. Laser only.
Properties:
- `GapSize` (double): uncut material length. Default: 0.03".
Does NOT add instructions — returns empty list. The `ContourCuttingStrategy` detects this type and trims the last cutting move by `GapSize` instead.
## Tab Hierarchy
Tabs are mid-contour features that temporarily lift the beam to leave bridges holding the part in place.
### Abstract Base: `Tab`
```csharp
public abstract class Tab
{
public double Size { get; set; } = 0.03;
public LeadIn TabLeadIn { get; set; }
public LeadOut TabLeadOut { get; set; }
public abstract List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle);
}
```
### NormalTab
Standard tab: cut up to tab start, lift/rapid over gap, resume cutting.
Additional properties:
- `CutoutMinWidth`, `CutoutMinHeight` (double): minimum cutout size to receive this tab
- `CutoutMaxWidth`, `CutoutMaxHeight` (double): maximum cutout size to receive this tab
- `AppliesToCutout(double width, double height)` method for size filtering
Generates: TabLeadOut codes → `RapidMove(tabEndPoint)` → TabLeadIn codes.
### BreakerTab
Like NormalTab but adds a scoring cut into the part at the tab location to make snapping easier.
Additional properties:
- `BreakerDepth` (double): how far the score cuts into the part
- `BreakerLeadInLength` (double)
- `BreakerAngle` (double)
Generates: TabLeadOut codes → `LinearMove(scoreEnd)``RapidMove(tabEndPoint)` → TabLeadIn codes.
### MachineTab
Tab behavior configured at the CNC controller level. OpenNest just signals the controller.
Additional properties:
- `MachineTabId` (int): passed to post-processor for M-code translation
Returns a placeholder `RapidMove(tabEndPoint)` — the post-processor plugin replaces this with machine-specific commands.
## CuttingParameters
One instance per material/machine combination. Ties everything together.
```csharp
public class CuttingParameters
{
public int Id { get; set; }
// Material/Machine identification
public string MachineName { get; set; }
public string MaterialName { get; set; }
public string Grade { get; set; }
public double Thickness { get; set; }
// Kerf and spacing
public double Kerf { get; set; }
public double PartSpacing { get; set; }
// External contour lead-in/out
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
// Internal contour lead-in/out
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, Angle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
// Arc/circle specific (overrides internal for circular features)
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
// Tab configuration
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; } = false;
// Sequencing and assignment
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
}
```
## SequenceParameters and AssignmentParameters
```csharp
// Values match PEP Technology's numbering scheme (value 6 intentionally skipped)
public enum SequenceMethod
{
RightSide = 1, LeastCode = 2, Advanced = 3,
BottomSide = 4, EdgeStart = 5, LeftSide = 7, RightSideAlt = 8
}
public class SequenceParameters
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public double SmallCutoutWidth { get; set; } = 1.5;
public double SmallCutoutHeight { get; set; } = 1.5;
public double MediumCutoutWidth { get; set; } = 8.0;
public double MediumCutoutHeight { get; set; } = 8.0;
public double DistanceMediumSmall { get; set; }
public bool AlternateRowsColumns { get; set; } = true;
public bool AlternateCutoutsWithinRowColumn { get; set; } = true;
public double MinDistanceBetweenRowsColumns { get; set; } = 0.25;
}
public class AssignmentParameters
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public string Preference { get; set; } = "ILAT";
public double MinGeometryLength { get; set; } = 0.01;
}
```
## ContourCuttingStrategy
The orchestrator. Uses `ShapeProfile` to decompose a part into perimeter + cutouts, then sequences and applies cutting parameters using nearest-neighbor chaining from an exit point.
### Exit Point from Plate Quadrant
The exit point is the **opposite corner** of the plate from the quadrant origin. This is where the head ends up after traversing the plate, and is the starting point for backwards nearest-neighbor sequencing.
| Quadrant | Origin | Exit Point |
|----------|--------|------------|
| 1 | TopRight | BottomLeft (0, 0) |
| 2 | TopLeft | BottomRight (width, 0) |
| 3 | BottomLeft | TopRight (width, length) |
| 4 | BottomRight | TopLeft (0, length) |
The exit point is derived from `Plate.Quadrant` and `Plate.Size` — not passed in manually.
### Approach
Instead of requiring `Program.GetStartPoint()` / `GetNormalAtStart()` (which don't exist), the strategy:
1. Computes the **exit point** from the plate's quadrant and size
2. Converts the program to geometry via `Program.ToGeometry()`
3. Builds a `ShapeProfile` from the geometry — gives `Perimeter` (Shape) and `Cutouts` (List&lt;Shape&gt;)
4. Uses `Shape.ClosestPointTo(point, out Entity entity)` to find lead-in points and the entity for normal computation
5. Chains cutouts by nearest-neighbor distance from the perimeter closest point
6. Reverses the chain → cut order is cutouts first (nearest-last), perimeter last
### Contour Re-Indexing
After `ClosestPointTo` finds the lead-in point on a shape, the shape's entity list must be reordered so that cutting starts at that point. This means:
1. Find which entity in `Shape.Entities` contains the closest point
2. Split that entity at the closest point into two segments
3. Reorder: second half of split entity → remaining entities in order → first half of split entity
4. The contour now starts and ends at the lead-in point (for closed contours)
This produces the `List<ICode>` for the contour body that goes between the lead-in and lead-out codes.
### ContourType Detection
- `ShapeProfile.Perimeter``ContourType.External`
- Each cutout in `ShapeProfile.Cutouts`:
- If single entity and entity is `Circle``ContourType.ArcCircle`
- Otherwise → `ContourType.Internal`
### Normal Angle Computation
Derived from the `out Entity` returned by `ClosestPointTo`:
- **Line**: normal is perpendicular to line direction. Use the line's tangent angle, then add π/2 for the normal pointing away from the part interior.
- **Arc/Circle**: normal is radial direction from arc center to the closest point: `closestPoint.AngleFrom(arc.Center)`.
Normal direction convention: always points **away from the part material** (outward from perimeter, inward toward scrap for cutouts). The lead-in approaches from this direction.
### Arc Rotation Direction
Lead-in/lead-out arcs must match the **contour winding direction**, not be hardcoded CW. Determine winding from the shape's entity traversal order. Pass the appropriate `RotationType` to `ArcMove`.
### Method Signature
```csharp
public class ContourCuttingStrategy
{
public CuttingParameters Parameters { get; set; }
/// <summary>
/// Apply cutting strategy to a part's program.
/// </summary>
/// <param name="partProgram">Original part program (unmodified).</param>
/// <param name="plate">Plate for quadrant/size to compute exit point.</param>
/// <returns>New Program with lead-ins, lead-outs, and tabs applied. Cutouts first, perimeter last.</returns>
public Program Apply(Program partProgram, Plate plate)
{
// 1. Compute exit point from plate quadrant + size
// 2. Convert to geometry, build ShapeProfile
// 3. Find closest point on perimeter from exitPoint
// 4. Chain cutouts by nearest-neighbor from perimeter point
// 5. Reverse chain → cut order
// 6. For each contour:
// a. Re-index shape entities to start at closest point
// b. Detect ContourType
// c. Compute normal angle from entity
// d. Select lead-in/out from CuttingParameters by ContourType
// e. Generate lead-in codes + contour body + lead-out codes
// 7. Handle MicrotabLeadOut by trimming last segment
// 8. Assemble and return new Program
}
}
```
### ContourType Enum
```csharp
public enum ContourType
{
External,
Internal,
ArcCircle
}
```
## Integration Point
`ContourCuttingStrategy.Apply()` runs at nest-time (when parts are placed or cutting parameters are assigned), not at post-processing time. The output `Program` — with lead-ins, lead-outs, start points, and contour ordering — is stored on the `Part` and saved through the normal `NestWriter` path. The post-processor receives this already-complete program and only translates it to machine-specific G-code.
## Out of Scope (Deferred)
- **Serialization** of CuttingParameters (JSON/XML discriminators)
- **UI integration** (parameter editor forms in WinForms app)
- **Part.CutProgram property** (storing the strategy-applied program on `Part`, separate from `Drawing.Program`)
- **Tab insertion logic** (`InsertTabs` / `TrimLastSegment` — stubbed with `NotImplementedException`)

View File

@@ -1,134 +0,0 @@
# Nest File Format v2 Design
## Problem
The current nest file format stores metadata across three separate XML files (`info`, `drawing-info`, `plate-info`) plus per-plate G-code files for part placements inside a ZIP archive. This results in ~400 lines of hand-written XML read/write code, fragile dictionary-linking to reconnect drawings/plates by ID after parsing, and the overhead of running the full G-code parser just to extract part positions.
## Design
### File Structure
The nest file remains a ZIP archive. Contents:
```
nest.json
programs/
program-1
program-2
...
```
- **`nest.json`** — single JSON file containing all metadata and part placements.
- **`programs/program-N`** — G-code text for each drawing's CNC program (1-indexed, no zero-padding). Previously stored at the archive root as `program-NNN` (zero-padded). Parsed by `ProgramReader`, written by existing G-code serialization logic. Format unchanged.
Plate G-code files (`plate-NNN`) are removed. Part placements are stored inline in `nest.json`.
### JSON Schema
```json
{
"version": 2,
"name": "string",
"units": "Inches | Millimeters",
"customer": "string",
"dateCreated": "2026-03-12T10:30:00",
"dateLastModified": "2026-03-12T14:00:00",
"notes": "string (plain JSON, no URI-escaping)",
"plateDefaults": {
"size": { "width": 0.0, "height": 0.0 },
"thickness": 0.0,
"quadrant": 1,
"partSpacing": 0.0,
"material": { "name": "string", "grade": "string", "density": 0.0 },
"edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 }
},
"drawings": [
{
"id": 1,
"name": "string",
"customer": "string",
"color": { "a": 255, "r": 0, "g": 0, "b": 0 },
"quantity": { "required": 0 },
"priority": 0,
"constraints": {
"stepAngle": 0.0,
"startAngle": 0.0,
"endAngle": 0.0,
"allow180Equivalent": false
},
"material": { "name": "string", "grade": "string", "density": 0.0 },
"source": {
"path": "string",
"offset": { "x": 0.0, "y": 0.0 }
}
}
],
"plates": [
{
"id": 1,
"size": { "width": 0.0, "height": 0.0 },
"thickness": 0.0,
"quadrant": 1,
"quantity": 1,
"partSpacing": 0.0,
"material": { "name": "string", "grade": "string", "density": 0.0 },
"edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 },
"parts": [
{ "drawingId": 1, "x": 0.0, "y": 0.0, "rotation": 0.0 }
]
}
]
}
```
Key details:
- **Version**: `"version": 2` at the top level for future format migration.
- Drawing `id` values are 1-indexed, matching `programs/program-N` filenames.
- Part `rotation` is stored in **radians** (matches internal domain model, no conversion needed).
- Part `drawingId` references the drawing's `id` in the `drawings` array.
- **Dates**: local time, serialized via `DateTime.ToString("o")` (ISO 8601 round-trip format with timezone offset).
- **Notes**: stored as plain JSON strings. The v1 URI-escaping (`Uri.EscapeDataString`) is not needed since JSON handles special characters natively.
- `quantity.required` is the only quantity persisted; `nested` is computed at load time from part placements.
- **Units**: enum values match the domain model: `Inches` or `Millimeters`.
- **Size**: uses `width`/`height` matching the `OpenNest.Geometry.Size` struct.
- **Drawing.Priority** and **Drawing.Constraints** (stepAngle, startAngle, endAngle, allow180Equivalent) are now persisted (v1 omitted these).
- **Empty collections**: `drawings` and `plates` arrays are always present (may be empty `[]`). The `programs/` folder is empty when there are no drawings.
### Serialization Approach
Use `System.Text.Json` with small DTO (Data Transfer Object) classes for serialization. The DTOs map between the domain model and the JSON structure, keeping serialization concerns out of the domain classes.
### What Changes
| File | Change |
|------|--------|
| `NestWriter.cs` | Replace all XML writing and plate G-code writing with JSON serialization. Programs written to `programs/` folder. |
| `NestReader.cs` | Replace all XML parsing, plate G-code parsing, and dictionary-linking with JSON deserialization. Programs read from `programs/` folder. |
### What Stays the Same
| File | Reason |
|------|--------|
| `ProgramReader.cs` | G-code parsing for CNC programs is unchanged. |
| `NestWriter` G-code writing (`WriteDrawing`, `GetCodeString`) | G-code serialization for programs is unchanged. |
| `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs` | Unrelated to nest file format. |
| Domain model classes | No changes needed. |
### Public API
The public API is unchanged:
- `NestReader(string file)` and `NestReader(Stream stream)` constructors preserved.
- `NestReader.Read()` returns `Nest`.
- `NestWriter(Nest nest)` constructor preserved.
- `NestWriter.Write(string file)` returns `bool`.
### Callers (no changes needed)
- `MainForm.cs:329``new NestReader(path)`
- `MainForm.cs:363``new NestReader(dlg.FileName)`
- `EditNestForm.cs:212``new NestWriter(Nest)`
- `EditNestForm.cs:223``new NestWriter(nst)`
- `Document.cs:27``new NestWriter(Nest)`
- `OpenNest.Console/Program.cs:94``new NestReader(nestFile)`
- `OpenNest.Console/Program.cs:190``new NestWriter(nest)`
- `OpenNest.Mcp/InputTools.cs:30``new NestReader(path)`

View File

@@ -1,92 +0,0 @@
# Iterative Halving Sweep in RotationSlideStrategy
## Problem
`RotationSlideStrategy.GenerateCandidatesForAxis` sweeps the full perpendicular range at `stepSize` (default 0.25"), calling `Helper.DirectionalDistance` at every step. Profiling shows `DirectionalDistance` accounts for 62% of CPU during best-fit computation. For parts with large bounding boxes, this produces hundreds of steps per direction, making the Pairs phase take 2.5+ minutes.
## Solution
Replace the single fine sweep with an iterative halving search inside `GenerateCandidatesForAxis`. Starting at a coarse step size (16× the fine step), each iteration identifies the best offset regions by slide distance, then halves the step and re-sweeps only within narrow windows around those regions. This converges to the optimal offsets in ~85 `DirectionalDistance` calls vs ~160 for a full fine sweep.
## Design
### Modified method: `GenerateCandidatesForAxis`
Located in `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`. The public `GenerateCandidates` method and all other code remain unchanged.
**Current flow:**
1. Sweep `alignedStart` to `perpMax` at `stepSize`
2. At each offset: clone part2, position, compute offset lines, call `DirectionalDistance`, build `PairCandidate`
**New flow:**
**Constants (local to the method):**
- `CoarseMultiplier = 16` — initial step is `stepSize * 16`
- `MaxRegions = 5` — top-N regions to keep per iteration
**Algorithm:**
1. Compute `currentStep = stepSize * CoarseMultiplier`
2. Set the initial sweep range to `[alignedStart, perpMax]` where `alignedStart = Math.Ceiling(perpMin / currentStep) * currentStep`
3. **Iteration loop** — while `currentStep > stepSize`:
a. Sweep all active regions at `currentStep`, collecting `(offset, slideDist)` tuples:
- For each offset in each region: clone part2, position, compute offset lines, call `DirectionalDistance`
- Skip if `slideDist >= double.MaxValue || slideDist < 0`
b. Select top `MaxRegions` hits by `slideDist` ascending (tightest fit first), deduplicating any hits within `currentStep` of an already-selected hit
c. Build new regions: for each selected hit, the new region is `[offset - currentStep, offset + currentStep]`, clamped to `[perpMin, perpMax]`
d. Halve: `currentStep /= 2`
e. Align each region's start to a multiple of `currentStep`
4. **Final pass** — sweep all active regions at `stepSize`, generating full `PairCandidate` objects (same logic as current code: clone part2, position, compute offset lines, `DirectionalDistance`, build candidate)
**Iteration trace for a 20" range with `stepSize = 0.25`:**
| Pass | Step | Regions | Samples per region | Total samples |
|------|------|---------|--------------------|---------------|
| 1 | 4.0 | 1 (full range) | ~5 | ~5 |
| 2 | 2.0 | up to 5 | ~4 | ~20 |
| 3 | 1.0 | up to 5 | ~4 | ~20 |
| 4 | 0.5 | up to 5 | ~4 | ~20 |
| 5 (final) | 0.25 | up to 5 | ~4 | ~20 (generates candidates) |
| **Total** | | | | **~85** vs **~160 current** |
**Alignment:** Each pass aligns its sweep start to a multiple of `currentStep`. Since `currentStep` is always a power-of-two multiple of `stepSize`, offset=0 is always a sample point when it falls within a region. This preserves perfect grid arrangements for rectangular parts.
**Region deduplication:** When selecting top hits, any hit whose offset is within `currentStep` of a previously selected hit is skipped. This prevents overlapping refinement windows from wasting samples on the same area.
### Integration points
The changes are entirely within the private method `GenerateCandidatesForAxis`. The method signature, parameters, and return type (`List<PairCandidate>`) are unchanged. The only behavioral difference is that it generates fewer candidates overall (only from the promising regions), but those candidates cover the same quality range because the iterative search converges on the best offsets.
### Performance
- Current: ~160 `DirectionalDistance` calls per direction (20" range / 0.25 step)
- Iterative halving: ~85 calls (5 + 20 + 20 + 20 + 20)
- ~47% reduction in `DirectionalDistance` calls per direction
- Coarse passes are cheaper per-call since they only store `(offset, slideDist)` tuples rather than building full `PairCandidate` objects
- Total across 4 directions × N angles: proportional reduction throughout
- For larger parts (40"+ range), the savings are even greater since the coarse pass covers the range in very few samples
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Replace single sweep in `GenerateCandidatesForAxis` with iterative halving sweep |
## What Doesn't Change
- `RotationSlideStrategy.GenerateCandidates` — unchanged, calls `GenerateCandidatesForAxis` as before
- `BestFitFinder` — unchanged, calls `strategy.GenerateCandidates` as before
- `BestFitCache` — unchanged
- `PairEvaluator` / `IPairEvaluator` — unchanged
- `PairCandidate`, `BestFitResult`, `BestFitFilter` — unchanged
- `Helper.DirectionalDistance`, `Helper.GetOffsetPartLines` — reused as-is
- `NestEngine.FillWithPairs` — unchanged caller
## Edge Cases
- **Part smaller than initial coarseStep:** The first pass produces very few samples (possibly 1-2), but each subsequent halving still narrows correctly. For tiny parts, the total range may be smaller than `coarseStep`, so the algorithm effectively skips to finer passes quickly.
- **Refinement regions overlap after halving:** Deduplication at each iteration prevents selecting nearby hits. Even if two regions share a boundary after halving, at worst one offset is evaluated twice — negligible cost.
- **No valid hits at any pass:** If all offsets at a given step produce invalid slide distances, the hit list is empty, no regions are generated, and subsequent passes produce no candidates. This matches current behavior for parts that can't pair in the given direction.
- **Sweep region extends past bounds:** All regions are clamped to `[perpMin, perpMax]` at each iteration.
- **Only one valid region found:** The algorithm works correctly with 1 region — it just refines a single window instead of 5. This is common for simple rectangular parts where there's one clear best offset.
- **stepSize is not a power of two:** The halving produces steps like 4.0 → 2.0 → 1.0 → 0.5 → 0.25 regardless of whether `stepSize` is a power of two. The loop condition `currentStep > stepSize` terminates correctly because `currentStep` will eventually equal `stepSize` after enough halvings (since `CoarseMultiplier` is a power of 2).

View File

@@ -1,163 +0,0 @@
# Nesting Progress Window Design
## Problem
The auto-nest and fill operations run synchronously on the UI thread, freezing the application until complete. The user has no visibility into what the engine is doing, no way to stop early, and no preview of intermediate results.
## Solution
Run nesting on a background thread with `IProgress<NestProgress>` callbacks. Show a modeless progress dialog with current-best stats and a Stop button. Render the current best layout as temporary parts on the PlateView in a distinct preview color.
## Progress Data Model
**New file: `OpenNest.Engine/NestProgress.cs`**
A class carrying progress updates from the engine to the UI:
- `Phase` (NestPhase enum): Current strategy — `Linear`, `RectBestFit`, `Pairs`, `Remainder`
- `PlateNumber` (int): Current plate number (for auto-nest multi-plate loop)
- `BestPartCount` (int): Part count of current best result
- `BestDensity` (double): Density percentage of current best
- `UsableRemnantArea` (double): Usable remnant area of current best (matches `FillScore.UsableRemnantArea`)
- `BestParts` (List\<Part\>): Cloned snapshot of the best parts for preview
`Phase` uses a `NestPhase` enum (defined in the same file) to prevent typos and allow the progress form to map to display-friendly text (e.g., `NestPhase.Pairs` → "Trying pairs...").
`BestParts` must be a cloned list (using `Part.Clone()`) so the UI thread can safely read it while the engine continues on the background thread. The clones share `BaseDrawing` references (not deep copies of drawings) since drawings are read-only templates during nesting. Progress is reported only after each phase completes (3-4 reports per fill call), so the cloning cost is negligible.
## Engine Changes
**Modified file: `OpenNest.Engine/NestEngine.cs`**
### Return type change
The new overloads return `List<Part>` instead of `bool`. They do **not** call `Plate.Parts.AddRange()` — the caller is responsible for committing parts to the plate. This is critical because:
1. The engine runs on a background thread and must not touch `Plate.Parts` (an `ObservableList` that fires UI events).
2. It cleanly separates the "compute" phase from the "commit" phase, allowing the UI to preview results as temporary parts before committing.
Existing `bool Fill(...)` overloads remain unchanged — they delegate to the new overloads and call `Plate.Parts.AddRange()` themselves, preserving current behavior for all existing callers (MCP tools, etc.).
### Fill overloads
New signatures:
- `List<Part> Fill(NestItem item, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
- `List<Part> Fill(List<Part> groupParts, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
**Note on `Fill(List<Part>, ...)` overload:** When `groupParts.Count > 1`, only the Linear phase runs (no RectBestFit, Pairs, or Remainder). The additional phases only apply when `groupParts.Count == 1`, matching the existing engine behavior.
Inside `FindBestFill`, after each strategy completes:
1. **Linear phase**: Try all rotation angles (already uses `Parallel.ForEach`). If new best found, report progress with `Phase=Linear`. Check cancellation token.
2. **RectBestFit phase**: If new best found, report progress with `Phase=RectBestFit`. Check cancellation token.
3. **Pairs phase**: Try all pair candidates (already uses `Parallel.For`). If new best found, report progress with `Phase=Pairs`. Check cancellation token.
4. **Remainder improvement**: If new best found, report progress with `Phase=Remainder`.
### Cancellation Behavior
On cancellation, the engine returns its current best result (not null/empty). `OperationCanceledException` is caught internally so the caller always gets a usable result. This enables "stop early, keep best result."
The `CancellationToken` is also passed into `ParallelOptions` for the existing `Parallel.ForEach` (linear phase) and `Parallel.For` (pairs phase) loops, so cancellation is responsive even mid-phase rather than only at phase boundaries.
## PlateView Temporary Parts
**Modified file: `OpenNest/Controls/PlateView.cs`**
Add a separate temporary parts list alongside the existing `parts` list:
```csharp
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
```
### Drawing
In `DrawParts`, after drawing real parts, iterate `temporaryParts` and draw them using a distinct preview color. The preview color is added to `ColorScheme` (e.g., `PreviewPart`) so it follows the existing theming pattern. Same drawing logic, different pen/brush.
### Public API
- `SetTemporaryParts(List<Part> parts)` — Clears existing temp parts, builds `LayoutPart` wrappers from the provided parts, triggers redraw.
- `ClearTemporaryParts()` — Clears temp parts and redraws.
- `AcceptTemporaryParts()` — Adds the temp parts to the real `Plate.Parts` collection (which triggers quantity tracking via `ObservableList` events), then clears the temp list.
`AcceptTemporaryParts()` is the sole "commit" path. The engine never writes to `Plate.Parts` directly when using the progress overloads.
### Thread Safety
`SetTemporaryParts` is called from `IProgress<T>` callbacks. When using `Progress<T>` constructed on the UI thread, callbacks are automatically marshalled via `SynchronizationContext`. No extra marshalling needed.
## NestProgressForm (Modeless Dialog)
**New files: `OpenNest/Forms/NestProgressForm.cs`, `NestProgressForm.Designer.cs`, `NestProgressForm.resx`**
A small modeless dialog with a `TableLayoutPanel` layout:
```
┌──────────────────────────────┐
│ Phase: Trying pairs... │
│ Plate: 2 │
│ Parts: 156 │
│ Density: 68.3% │
│ Remnant: 0.0 sq in │
│ │
│ [ Stop ] │
└──────────────────────────────┘
```
Two-column `TableLayoutPanel`: left column is fixed-width labels, right column is auto-sized values. Stop button below the table.
The Plate row shows just the current plate number (no total — the total is not known in advance since it depends on how many parts fit per plate). The Plate row is hidden when running a single-plate fill.
### Behavior
- Opened via `form.Show(owner)` — modeless, stays on top of MainForm
- Receives `NestProgress` updates and refreshes labels
- Stop button triggers `CancellationTokenSource.Cancel()`, changes text to "Stopping..." (disabled)
- On engine completion (natural or cancelled), auto-closes or shows "Done" with Close button
- Closing via X button acts the same as Stop — cancels and accepts current best
## MainForm Integration
**Modified file: `OpenNest/Forms/MainForm.cs`**
### RunAutoNest_Click (auto-nest)
1. Show `AutoNestForm` dialog as before to get `NestItems`
2. Create `CancellationTokenSource`, `Progress<NestProgress>`, `NestProgressForm`
3. Open progress form modeless
4. `Task.Run` with the multi-plate loop. The background work computes results only — all UI/plate mutation happens on the UI thread via `Progress<T>` callbacks and the `await` continuation:
- The loop iterates items, calling the new `Fill(item, workArea, progress, token)` overloads which return `List<Part>` without modifying the plate.
- Progress callbacks update the preview via `SetTemporaryParts()` on the UI thread.
- When a plate's fill completes, the continuation (back on the UI thread) counts the returned parts per drawing (from the last `NestProgress.BestParts`) to decrement `NestItem.Quantity`, then calls `AcceptTemporaryParts()` to commit to the plate. `Nest.CreatePlate()` for the next plate also happens on the UI thread.
- On cancellation, breaks out of the loop and commits whatever was last previewed.
5. On completion, call `Nest.UpdateDrawingQuantities()`, close progress form
6. Disable nesting-related menu items while running, re-enable on completion
7. Dispose `CancellationTokenSource` when done
### ActionFillArea / single-plate Fill
Same pattern but simpler — no plate loop, single fill call with progress/cancellation. The progress form is created and owned by the code that launches the fill (in MainForm, not inside ActionFillArea). The Plate row is hidden. Escape key during the action cancels the token (same as clicking Stop).
### UI Lockout
While the engine runs, the user can pan/zoom the PlateView (read-only interaction) but editing actions (add/remove parts, change plates, plate navigation) are disabled. Plate navigation is locked during auto-nest to prevent the PlateView from switching away from the plate being filled. Re-enabled when nesting completes or is stopped.
If the user closes the `EditNestForm` (MDI child) while nesting is running, the cancellation token is triggered and the progress form is closed. No partial results are committed.
### Error Handling
The `Task.Run` body is wrapped in try/catch. If the engine throws an unexpected exception (not `OperationCanceledException`), the continuation shows a `MessageBox` with the error, clears temporary parts, and re-enables the UI. No partial results are committed on unexpected errors.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest.Engine/NestProgress.cs` | New — progress data model + `NestPhase` enum |
| `OpenNest.Engine/NestEngine.cs` | New `List<Part>`-returning overloads with `IProgress`/`CancellationToken` |
| `OpenNest/Controls/PlateView.cs` | Temporary parts list + drawing |
| `OpenNest/Forms/NestProgressForm.cs` (+Designer, +resx) | New — modeless progress dialog |
| `OpenNest/Forms/MainForm.cs` | Rewire auto-nest and fill to async with progress |
## Not Changed
OpenNest.Core, OpenNest.IO, OpenNest.Mcp, EditNestForm, existing engine callers (MCP tools, etc.). The existing `bool Fill(...)` overloads continue to work as before by delegating to the new overloads and calling `Plate.Parts.AddRange()` themselves.

View File

@@ -1,94 +0,0 @@
# NFP Strategy in FindBestFill
## Problem
`NestEngine.FindBestFill()` currently runs three rectangle-based strategies (Linear, RectBestFit, Pairs) that treat parts as bounding boxes. For non-rectangular parts (L-shapes, circles, irregular profiles), this wastes significant plate area because the strategies can't interlock actual part geometry.
The NFP infrastructure already exists (used by `AutoNest`) but is completely separate from the single-drawing fill path.
## Solution
Add `FillNfpBestFit` as a new competing strategy in `FindBestFill()`. It uses the existing NFP/BLF infrastructure to place many copies of a single drawing using actual part geometry instead of bounding boxes. It only runs when the part is non-rectangular (where it can actually improve on grid packing).
## Design
### New method: `FillNfpBestFit(NestItem item, Box workArea)`
Located in `NestEngine.cs`, private method alongside `FillRectangleBestFit` and `FillWithPairs`.
**Algorithm:**
1. Compute `halfSpacing = Plate.PartSpacing / 2.0`
2. Extract the offset perimeter polygon via `ExtractPerimeterPolygon(drawing, halfSpacing)` (already exists as a private static method in NestEngine). Returns null if invalid — return empty list.
3. **Rectangularity gate:** compute `polygon.Area() / polygon.BoundingBox.Area()`. If ratio > 0.95, return empty list — grid strategies already handle rectangular parts optimally. Note: `BoundingBox` is a property (set by `UpdateBounds()` which `ExtractPerimeterPolygon` calls before returning).
4. Compute candidate rotation angles via `ComputeCandidateRotations(item, polygon, workArea)` (already exists in NestEngine — computes hull edge angles, adds 0° and 90°, adds narrow-area sweep). Then filter the results by `NestItem.RotationStart` / `NestItem.RotationEnd` window (keep angles where `RotationStart <= angle <= RotationEnd`; if both are 0, treat as unconstrained). This filtering is applied locally after `ComputeCandidateRotations` returns — the shared method is not modified, so `AutoNest` behavior is unchanged.
5. Build an `NfpCache`:
- For each candidate rotation, rotate the polygon via `RotatePolygon()` and register it via `nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon)`
- Call `nfpCache.PreComputeAll()` — since all entries share the same drawing ID, this computes NFPs between all rotation pairs of the single part shape
6. For each candidate rotation, run `BottomLeftFill.Fill()`:
- Build a sequence of N copies of `(drawing.Id, rotation, drawing)`
- N = `(int)(workArea.Area() / polygon.Area())`, capped to 500 max, and further capped to `item.Quantity` when Quantity > 0 (avoids wasting BLF cycles on parts that will be discarded)
- Convert BLF result via `BottomLeftFill.ToNestParts()` to get `List<Part>`
- Score via `FillScore.Compute(parts, workArea)`
7. Return the parts list from the highest-scoring rotation
### Integration points
**Both `FindBestFill` overloads** — insert after the Pairs phase, before remainder improvement:
```csharp
// NFP phase (non-rectangular parts only)
var nfpResult = FillNfpBestFit(item, workArea);
Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts");
if (IsBetterFill(nfpResult, best, workArea))
{
best = nfpResult;
ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea);
}
```
The progress-reporting overload also adds `token.ThrowIfCancellationRequested()` before the NFP phase.
**`Fill(List<Part> groupParts, ...)` overload** — this method runs its own RectBestFit and Pairs phases inline when `groupParts.Count == 1`, bypassing `FindBestFill`. Add the NFP phase here too, after Pairs and before remainder improvement, following the same pattern.
### NestPhase enum
Add `Nfp` after `Pairs`:
```csharp
public enum NestPhase
{
Linear,
RectBestFit,
Pairs,
Nfp,
Remainder
}
```
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/NestEngine.cs` | Add `FillNfpBestFit()` method; call from both `FindBestFill` overloads and the `Fill(List<Part>, ...)` single-drawing path, after Pairs phase |
| `OpenNest.Engine/NestProgress.cs` | Add `Nfp` to `NestPhase` enum |
## What Doesn't Change
- `FillBestFit`, `FillLinear`, `FillWithPairs` — untouched
- `AutoNest` — separate code path, untouched
- `BottomLeftFill`, `NfpCache`, `NoFitPolygon`, `InnerFitPolygon` — reused as-is, no modifications
- `ComputeCandidateRotations`, `ExtractPerimeterPolygon`, `RotatePolygon` — reused as-is
- UI callers (`ActionFillArea`, `ActionClone`, `PlateView.FillWithProgress`) — no changes
- MCP tools (`NestingTools`) — no changes
## Edge Cases
- **Part with no valid perimeter polygon:** `ExtractPerimeterPolygon` returns null → return empty list
- **Rectangularity ratio > 0.95:** skip NFP entirely, grid strategies are optimal
- **All rotations filtered out by constraints:** no BLF runs → return empty list
- **BLF places zero parts at a rotation:** skip that rotation, try others
- **Very small work area where part doesn't fit:** IFP computation returns invalid polygon → BLF places nothing → return empty list
- **Large plate with small part:** N capped to 500 to keep BLF O(N^2) cost manageable
- **item.Quantity is set:** N further capped to Quantity to avoid placing parts that will be discarded

View File

@@ -1,218 +0,0 @@
# ML Angle Pruning Design
**Date:** 2026-03-14
**Status:** Draft
## Problem
The nesting engine's biggest performance bottleneck is `FillLinear.FillRecursive`, which consumes ~66% of total CPU time. The linear phase builds a list of rotation angles to try — normally just 2 (`bestRotation` and `bestRotation + 90`), but expanding to a full 36-angle sweep (0-175 in 5-degree increments) when the work area's short side is smaller than the part's longest side. This narrow-work-area condition triggers frequently during remainder-strip fills and for large/elongated parts. Each angle x 2 directions requires expensive ray/edge distance calculations for every tile placement.
## Goal
Train an ML model that predicts which rotation angles are competitive for a given part geometry and sheet size. At runtime, replace the full angle sweep with only the predicted angles, reducing linear phase compute time in the narrow-work-area case. The model only applies when the engine would otherwise sweep all 36 angles — for the normal 2-angle case, no change is needed.
## Design
### Training Data Collection
#### Forced Full Sweep for Training
In production, `FindBestFill` only sweeps all 36 angles when `workAreaShortSide < partLongestSide`. For training, the sweep must be forced for every part x sheet combination regardless of this condition — otherwise the model has no data to learn from for the majority of runs that only evaluate 2 angles.
`NestEngine` gains a `ForceFullAngleSweep` property (default `false`). When `true`, `FindBestFill` always builds the full 0-175 angle list. The training runner sets this to `true`; production code leaves it `false`.
#### Per-Angle Results from NestEngine
Instrument `NestEngine.FindBestFill` to collect per-angle results from the linear phase. Each call to `FillLinear.Fill(drawing, angle, direction)` produces a result that is currently only compared against the running best. With this change, each result is also accumulated into a collection on the engine instance.
New types in `NestProgress.cs`:
```csharp
public class AngleResult
{
public double AngleDeg { get; set; }
public NestDirection Direction { get; set; }
public int PartCount { get; set; }
}
```
New properties on `NestEngine`:
```csharp
public bool ForceFullAngleSweep { get; set; }
public List<AngleResult> AngleResults { get; } = new();
```
`AngleResults` is cleared at the start of `Fill` (alongside `PhaseResults.Clear()`). Populated inside the `Parallel.ForEach` over angles in `FindBestFill` — uses a `ConcurrentBag<AngleResult>` during the parallel loop, then transferred to `AngleResults` via `AddRange` after the loop completes (same pattern as the existing `linearBag`).
#### Progress Window Enhancement
`NestProgress` gains a `Description` field — a freeform status string that the progress window displays directly:
```csharp
public class NestProgress
{
// ... existing fields ...
public string Description { get; set; }
}
```
Progress is reported per-angle during the linear phase (e.g. `"Linear: 35 V - 48 parts"`) and per-candidate during the pairs phase (e.g. `"Pairs: candidate 12/50"`). This gives real-time visibility into what the engine is doing, beyond the current phase-level updates.
#### BruteForceRunner Changes
`BruteForceRunner.Run` reads `engine.AngleResults` after `Fill` completes and passes them through `BruteForceResult`:
```csharp
public class BruteForceResult
{
// ... existing fields ...
public List<AngleResult> AngleResults { get; set; }
}
```
The training runner sets `engine.ForceFullAngleSweep = true` before calling `Fill`.
#### Database Schema
New `AngleResults` table:
| Column | Type | Description |
|-----------|---------|--------------------------------------|
| Id | long | PK, auto-increment |
| RunId | long | FK to Runs table |
| AngleDeg | double | Rotation angle in degrees (0-175) |
| Direction | string | "Horizontal" or "Vertical" |
| PartCount | int | Parts placed at this angle/direction |
Each run produces up to ~72 rows (36 angles x 2 directions, minus angles where zero parts fit). With forced full sweep during training: 41k parts x 11 sheet sizes x ~72 angle results = ~32 million rows. SQLite handles this for batch writes; SQL Express on barge.lan is available as a fallback if needed.
New EF Core entity `TrainingAngleResult` in `OpenNest.Training/Data/`. `TrainingDatabase.AddRun` is extended to accept and batch-insert angle results alongside the run.
Migration: `MigrateSchema` creates the `AngleResults` table if it doesn't exist. Existing databases without the table continue to work — the table is created on first use.
### Model Architecture
**Type:** XGBoost multi-label classifier exported to ONNX.
**Input features (11 scalars):**
- Part geometry (7): Area, Convexity, AspectRatio, BBFill, Circularity, PerimeterToAreaRatio, VertexCount
- Sheet dimensions (2): Width, Height
- Derived (2): SheetAspectRatio (Width/Height), PartToSheetAreaRatio (PartArea / SheetArea)
The 32x32 bitmask is excluded from the initial model. The 7 scalar geometry features capture sufficient shape information for angle prediction. Bitmask can be added later if accuracy needs improvement.
**Output:** 36 probabilities, one per 5-degree angle bin (0, 5, 10, ..., 175). Each probability represents "this angle is competitive for this part/sheet combination."
**Label generation:** For each part x sheet run, an angle is labeled positive (1) if its best PartCount (max of H and V directions) is >= 95% of the overall best angle's PartCount for that run. This creates a multi-label target where typically 2-8 angles are labeled positive.
**Direction handling:** The model predicts angles only. Both H and V directions are always tried for each selected angle — direction computation is cheap relative to the angle setup.
### Training Pipeline
Python notebook at `OpenNest.Training/notebooks/train_angle_model.ipynb`:
1. **Extract** — Read SQLite database, join Parts + Runs + AngleResults into a flat dataframe.
2. **Filter** — Remove title block outliers using feature thresholds (e.g. BBFill < 0.01, abnormally large bounding boxes relative to actual geometry area). Log filtered parts for manual review.
3. **Label** — For each run, compute the best angle's PartCount. Mark angles within 95% as positive. Build a 36-column binary label matrix.
4. **Feature engineering** — Compute derived features (SheetAspectRatio, PartToSheetAreaRatio). Normalize if needed.
5. **Train** — XGBoost multi-label classifier. Use `sklearn.multioutput.MultiOutputClassifier` wrapping `xgboost.XGBClassifier`. Train/test split stratified by part (all sheet sizes for a part stay in the same split).
6. **Evaluate** — Primary metric: per-angle recall > 95% (must almost never skip the winning angle). Secondary: precision > 60% (acceptable to try a few extra angles). Report average angles predicted per part.
7. **Export** — Convert to ONNX via `skl2onnx` or `onnxmltools`. Save to `OpenNest.Engine/Models/angle_predictor.onnx`.
Python dependencies: `pandas`, `scikit-learn`, `xgboost`, `onnxmltools` (or `skl2onnx`), `matplotlib` (for evaluation plots).
### C# Inference Integration
New file `OpenNest.Engine/ML/AnglePredictor.cs`:
```csharp
public static class AnglePredictor
{
public static List<double> PredictAngles(
PartFeatures features, double sheetWidth, double sheetHeight);
}
```
- Loads `angle_predictor.onnx` from the `Models/` directory adjacent to the Engine DLL on first call. Caches the ONNX session for reuse.
- Runs inference with the 11 input features.
- Applies threshold (default 0.3) to the 36 output probabilities.
- Returns angles above threshold, converted to radians.
- Always includes 0 and 90 degrees as safety fallback.
- Minimum 3 angles returned (if fewer pass threshold, take top 3 by probability).
- If the model file is missing or inference fails, returns `null` — caller falls back to trying all angles (current behavior unchanged).
**NuGet dependency:** `Microsoft.ML.OnnxRuntime` added to `OpenNest.Engine.csproj`.
### NestEngine Integration
In `FindBestFill` (the progress/token overload), the angle list construction changes:
```
Current:
angles = [bestRotation, bestRotation + 90]
+ sweep 0-175 if narrow work area
With model (only when narrow work area condition is met):
predicted = AnglePredictor.PredictAngles(features, sheetW, sheetH)
if predicted != null:
angles = predicted
+ bestRotation and bestRotation + 90 (if not already included)
else:
angles = current behavior (full sweep)
ForceFullAngleSweep = true (training only):
angles = full 0-175 sweep regardless of work area condition
```
`FeatureExtractor.Extract(drawing)` is called once per drawing before the fill loop. This is cheap (~0ms) and already exists.
**Note:** The Pairs phase (`FillWithPairs`) uses hull-edge angles from each pair candidate's geometry, not the linear angle list. The ML model does not affect the Pairs phase angle selection. Pairs phase optimization (e.g. pruning pair candidates) is a separate future concern.
### Fallback and Safety
- **No model file:** Full angle sweep (current behavior). Zero regression risk.
- **Model loads but prediction fails:** Full angle sweep. Logged to Debug output.
- **Model predicts too few angles:** Minimum 3 angles enforced. 0, 90, bestRotation, and bestRotation + 90 always included.
- **Normal 2-angle case (no narrow work area):** Model is not consulted — the engine only tries bestRotation and bestRotation + 90 as it does today.
- **Model misses the optimal angle:** Recall target of 95% means ~5% of runs may not find the absolute best. The result will still be good (within 95% of optimal by definition of the training labels). Users can disable the model via a setting if needed.
## Files Changed
### OpenNest.Engine
- `NestProgress.cs` — Add `AngleResult` class, add `Description` to `NestProgress`
- `NestEngine.cs` — Add `ForceFullAngleSweep` and `AngleResults` properties, clear `AngleResults` alongside `PhaseResults`, populate per-angle results in `FindBestFill` via `ConcurrentBag` + `AddRange`, report per-angle progress with descriptions, use `AnglePredictor` for angle selection when narrow work area
- `ML/BruteForceRunner.cs` — Pass through `AngleResults` from engine
- `ML/AnglePredictor.cs` — New: ONNX model loading and inference
- `ML/FeatureExtractor.cs` — No changes (already exists)
- `Models/angle_predictor.onnx` — New: trained model file (added after training)
- `OpenNest.Engine.csproj` — Add `Microsoft.ML.OnnxRuntime` NuGet package
### OpenNest.Training
- `Data/TrainingAngleResult.cs` — New: EF Core entity for AngleResults table
- `Data/TrainingDbContext.cs` — Add `DbSet<TrainingAngleResult>`
- `Data/TrainingRun.cs` — No changes
- `TrainingDatabase.cs` — Add angle result storage, extend `MigrateSchema`
- `Program.cs` — Set `ForceFullAngleSweep = true` on engine, collect and store per-angle results from `BruteForceRunner`
### OpenNest.Training/notebooks (new directory)
- `train_angle_model.ipynb` — Training notebook
- `requirements.txt` — Python dependencies
### OpenNest (WinForms)
- Progress window UI — Display `NestProgress.Description` string (minimal change)
## Data Volume Estimates
- 41k parts x 11 sheet sizes = ~450k runs
- With forced full sweep: ~72 angle results per run = ~32 million angle result rows
- SQLite can handle this for batch writes. SQL Express on barge.lan available as fallback.
- Trained model file: ~1-5 MB ONNX
## Success Criteria
- Per-angle recall > 95% (almost never skips the winning angle)
- Average angles predicted: 4-8 per part (down from 36)
- Linear phase speedup in narrow-work-area case: 70-80% reduction
- Zero regression when model is absent — current behavior preserved exactly
- Progress window shows live angle/candidate details during nesting

View File

@@ -1,195 +0,0 @@
# Abstract Nest Engine Design Spec
**Date:** 2026-03-15
**Goal:** Create a pluggable nest engine architecture so users can create custom nesting algorithms, switch between engines globally, and load third-party engines as plugins.
---
## Motivation
The current `NestEngine` is a concrete class with a sophisticated multi-phase fill strategy (Linear, Pairs, RectBestFit, Remainder). Different part geometries benefit from different algorithms — circles need circle-packing, strip-based layouts work better for mixed-drawing nests, and users may want to experiment with their own approaches. The engine needs to be swappable without changing the UI or other consumers.
## Architecture Overview
```
NestEngineBase (abstract, OpenNest.Engine)
├── DefaultNestEngine (current multi-phase logic)
├── StripNestEngine (strip-based multi-drawing nesting)
├── CircleNestEngine (future, circle-packing)
└── [Plugin engines loaded from DLLs]
NestEngineRegistry (static, OpenNest.Engine)
├── Tracks available engines (built-in + plugins)
├── Manages active engine selection (global)
└── Factory method: Create(Plate) → NestEngineBase
```
**Note on AutoNester:** The existing `AutoNester` static class (NFP + simulated annealing for mixed parts) is a natural future candidate for the registry but is currently unused by any caller. It is out of scope for this refactor — it can be wrapped as an engine later when it's ready for use.
## NestEngineBase
Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods.
**Instance lifetime:** Engine instances are short-lived and plate-specific — created per operation via the registry factory. Some engines (like `DefaultNestEngine`) maintain internal state across multiple `Fill` calls on the same instance (e.g., `knownGoodAngles` for angle pruning). Plugin authors should be aware that a single engine instance may receive multiple `Fill` calls within one nesting session.
### Properties
| Property | Type | Notes |
|----------|------|-------|
| `Plate` | `Plate` | The plate being nested |
| `PlateNumber` | `int` | For progress reporting |
| `NestDirection` | `NestDirection` | Fill direction preference, set by callers after creation |
| `WinnerPhase` | `NestPhase` | Which phase produced the best result (protected set) |
| `PhaseResults` | `List<PhaseResult>` | Per-phase results for diagnostics |
| `AngleResults` | `List<AngleResult>` | Per-angle results for diagnostics |
### Abstract Members
| Member | Type | Purpose |
|--------|------|---------|
| `Name` | `string` (get) | Display name for UI/registry |
| `Description` | `string` (get) | Human-readable description |
### Virtual Methods (return parts, no side effects)
These are the core methods subclasses override. Base class default implementations return empty lists — subclasses override the ones they support.
```csharp
virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
```
**`FillExact` is non-virtual.** It is orchestration logic (binary search wrapper around `Fill`) that works regardless of the underlying fill algorithm. It lives in the base class and calls the virtual `Fill` method. Any engine that implements `Fill` gets `FillExact` for free.
**`PackArea` signature change:** The current `PackArea(Box, List<NestItem>)` mutates the plate directly and returns `bool`. The new virtual method adds `IProgress<NestProgress>` and `CancellationToken` parameters and returns `List<Part>` (side-effect-free). This is a deliberate refactor — the old mutating behavior moves to the convenience overload `Pack(List<NestItem>)`.
### Convenience Overloads (non-virtual, add parts to plate)
These call the virtual methods and handle plate mutation:
```csharp
bool Fill(NestItem item)
bool Fill(NestItem item, Box workArea)
bool Fill(List<Part> groupParts)
bool Fill(List<Part> groupParts, Box workArea)
bool Pack(List<NestItem> items)
```
Pattern: call the virtual method → if parts returned → add to `Plate.Parts` → return `true`.
### Protected Utilities
Available to all subclasses:
- `ReportProgress(IProgress<NestProgress>, NestPhase, int plateNumber, List<Part>, Box, string)` — clone parts and report
- `BuildProgressSummary()` — format PhaseResults into a status string
- `IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)` — FillScore comparison
- `IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)` — with overlap check
## DefaultNestEngine
Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all virtual methods with the existing multi-phase logic.
- `Name``"Default"`
- `Description``"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"`
- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, `QuickFillCount`, etc.) remain as private methods in this class
- `ForceFullAngleSweep` property stays on `DefaultNestEngine` (not the base class) — only used by `BruteForceRunner` which references `DefaultNestEngine` directly
- `knownGoodAngles` HashSet stays as a private field — accumulates across multiple `Fill` calls for angle pruning
- No behavioral change — purely structural refactor
## StripNestEngine
The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase` subclass instead of a standalone class.
- `Name``"Strip"`
- `Description``"Strip-based nesting for mixed-drawing layouts"`
- Overrides `Fill` for multi-item scenarios with its strip+remnant strategy
- Uses `DefaultNestEngine` internally as a building block for individual strip/remnant fills (composition, not inheritance from Default)
## NestEngineRegistry
Static class in `OpenNest.Engine` managing engine discovery and selection. Accessed only from the UI thread — not thread-safe. Engines are created per-operation and used on background threads, but the registry itself is only mutated/queried from the UI thread at startup and when the user changes the active engine.
### NestEngineInfo
```csharp
class NestEngineInfo
{
string Name { get; }
string Description { get; }
Func<Plate, NestEngineBase> Factory { get; }
}
```
### API
| Member | Purpose |
|--------|---------|
| `List<NestEngineInfo> AvailableEngines` | All registered engines |
| `string ActiveEngineName` | Currently selected engine (defaults to `"Default"`) |
| `NestEngineBase Create(Plate plate)` | Creates instance of active engine |
| `void Register(string name, string description, Func<Plate, NestEngineBase> factory)` | Register a built-in engine |
| `void LoadPlugins(string directory)` | Scan DLLs for NestEngineBase subclasses |
### Built-in Registration
```csharp
Register("Default", "Multi-phase nesting...", plate => new DefaultNestEngine(plate));
Register("Strip", "Strip-based nesting...", plate => new StripNestEngine(plate));
```
### Plugin Discovery
Follows the existing `IPostProcessor` pattern from `Posts/`:
- Scan `Engines/` directory next to the executable for DLLs
- Reflect over types, find concrete subclasses of `NestEngineBase`
- Require a constructor taking `Plate`
- Register each with its `Name` and `Description` properties
- Called at application startup alongside post-processor loading (WinForms app only — Console and MCP use built-in engines only)
**Error handling:**
- DLLs that fail to load (bad assembly, missing dependencies) are logged and skipped
- Types without a `Plate` constructor are skipped
- Duplicate engine names: first registration wins, duplicates are logged and skipped
- Exceptions from plugin constructors during `Create()` are caught and surfaced to the caller
## Callsite Migration
All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`:
| Location | Count | Notes |
|----------|-------|-------|
| `MainForm.cs` | 3 | Auto-nest fill, auto-nest pack, single-drawing fill plate |
| `ActionFillArea.cs` | 2 | |
| `PlateView.cs` | 1 | |
| `NestingTools.cs` (MCP) | 6 | |
| `Program.cs` (Console) | 3 | |
| `BruteForceRunner.cs` | 1 | **Keep as `new DefaultNestEngine(plate)`** — training data must come from the known algorithm |
## UI Integration
- Global engine selector: combobox or menu item bound to `NestEngineRegistry.AvailableEngines`
- Changing selection sets `NestEngineRegistry.ActiveEngineName`
- No per-plate engine state — global setting applies to all subsequent operations
- Plugin directory: `Engines/` next to executable, loaded at startup
## File Summary
| Action | File | Project |
|--------|------|---------|
| Create | `NestEngineBase.cs` | OpenNest.Engine |
| Rename/Modify | `NestEngine.cs``DefaultNestEngine.cs` | OpenNest.Engine |
| Create | `NestEngineRegistry.cs` | OpenNest.Engine |
| Create | `NestEngineInfo.cs` | OpenNest.Engine |
| Modify | `StripNester.cs``StripNestEngine.cs` | OpenNest.Engine |
| Modify | `MainForm.cs` | OpenNest |
| Modify | `ActionFillArea.cs` | OpenNest |
| Modify | `PlateView.cs` | OpenNest |
| Modify | `NestingTools.cs` | OpenNest.Mcp |
| Modify | `Program.cs` | OpenNest.Console |

View File

@@ -1,96 +0,0 @@
# FillExact — Exact-Quantity Fill with Binary Search
## Problem
The current `NestEngine.Fill` fills an entire work area and truncates to `item.Quantity` with `.Take(n)`. This wastes plate space — parts are spread across the full area, leaving no usable remainder strip for subsequent drawings in AutoNest.
## Solution
Add a `FillExact` method that binary-searches for the smallest sub-area of the work area that fits exactly the requested quantity. This packs parts tightly against one edge, maximizing the remainder strip available for the next drawing.
## Coordinate Conventions
`Box.Width` is the X-axis extent. `Box.Length` is the Y-axis extent. The box is anchored at `(Box.X, Box.Y)` (bottom-left corner).
- **Shrink width** means reducing `Box.Width` (X-axis), producing a narrower box anchored at the left edge. The remainder strip extends to the right.
- **Shrink length** means reducing `Box.Length` (Y-axis), producing a shorter box anchored at the bottom edge. The remainder strip extends upward.
## Algorithm
1. **Early exits:**
- Quantity is 0 (unlimited): delegate to `Fill` directly.
- Quantity is 1: delegate to `Fill` directly (a single part placement doesn't benefit from area search).
2. **Full fill** — Call `Fill(item, workArea, progress, token)` to establish the upper bound (max parts that fit). This call gets progress reporting so the user sees the phases running.
3. **Already exact or under** — If `fullCount <= quantity`, return the full fill result. The plate can't fit more than requested anyway.
4. **Estimate starting point** — Calculate an initial dimension estimate assuming 50% utilization: `estimatedDim = (partArea * quantity) / (0.5 * fixedDim)`, clamped to at least the part's bounding box dimension in that axis.
5. **Binary search** (max 8 iterations, or until `high - low < partSpacing`) — Keep one dimension of the work area fixed and binary-search on the other:
- `low = estimatedDim`, `high = workArea dimension`
- Each iteration: create a test box, call `Fill(item, testBox, null, token)` (no progress — search iterations are silent), check count.
- `count >= quantity` → record result, shrink: `high = mid`
- `count < quantity` → expand: `low = mid`
- Check cancellation token between iterations; if cancelled, return best found so far.
6. **Try both orientations** — Run the binary search twice: once shrinking length (fixed width) and once shrinking width (fixed length).
7. **Pick winner** — Compare by test box area (`testBox.Width * testBox.Length`). Return whichever orientation's result has a smaller test box area, leaving more remainder for subsequent drawings. Tie-break: prefer shrink-length (leaves horizontal remainder strip, generally more useful on wide plates).
## Method Signature
```csharp
// NestEngine.cs
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
Returns exactly `item.Quantity` parts packed into the smallest sub-area of `workArea`, or fewer if they don't all fit.
## Internal Helper
```csharp
private (List<Part> parts, double usedDim) BinarySearchFill(
NestItem item, Box workArea, bool shrinkWidth,
CancellationToken token)
```
Performs the binary search for one orientation. Returns the parts and the dimension value at which the exact quantity was achieved. Progress is not passed to inner Fill calls — the search iterations run silently.
## Engine State
Each inner `Fill` call clears `PhaseResults`, `AngleResults`, and overwrites `WinnerPhase`. After the winning Fill call is identified, `FillExact` runs the winner one final time with `progress` so:
- `PhaseResults` / `AngleResults` / `WinnerPhase` reflect the winning fill.
- The progress form shows the final result.
## Integration
### AutoNest (MainForm.RunAutoNest_Click)
Replace `engine.Fill(item, workArea, progress, token)` with `engine.FillExact(item, workArea, progress, token)` for multi-quantity items. The tighter packing means `ComputeRemainderStrip` returns a larger box for subsequent drawings.
### Single-drawing Fill
`FillExact` works for single-drawing fills too. When `item.Quantity` is set, the caller gets a tight layout instead of parts scattered across the full plate.
### Fallback
When `item.Quantity` is 0 (unlimited), `FillExact` falls through to the standard `Fill` behavior — fill the entire work area.
## Performance Notes
The binary search converges in at most 8 iterations per orientation. Each iteration calls `Fill` internally, which runs the pairs/linear/best-fit phases. For a typical auto-nest scenario:
- Full fill: 1 call (with progress)
- Shrink-length search: ~6-8 calls (silent)
- Shrink-width search: ~6-8 calls (silent)
- Final re-fill of winner: 1 call (with progress)
- Total: ~15-19 Fill calls per drawing
The inner `Fill` calls for reduced work areas are faster than full-plate fills since the search space is smaller. The `BestFitCache` (used by the pairs phase) is keyed on the full plate size, so it stays warm across iterations — only the linear/rect phases re-run.
Early termination (`high - low < partSpacing`) typically cuts 1-3 iterations, bringing the total closer to 12-15 calls.
## Edge Cases
- **Quantity 0 (unlimited):** Skip binary search, delegate to `Fill` directly.
- **Quantity 1:** Skip binary search, delegate to `Fill` directly.
- **Full fill already exact:** Return immediately without searching.
- **Part doesn't fit at all:** Return empty list.
- **Binary search can't hit exact count** (e.g., jumps from N-1 to N+2): Take the smallest test box where `count >= quantity` and truncate with `.Take(quantity)`.
- **Cancellation:** Check token between iterations. Return best result found so far.

View File

@@ -1,135 +0,0 @@
# NestProgressForm Redesign
## Problem
The current `NestProgressForm` is a flat list of label/value pairs with no visual hierarchy, no progress indicator, and default WinForms styling. It's functional but looks basic and gives no sense of where the engine is in its process.
## Solution
Redesign the form with three changes:
1. A custom-drawn **phase stepper** control showing which nesting phases have been visited
2. **Grouped sections** separating results from status information
3. **Modern styling** — Segoe UI fonts, subtle background contrast, better spacing
## Phase Stepper Control
**New file: `OpenNest/Controls/PhaseStepperControl.cs`**
A custom `UserControl` that draws 4 circles with labels beneath, connected by lines:
```
●━━━━━━━●━━━━━━━○━━━━━━━○
Linear BestFit Pairs Remainder
```
### Non-sequential design
The engine does **not** execute phases in a fixed order. `FindBestFill` runs Pairs → Linear → BestFit → Remainder, while the group fill path runs Linear → BestFit → Pairs → Remainder. Some phases may not execute at all (e.g., multi-part fills only run Linear).
The stepper therefore tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress, regardless of position. The connecting lines between circles are purely decorative (always light gray) — they do not indicate sequential flow.
### Visual States
- **Completed/visited:** Filled circle with accent color, bold label — the phase has reported at least one progress update
- **Active:** Filled circle with accent color and slightly larger radius, bold label — the phase currently executing
- **Pending:** Hollow circle with border only, dimmed label text — the phase has not yet reported progress
- **Skipped:** Same as Pending — phases that never execute simply remain hollow. No special "skipped" visual needed.
- **All complete:** All 4 circles filled (used when `ShowCompleted()` is called)
- **Initial state (before first `UpdateProgress`):** All 4 circles in Pending (hollow) state
### Implementation
- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray.
- Colors and fonts defined as `static readonly` fields at the top of the class. Fonts are cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates.
- Tracks state via a `HashSet<NestPhase> VisitedPhases` and a `NestPhase? ActivePhase` property. When `ActivePhase` is set, it is added to `VisitedPhases` and `Invalidate()` is called. A `bool IsComplete` property marks all phases as done.
- `DoubleBuffered = true` to prevent flicker on repaint.
- Fixed height (~60px), docks to fill width.
- Namespace: `OpenNest.Controls` (follows existing convention, e.g., `QuadrantSelect`).
## Form Layout
Three vertical zones using `DockStyle.Top` stacking:
```
┌─────────────────────────────────────┐
│ ●━━━━━━━●━━━━━━━○━━━━━━━○ │ Phase stepper
│ Linear BestFit Pairs Remainder │
├─────────────────────────────────────┤
│ Results │ Results group
│ Parts: 156 │
│ Density: 68.3% │
│ Nested: 24.1 x 36.0 (867.6 sq in)│
│ Unused: 43.2 sq in │
├─────────────────────────────────────┤
│ Status │ Status group
│ Plate: 2 │
│ Elapsed: 1:24 │
│ Detail: Trying 45° rotation... │
├─────────────────────────────────────┤
│ [ Stop ] │ Button bar
└─────────────────────────────────────┘
```
### Group Panels
Each group is a `Panel` containing:
- A header label (Segoe UI 9pt bold) at the top
- A `TableLayoutPanel` with label/value rows beneath
Group panels use `Color.White` (or very light gray) `BackColor` against the form's `SystemColors.Control` background to create visual separation without borders. Small padding/margins between groups.
### Typography
- All fonts: Segoe UI (replaces MS Sans Serif)
- Group headers: 9pt bold
- Row labels: 8.25pt bold
- Row values: 8.25pt regular
- Value labels use `ForeColor = SystemColors.ControlText`
### Sizing
- Width: ~450px (slightly wider than current 425px for breathing room)
- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~110px) + status group (~90px) + button bar (~45px) + padding. The form uses `FixedToolWindow` which does not auto-resize, so the height is set explicitly in the designer.
- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false`
### Plate Row Visibility
The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior).
### Phase description text
The current form's `FormatPhase()` method produces friendly text like "Trying rotations..." which was displayed in the Phase row. Since the phase stepper replaces the Phase row visually, this descriptive text moves to the **Detail** row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when it's set (the engine's per-iteration descriptions like "Linear: 3/12 angles" take precedence).
## Public API
No signature changes. The form remains a drop-in replacement.
### Constructor
`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged.
### UpdateProgress(NestProgress progress)
Same as today, plus:
- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper
- Writes `FormatPhase(progress.Phase)` to the Detail row as a fallback when `progress.Description` is empty
### ShowCompleted()
Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles.
Note: `MainForm.FillArea_Click` currently calls `progressForm.Close()` without calling `ShowCompleted()` first. This is existing behavior and is fine — the form closes immediately so the "all complete" visual is not needed in that path.
## No External Changes
- `NestProgress` and `NestPhase` are unchanged.
- All callers (`MainForm`, `PlateView.FillWithProgress`) continue calling `UpdateProgress` and `ShowCompleted` with no code changes.
- The form file paths remain the same — this is a modification, not a new form.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control |
| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration |
| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout |

View File

@@ -1,329 +0,0 @@
# Plate Processor Design — Per-Part Lead-In Assignment & Cut Sequencing
## Overview
Add a plate-level orchestrator (`PlateProcessor`) to `OpenNest.Engine` that sequences parts across a plate, assigns lead-ins per-part based on approach direction, and plans safe rapid paths between parts. This replaces the current `ContourCuttingStrategy` usage model where the exit point is derived from the plate corner alone — instead, each part's lead-in pierce point is computed from the actual approach direction (the previous part's last cut point).
The motivation is laser head safety: on a CL-980 fiber laser, head-down rapids are significantly faster than raising the head, but traversing over already-cut areas risks collision with tipped-up slugs. The orchestrator must track cut areas and choose safe rapid paths.
## Architecture
Three pipeline stages, wired by a thin orchestrator:
```
IPartSequencer → ContourCuttingStrategy → IRapidPlanner
↓ ↓ ↓
ordered parts lead-ins applied safe rapid paths
└──────────── PlateProcessor ─────────────┘
```
All new code lives in `OpenNest.Engine/` except the `ContourCuttingStrategy` signature change and `Part.HasManualLeadIns` flag which are in `OpenNest.Core`.
## Model Changes
### Part (OpenNest.Core)
Add a flag to indicate the user has manually assigned lead-ins to this part:
```csharp
public bool HasManualLeadIns { get; set; }
```
When `true`, the orchestrator skips `ContourCuttingStrategy.Apply()` for this part and uses the program as-is.
### ContourCuttingStrategy (OpenNest.Core)
Change the `Apply` signature to accept an approach point instead of a plate:
```csharp
// Before
public Program Apply(Program partProgram, Plate plate)
// After
public CuttingResult Apply(Program partProgram, Vector approachPoint)
```
Remove `GetExitPoint(Plate)` — the caller provides the approach point in part-local coordinates.
### CuttingResult (OpenNest.Core, namespace OpenNest.CNC.CuttingStrategy)
New readonly struct returned by `ContourCuttingStrategy.Apply()`. Lives in `CNC/CuttingStrategy/CuttingResult.cs`:
```csharp
public readonly struct CuttingResult
{
public Program Program { get; init; }
public Vector LastCutPoint { get; init; }
}
```
- `Program`: the program with lead-ins/lead-outs applied.
- `LastCutPoint`: where the last contour cut ends, in part-local coordinates. The orchestrator transforms this to plate coordinates to compute the approach point for the next part.
## Stage 1: IPartSequencer
### Interface
```csharp
namespace OpenNest.Engine
{
public interface IPartSequencer
{
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}
```
### SequencedPart
```csharp
public readonly struct SequencedPart
{
public Part Part { get; init; }
}
```
The sequencer only determines cut order. Approach points are computed by the orchestrator as it loops, since each part's approach point depends on the previous part's `CuttingResult.LastCutPoint`.
### Implementations
One class per `SequenceMethod`. All live in `OpenNest.Engine/Sequencing/`.
| Class | SequenceMethod | Algorithm |
|-------|---------------|-----------|
| `RightSideSequencer` | RightSide | Sort parts by X descending (rightmost first) |
| `LeftSideSequencer` | LeftSide | Sort parts by X ascending (leftmost first) |
| `BottomSideSequencer` | BottomSide | Sort parts by Y ascending (bottom first) |
| `LeastCodeSequencer` | LeastCode | Nearest-neighbor from exit point, then 2-opt improvement |
| `AdvancedSequencer` | Advanced | Nearest-neighbor with row/column grouping from `SequenceParameters` |
| `EdgeStartSequencer` | EdgeStart | Sort by distance from nearest plate edge, closest first |
#### Directional sequencers (RightSide, LeftSide, BottomSide)
Sort parts by their bounding box center along the relevant axis. Ties broken by the perpendicular axis. These are simple positional sorts — no TSP involved.
#### LeastCodeSequencer
1. Start from the plate exit point.
2. Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position.
3. 2-opt improvement: iterate over the sequence, try swapping pairs. If total travel distance decreases, keep the swap. Repeat until no improvement found (or max iterations).
#### AdvancedSequencer
Uses `SequenceParameters` to group parts into rows/columns based on `MinDistanceBetweenRowsColumns`, then sequences within each group. `AlternateRowsColumns` and `AlternateCutoutsWithinRowColumn` control serpentine vs. unidirectional ordering within rows.
#### EdgeStartSequencer
Sort parts by distance from the nearest plate edge (minimum of distances to all four edges). Parts closest to an edge cut first. Ties broken by nearest-neighbor.
### Parameter Flow
Sequencers that need configuration accept it through their constructor:
- `LeastCodeSequencer(int maxIterations = 100)` — max 2-opt iterations
- `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config
- Directional sequencers and `EdgeStartSequencer` need no configuration
### Factory
A static `PartSequencerFactory.Create(SequenceParameters parameters)` method in `OpenNest.Engine/Sequencing/` maps `parameters.Method` to the correct `IPartSequencer` implementation, passing constructor args as needed. Throws `NotSupportedException` for `RightSideAlt`.
## Stage 2: ContourCuttingStrategy
Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change:
1. `Apply(Program partProgram, Plate plate)``Apply(Program partProgram, Vector approachPoint)`
2. Return `CuttingResult` instead of `Program`
3. Remove `GetExitPoint(Plate)` — replaced by the `approachPoint` parameter
4. Set `CuttingResult.LastCutPoint` to the end point of the last contour (perimeter), which is the same as the perimeter's reindexed start point for closed contours
The internal logic (cutout sequencing, contour type detection, normal computation, lead-in/out selection) remains unchanged — only the source of the approach direction changes.
## Stage 3: IRapidPlanner
### Interface
```csharp
namespace OpenNest.Engine
{
public interface IRapidPlanner
{
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
}
}
```
All coordinates are in plate space.
### RapidPath
```csharp
public readonly struct RapidPath
{
public bool HeadUp { get; init; }
public List<Vector> Waypoints { get; init; }
}
```
- `HeadUp = true`: the post-processor should raise Z before traversing. `Waypoints` is empty (direct move).
- `HeadUp = false`: head-down rapid. `Waypoints` contains the path (may be empty for a direct move, or contain intermediate points for obstacle avoidance in future implementations).
### Implementations
Both live in `OpenNest.Engine/RapidPlanning/`.
#### SafeHeightRapidPlanner
Always returns `HeadUp = true` with empty waypoints. Guaranteed safe, simplest possible implementation.
#### DirectRapidPlanner
Checks if the straight line from `from` to `to` intersects any shape in `cutAreas`:
- If clear: returns `HeadUp = false`, empty waypoints (direct head-down rapid).
- If blocked: returns `HeadUp = true`, empty waypoints (fall back to safe height).
Uses existing `Intersect` class from `OpenNest.Geometry` for line-shape intersection checks.
Future enhancement: obstacle-avoidance pathfinding that routes around cut areas with head down. This is a 2D pathfinding problem (visibility graph or similar) and is out of scope for the initial implementation.
## PlateProcessor (Orchestrator)
Lives in `OpenNest.Engine/PlateProcessor.cs`.
```csharp
public class PlateProcessor
{
public IPartSequencer Sequencer { get; set; }
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
{
// 1. Sequence parts
var ordered = Sequencer.Sequence(plate.Parts, plate);
var results = new List<ProcessedPart>();
var cutAreas = new List<Shape>();
var currentPoint = GetExitPoint(plate); // plate-space starting point
foreach (var sequenced in ordered)
{
var part = sequenced.Part;
// 2. Transform approach point from plate space to part-local space
var localApproach = ToPartLocal(currentPoint, part);
// 3. Apply lead-ins (or skip if manual)
CuttingResult cutResult;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
cutResult = CuttingStrategy.Apply(part.Program, localApproach);
}
else
{
cutResult = new CuttingResult
{
Program = part.Program,
LastCutPoint = GetProgramEndPoint(part.Program)
};
}
// 4. Get pierce point in plate space for rapid planning
var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part);
// 5. Plan rapid from current position to this part's pierce point
var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
{
Part = part,
ProcessedProgram = cutResult.Program,
RapidPath = rapid
});
// 6. Track cut area (part perimeter in plate space) for future rapid planning
cutAreas.Add(GetPartPerimeter(part));
// 7. Update current position to this part's last cut point (plate space)
currentPoint = ToPlateSpace(cutResult.LastCutPoint, part);
}
return new PlateResult { Parts = results };
}
}
```
### Coordinate Transforms
Part programs already have rotation baked in (the `Part` constructor calls `Program.Rotate()`). `Part.Location` is a pure translation offset. Therefore, coordinate transforms are simple vector addition/subtraction — no rotation involved:
- `ToPartLocal(Vector platePoint, Part part)`: `platePoint - part.Location`
- `ToPlateSpace(Vector localPoint, Part part)`: `localPoint + part.Location`
This matches how `Part.Intersects` converts to plate space (offset by `Location` only).
### Helper Methods
- `GetExitPoint(Plate)`: moved from `ContourCuttingStrategy` — returns the plate corner opposite the quadrant origin.
- `GetProgramStartPoint(Program)`: first `RapidMove` position in the program (the pierce point).
- `GetProgramEndPoint(Program)`: last move's end position in the program.
- `GetPartPerimeter(Part)`: converts the part's program to geometry, builds `ShapeProfile`, returns the perimeter `Shape` offset by `part.Location` (translation only — rotation is already baked in).
### PlateResult
```csharp
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged)
public RapidPath RapidPath { get; init; }
}
```
The orchestrator is non-destructive — it does not mutate `Part.Program` (which has a `private set`). Instead, the processed program with lead-ins is stored in `ProcessedPart.ProcessedProgram`. The post-processor consumes `PlateResult` to generate machine-specific G-code, using `ProcessedProgram` for cut paths and `RapidPath.HeadUp` for Z-axis commands.
Note: the caller is responsible for configuring `CuttingStrategy.Parameters` (the `CuttingParameters` instance with lead-in/lead-out settings) before calling `Process()`. Parameters typically vary by material/thickness.
## File Structure
```
OpenNest.Core/
├── Part.cs # add HasManualLeadIns property
└── CNC/CuttingStrategy/
├── ContourCuttingStrategy.cs # signature change + CuttingResult return
└── CuttingResult.cs # new struct
OpenNest.Engine/
├── PlateProcessor.cs # orchestrator
├── Sequencing/
│ ├── IPartSequencer.cs
│ ├── SequencedPart.cs # removed ApproachPoint (orchestrator tracks it)
│ ├── RightSideSequencer.cs
│ ├── LeftSideSequencer.cs
│ ├── BottomSideSequencer.cs
│ ├── LeastCodeSequencer.cs
│ ├── AdvancedSequencer.cs
│ └── EdgeStartSequencer.cs
└── RapidPlanning/
├── IRapidPlanner.cs
├── RapidPath.cs
├── SafeHeightRapidPlanner.cs
└── DirectRapidPlanner.cs
```
## Known Limitations
- `DirectRapidPlanner` checks edge intersection only — a rapid that passes entirely through the interior of a concave cut part without crossing a perimeter edge would not be detected. Unlikely in practice (parts have material around them) but worth noting.
- `LeastCodeSequencer` uses bounding box centers for nearest-neighbor distance. For highly irregular parts, closest-point-on-perimeter could yield better results, but the simpler approach is sufficient for the initial implementation.
## Out of Scope
- Obstacle-avoidance pathfinding for head-down rapids (future enhancement to `DirectRapidPlanner`)
- UI integration (selecting sequencing method, configuring rapid planner)
- Post-processor changes to consume `PlateResult` — interim state: `PlateResult` is returned from `Process()` and the caller bridges it to the existing `IPostProcessor` interface
- `RightSideAlt` sequencer (unclear how it differs from `RightSide` — defer until behavior is defined; `PlateProcessor` should throw `NotSupportedException` if selected)
- Serialization of `PlateResult`

View File

@@ -1,133 +0,0 @@
# Strip Nester Design Spec
## Problem
The current multi-drawing nesting strategies (AutoNester with NFP/simulated annealing, sequential FillExact) produce scattered, unstructured layouts. For jobs with multiple part types, a structured strip-based approach can pack more densely by dedicating a tight strip to the highest-area drawing and filling the remnant with the rest.
## Strategy Overview
1. Pick the drawing that consumes the most plate area (bounding box area x quantity) as the "strip item." All others are "remainder items."
2. Try two orientations — bottom strip and left strip.
3. For each orientation, find the tightest strip that fits the strip item's full quantity.
4. Fill the remnant area with remainder items using existing fill strategies.
5. Compare both orientations. The denser overall result wins.
## Algorithm Detail
### Step 1: Select Strip Item
Sort `NestItem`s by `Drawing.Program.BoundingBox().Area() * quantity` descending — bounding box area, not `Drawing.Area`, because the bounding box represents the actual plate space consumed by each part. The first item becomes the strip item. If quantity is 0 (unlimited), estimate max capacity from `workArea.Area() / bboxArea` as a stand-in for sorting.
### Step 2: Estimate Initial Strip Height
For the strip item, calculate at both 0 deg and 90 deg rotation. These two angles are sufficient since this is only an estimate for the shrink loop starting point — the actual fill in Step 3 uses `NestEngine.Fill` which tries many rotation angles internally.
- Parts per row: `floor(stripLength / bboxWidth)`
- Rows needed: `ceil(quantity / partsPerRow)`
- Strip height: `rows * bboxHeight`
Pick the rotation with the shorter strip height. The strip length is the work area dimension along the strip's long axis (work area width for bottom strip, work area length for left strip).
### Step 3: Initial Fill
Create a `Box` for the strip area:
- **Bottom strip**: `(workArea.X, workArea.Y, workArea.Width, estimatedStripHeight)`
- **Left strip**: `(workArea.X, workArea.Y, estimatedStripWidth, workArea.Length)`
Fill using `NestEngine.Fill(stripItem, stripBox)`. Measure the actual strip dimension from placed parts: for a bottom strip, `actualStripHeight = placedParts.GetBoundingBox().Top - workArea.Y`; for a left strip, `actualStripWidth = placedParts.GetBoundingBox().Right - workArea.X`. This may be shorter than the estimate since FillLinear packs more efficiently than pure bounding-box grid.
### Step 4: Shrink Loop
Starting from the actual placed dimension (not the estimate), capped at 20 iterations:
1. Reduce strip height by `plate.PartSpacing` (typically 0.25").
2. Create new strip box with reduced dimension.
3. Fill with `NestEngine.Fill(stripItem, newStripBox)`.
4. If part count equals the initial fill count, record this as the new best and repeat.
5. If part count drops, stop. Use the previous iteration's result (tightest strip that still fits).
For unlimited quantity (qty = 0), the initial fill count becomes the target.
### Step 5: Remnant Fill
Calculate the remnant box from the tightest strip's actual placed dimension, adding `plate.PartSpacing` between the strip and remnant to prevent spacing violations:
- **Bottom strip remnant**: `(workArea.X, workArea.Y + actualStripHeight + partSpacing, workArea.Width, workArea.Length - actualStripHeight - partSpacing)`
- **Left strip remnant**: `(workArea.X + actualStripWidth + partSpacing, workArea.Y, workArea.Width - actualStripWidth - partSpacing, workArea.Length)`
Fill remainder items in descending order by `bboxArea * quantity` (largest first, same as strip selection). If the strip item was only partially placed (fewer than target quantity), add the leftover quantity as a remainder item so it participates in the remnant fill.
For each remainder item, fill using `NestEngine.Fill(remainderItem, remnantBox)`.
### Step 6: Compare Orientations
Score each orientation using `FillScore.Compute` over all placed parts (strip + remnant) against `plate.WorkArea()`. The orientation with the better `FillScore` wins. Apply the winning parts to the plate.
## Classes
### `StripNester` (new, `OpenNest.Engine`)
```csharp
public class StripNester
{
public StripNester(Plate plate) { }
public List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress,
CancellationToken token);
}
```
**Constructor**: Takes the target plate (for work area, part spacing, quadrant).
**`Nest` method**: Runs the full strategy. Returns the combined list of placed parts. The caller adds them to `plate.Parts`. Same instance-based pattern as `NestEngine`.
### `StripNestResult` (new, internal, `OpenNest.Engine`)
```csharp
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; }
}
```
Holds intermediate results for comparing bottom vs left orientations.
### `StripDirection` (new enum, `OpenNest.Engine`)
```csharp
public enum StripDirection { Bottom, Left }
```
## Integration
### MCP (`NestingTools`)
`StripNester` becomes an additional strategy in the autonest flow. When multiple items are provided, both `StripNester` and the current approach run, and the better result wins.
### UI (`AutoNestForm`)
Can be offered as a strategy option alongside existing NFP-based auto-nesting.
### No changes to `NestEngine`
`StripNester` is a consumer of `NestEngine.Fill`, not a modification of it.
## Edge Cases
- **Single item**: Strategy reduces to strip optimization only (shrink loop with no remnant fill). Still valuable for finding the tightest area.
- **Strip item can't fill target quantity**: Use the partial result. Leftover quantity is added to remainder items for the remnant fill.
- **Remnant too small**: `NestEngine.Fill` returns empty naturally. No special handling needed.
- **Quantity = 0 (unlimited)**: Initial fill count becomes the shrink loop target.
- **Strip already one part tall**: Skip the shrink loop.
## Dependencies
- `NestEngine.Fill(NestItem, Box)` — existing API, no changes needed.
- `FillScore.Compute` — existing scoring, no changes needed.
- `Part.GetBoundingBox()` / list extensions — existing geometry utilities.

View File

@@ -1,197 +0,0 @@
# Engine Refactor: Extract Shared Algorithms from DefaultNestEngine and StripNestEngine
## Problem
`DefaultNestEngine` (~550 lines) mixes phase orchestration with strategy-specific logic (pair candidate selection, angle building, pattern helpers). `StripNestEngine` (~450 lines) duplicates patterns that DefaultNestEngine also uses: shrink-to-fit loops, iterative remnant filling, and progress accumulation. Both engines would benefit from extracting shared algorithms into focused, reusable classes.
## Approach
Extract five classes from the two engines. No new interfaces or strategy patterns — just focused helper classes that each engine composes.
## Extracted Classes
### 1. PairFiller
**Source:** DefaultNestEngine lines 362-489 (`FillWithPairs`, `SelectPairCandidates`, `BuildRemainderPatterns`, `MinPairCandidates`, `PairTimeLimit`).
**API:**
```csharp
public class PairFiller
{
public PairFiller(Size plateSize, double partSpacing) { }
public List<Part> Fill(NestItem item, Box workArea,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null);
}
```
**Details:**
- Constructor takes plate size and spacing — decoupled from `Plate` object.
- `SelectPairCandidates` and `BuildRemainderPatterns` become private methods.
- Uses `BestFitCache.GetOrCompute()` internally (same as today).
- Calls `BuildRotatedPattern` and `FillPattern` — these become `internal static` methods on DefaultNestEngine so PairFiller can call them without ceremony.
- Returns `List<Part>` (empty list if no result), same contract as today.
- Progress reporting: PairFiller accepts `IProgress<NestProgress>` and `int plateNumber` in its `Fill` method to maintain per-candidate progress updates. The caller passes these through from the engine.
**Caller:** `DefaultNestEngine.FindBestFill` replaces `this.FillWithPairs(...)` with `new PairFiller(Plate.Size, Plate.PartSpacing).Fill(...)`.
### 2. AngleCandidateBuilder
**Source:** DefaultNestEngine lines 279-347 (`BuildCandidateAngles`, `knownGoodAngles` HashSet, `ForceFullAngleSweep` property).
**API:**
```csharp
public class AngleCandidateBuilder
{
public bool ForceFullSweep { get; set; }
public List<double> Build(NestItem item, double bestRotation, Box workArea);
public void RecordProductive(List<AngleResult> angleResults);
}
```
**Details:**
- Owns `knownGoodAngles` state — lives as long as the engine instance so pruning accumulates across fills.
- `Build()` encapsulates the full pipeline: base angles, sweep check, ML prediction, known-good pruning.
- `RecordProductive()` replaces the inline loop that feeds `knownGoodAngles` after the linear phase.
- `ForceFullAngleSweep` moves from DefaultNestEngine to `AngleCandidateBuilder.ForceFullSweep`. DefaultNestEngine keeps a forwarding property `ForceFullAngleSweep` that delegates to its `AngleCandidateBuilder` instance, so `BruteForceRunner` (which sets `engine.ForceFullAngleSweep = true`) continues to work without changes.
**Caller:** DefaultNestEngine creates one `AngleCandidateBuilder` instance as a field and calls `Build()`/`RecordProductive()` from `FindBestFill`.
### 3. ShrinkFiller
**Source:** StripNestEngine `TryOrientation` shrink loop (lines 188-215) and `ShrinkFill` (lines 358-418).
**API:**
```csharp
public static class ShrinkFiller
{
public static ShrinkResult Shrink(
Func<NestItem, Box, List<Part>> fillFunc,
NestItem item, Box box,
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int maxIterations = 20);
}
public enum ShrinkAxis { Width, Height }
public class ShrinkResult
{
public List<Part> Parts { get; set; }
public double Dimension { get; set; }
}
```
**Details:**
- `fillFunc` delegate decouples ShrinkFiller from any specific engine — the caller provides how to fill.
- `ShrinkAxis` determines which dimension to reduce. `TryOrientation` maps strip direction to axis: `StripDirection.Bottom``ShrinkAxis.Height`, `StripDirection.Left``ShrinkAxis.Width`. `ShrinkFill` calls `Shrink` twice (width then height).
- Loop logic: fill initial box, measure placed bounding box, reduce dimension by `spacing`, retry until count drops below initial count. Dimension is measured as `placedBox.Right - box.X` for Width or `placedBox.Top - box.Y` for Height.
- Returns both the best parts and the final tight dimension (needed by `TryOrientation` to compute the remnant box).
- **Two-axis independence:** When `ShrinkFill` calls `Shrink` twice, each axis shrinks against the **original** box dimensions, not the result of the prior axis. This preserves the current behavior where width and height are shrunk independently.
**Callers:**
- `StripNestEngine.TryOrientation` replaces its inline shrink loop.
- `StripNestEngine.ShrinkFill` replaces its two-axis inline shrink loops.
### 4. RemnantFiller
**Source:** StripNestEngine remnant loop (lines 253-343) and the simpler version in NestEngineBase.Nest (lines 74-97).
**API:**
```csharp
public class RemnantFiller
{
public RemnantFiller(Box workArea, double spacing) { }
public void AddObstacles(IEnumerable<Part> parts);
public List<Part> FillItems(
List<NestItem> items,
Func<NestItem, Box, List<Part>> fillFunc,
CancellationToken token = default,
IProgress<NestProgress> progress = null);
}
```
**Details:**
- Owns a `RemnantFinder` instance internally.
- `AddObstacles` registers already-placed parts (bounding boxes offset by spacing).
- `FillItems` runs the iterative loop: find remnants, try each item in each remnant, fill, update obstacles, repeat until no progress.
- Local quantity tracking (dictionary keyed by drawing name) stays internal — does not mutate the input `NestItem` quantities. Returns the placed parts; the caller deducts quantities.
- Uses minimum-remnant-size filtering (smallest remaining part dimension), same as StripNestEngine today.
- `fillFunc` delegate allows callers to provide any fill strategy (DefaultNestEngine.Fill, ShrinkFill, etc.).
**Callers:**
- `StripNestEngine.TryOrientation` replaces its inline remnant loop with `RemnantFiller.FillItems(...)`.
- `NestEngineBase.Nest` replaces its hand-rolled largest-remnant loop. **Note:** This is a deliberate behavioral improvement — the base class currently uses only the single largest remnant, while `RemnantFiller` tries all remnants iteratively with minimum-size filtering. This may produce better fill results for engines that rely on the base `Nest` method.
**Unchanged:** `NestEngineBase.Nest` phase 2 (bin-packing single-quantity items via `PackArea`, lines 100-119) is not affected by this change.
### 5. AccumulatingProgress
**Source:** StripNestEngine nested class (lines 425-449).
**API:**
```csharp
internal class AccumulatingProgress : IProgress<NestProgress>
{
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts) { }
public void Report(NestProgress value);
}
```
**Details:**
- Moved from private nested class to standalone `internal` class in OpenNest.Engine.
- No behavioral change — wraps an `IProgress<NestProgress>` and prepends previously placed parts to each report.
## What Stays on Each Engine
### DefaultNestEngine (~200 lines after extraction)
- `Fill(NestItem, Box, ...)` — public entry point, unchanged.
- `Fill(List<Part>, Box, ...)` — group-parts overload, unchanged.
- `PackArea` — bin packing delegation, unchanged.
- `FindBestFill` — orchestration, now ~30 lines: calls `AngleCandidateBuilder.Build()`, `PairFiller.Fill()`, linear angle loop, `FillRectangleBestFit`, picks best.
- `FillRectangleBestFit` — 6-line private method, too small to extract.
- `BuildRotatedPattern` / `FillPattern` — become `internal static`, used by both the linear loop and PairFiller.
- `QuickFillCount` — stays (used by binary search, not shared).
### StripNestEngine (~200 lines after extraction)
- `Nest` — orchestration, unchanged.
- `TryOrientation` — becomes thinner: calls `DefaultNestEngine.Fill` for initial fill, `ShrinkFiller.Shrink()` for tightening, `RemnantFiller.FillItems()` for remnants.
- `ShrinkFill` — replaced by two `ShrinkFiller.Shrink()` calls.
- `SelectStripItemIndex` / `EstimateStripDimension` — stay private, strip-specific.
- `AccumulatingProgress` — removed, uses shared class.
### NestEngineBase
- `Nest` — switches from hand-rolled remnant loop to `RemnantFiller.FillItems()`.
- All other methods unchanged.
## File Layout
All new classes go in `OpenNest.Engine/`:
```
OpenNest.Engine/
PairFiller.cs
AngleCandidateBuilder.cs
ShrinkFiller.cs
RemnantFiller.cs
AccumulatingProgress.cs
```
## Non-Goals
- No new interfaces or strategy patterns.
- No changes to FillLinear, FillBestFit, PackBottomLeft, or any other existing algorithm.
- No changes to NestEngineRegistry or the plugin system.
- No changes to public API surface — all existing callers continue to work unchanged. One deliberate behavioral improvement: `NestEngineBase.Nest` gains multi-remnant filling (see RemnantFiller section).
- PatternHelper extraction deferred — `BuildRotatedPattern`/`FillPattern` become `internal static` on DefaultNestEngine for now. Extract if a third consumer appears.
- StripNestEngine continues to create fresh `DefaultNestEngine` instances per fill call. Sharing an `AngleCandidateBuilder` across sub-fills to enable angle pruning is a potential future optimization, not part of this refactor.

View File

@@ -1,260 +0,0 @@
# Lead-In Assignment UI Design (Revised)
## Overview
Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides separate parameter sets for external (perimeter) and internal (cutout/hole) contours. Lead-in/lead-out moves are tagged with the existing `LayerType.Leadin`/`LayerType.Leadout` enum on each code, making them distinguishable from normal cut code and easy to strip and re-apply.
## Design Principles
- **LayerType tagging.** Every lead-in move gets `Layer = LayerType.Leadin`, every lead-out move gets `Layer = LayerType.Leadout`. Normal contour cuts keep `Layer = LayerType.Cut` (the default). This uses the existing `LayerType` enum and `LinearMove.Layer`/`ArcMove.Layer` properties — no new enums or flags.
- **Always rebuild from base.** `ContourCuttingStrategy.Apply` converts the input program to geometry via `Program.ToGeometry()` and `ShapeProfile`. These do NOT filter by layer — all entities (including lead-in/out codes if present) would be processed. Therefore, the strategy must always receive a clean program (cut codes only). The flow always clones from `Part.BaseDrawing.Program` and re-rotates before applying.
- **Non-destructive.** `Part.BaseDrawing.Program` is never modified. The strategy builds a fresh `Program` with lead-ins baked in. `Part.HasManualLeadIns` (existing property) is set to `true` when lead-ins are assigned, so the automated `PlateProcessor` pipeline skips these parts.
## Lead-In Dialog (`LeadInForm`)
A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two parameter groups, one checkbox, and OK/Cancel buttons.
### External Group (Perimeter)
- Lead-in angle (degrees) — default 90
- Lead-in length (inches) — default 0.125
- Overtravel (inches) — default 0.03
### Internal Group (Cutouts & Holes)
- Lead-in angle (degrees) — default 90
- Lead-in length (inches) — default 0.125
- Overtravel (inches) — default 0.03
### Update Existing Checkbox
- **"Update existing lead-ins"** — checked by default
- When checked: strip all existing lead-in/lead-out codes from every part before re-applying
- When unchecked: only process parts that have no `LayerType.Leadin` codes in their program
### Dialog Result
```csharp
public class LeadInSettings
{
// External (perimeter) parameters
public double ExternalLeadInAngle { get; set; } = 90;
public double ExternalLeadInLength { get; set; } = 0.125;
public double ExternalOvertravel { get; set; } = 0.03;
// Internal (cutout/hole) parameters
public double InternalLeadInAngle { get; set; } = 90;
public double InternalLeadInLength { get; set; } = 0.125;
public double InternalOvertravel { get; set; } = 0.03;
// Behavior
public bool UpdateExisting { get; set; } = true;
}
```
Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (not radians), converting internally via `Angle.ToRadians()`. The `LeadInSettings` values are degrees and can be passed directly.
## LeadInSettings to CuttingParameters Mapping
The caller builds one `CuttingParameters` instance with separate external and internal settings. ArcCircle shares the internal settings:
```
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.ExternalLeadInAngle, Length = settings.ExternalLeadInLength }
ExternalLeadOut = new LineLeadOut { Length = settings.ExternalOvertravel }
InternalLeadIn = new LineLeadIn { ApproachAngle = settings.InternalLeadInAngle, Length = settings.InternalLeadInLength }
InternalLeadOut = new LineLeadOut { Length = settings.InternalOvertravel }
ArcCircleLeadIn = (same as Internal)
ArcCircleLeadOut = (same as Internal)
```
## Detecting Existing Lead-Ins
Check whether a part's program contains lead-in codes by inspecting `LayerType`:
```csharp
bool HasLeadIns(Program program)
{
foreach (var code in program.Codes)
{
if (code is LinearMove lm && lm.Layer == LayerType.Leadin)
return true;
if (code is ArcMove am && am.Layer == LayerType.Leadin)
return true;
}
return false;
}
```
## Preparing a Clean Program
**Important:** `Program.ToGeometry()` and `ShapeProfile` process ALL entities regardless of layer. They do NOT filter out lead-in/lead-out codes. If the strategy receives a program that already has lead-in codes baked in, those codes would be converted to geometry entities and corrupt the perimeter/cutout detection.
Therefore, the flow always starts from a clean base:
```csharp
var cleanProgram = part.BaseDrawing.Program.Clone() as Program;
cleanProgram.Rotate(part.Rotation);
```
This produces a program with only the original cut geometry at the part's current rotation angle, safe to feed into `ContourCuttingStrategy.Apply`.
## Menu Integration
Add "Assign Lead-Ins" to the Plate menu in `MainForm`, after "Sequence Parts" and before "Calculate Cut Time".
Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`.
## AssignLeadIns Flow (EditNestForm)
```
1. Open LeadInForm dialog
2. If user clicks OK:
a. Get LeadInSettings from dialog (includes UpdateExisting flag)
b. Build one ContourCuttingStrategy with CuttingParameters from settings
c. Get exit point: PlateHelper.GetExitPoint(plate) [now public]
d. Set currentPoint = exitPoint
e. For each part on the current plate (in sequence order):
- If !updateExisting and part already has lead-in codes → skip
- Build clean program: clone BaseDrawing.Program, rotate to part.Rotation
- Compute localApproach = currentPoint - part.Location
- Call strategy.Apply(cleanProgram, localApproach) → CuttingResult
- Call part.ApplyLeadIns(cutResult.Program)
(this sets Program, HasManualLeadIns = true, and recalculates bounds)
- Update currentPoint = cutResult.LastCutPoint + part.Location
f. Invalidate PlateView to show updated geometry
```
Note: The clean program is always rebuilt from `BaseDrawing.Program` — never from the current `Part.Program` — because `Program.ToGeometry()` and `ShapeProfile` do not filter by layer and would be corrupted by existing lead-in codes.
Note: Setting `Part.Program` requires a public method since the setter is `private`. See Model Changes below.
## Model Changes
### Part (OpenNest.Core)
Add a method to apply lead-ins and mark the part:
```csharp
public void ApplyLeadIns(Program processedProgram)
{
Program = processedProgram;
HasManualLeadIns = true;
UpdateBounds();
}
```
This atomically sets the processed program, marks `HasManualLeadIns = true` (so `PlateProcessor` skips this part), and recalculates bounds. The private setter on `Program` stays private — `ApplyLeadIns` is the public API.
### PlateHelper (OpenNest.Engine)
Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`.
## ContourCuttingStrategy Changes
### LayerType Tagging
When emitting lead-in moves, stamp each code with `Layer = LayerType.Leadin`. When emitting lead-out moves, stamp with `Layer = LayerType.Leadout`. This applies to all move types (`LinearMove`, `ArcMove`) generated by `LeadIn.Generate()` and `LeadOut.Generate()`.
The `LeadIn.Generate()` and `LeadOut.Generate()` methods return `List<ICode>`. After calling them, the strategy sets the `Layer` property on each returned code:
```csharp
var leadInCodes = leadIn.Generate(piercePoint, normal, winding);
foreach (var code in leadInCodes)
{
if (code is LinearMove lm) lm.Layer = LayerType.Leadin;
else if (code is ArcMove am) am.Layer = LayerType.Leadin;
}
result.Codes.AddRange(leadInCodes);
```
Same pattern for lead-out codes with `LayerType.Leadout`.
### Corner vs Mid-Entity Auto-Detection
When generating the lead-out, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks:
```csharp
private static bool IsCornerPierce(Vector closestPt, Entity entity)
{
if (entity is Line line)
return closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon
|| closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon;
if (entity is Arc arc)
return closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon
|| closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon;
return false;
}
```
Note: `Entity` has no polymorphic `StartPoint`/`EndPoint``Line` has properties, `Arc` has methods, `Circle` has neither.
### Corner Lead-Out
Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal. Moves are tagged `LayerType.Leadout`.
### Mid-Entity Lead-Out (Contour-Follow Overtravel)
Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). The overtravel distance is read from the selected `LeadOut` for the current contour type — `SelectLeadOut(contourType)`. Since external and internal have separate `LineLeadOut` instances in `CuttingParameters`, the overtravel distance automatically varies by contour type.
```csharp
var leadOut = SelectLeadOut(contourType);
if (IsCornerPierce(closestPt, entity))
{
// Corner: delegate to LeadOut.Generate() as normal
var codes = leadOut.Generate(closestPt, normal, winding);
// tag as LayerType.Leadout
}
else if (leadOut is LineLeadOut lineLeadOut && lineLeadOut.Length > 0)
{
// Mid-entity: retrace the start of the contour for overtravel distance
var codes = GenerateOvertravelMoves(reindexed, lineLeadOut.Length);
// tag as LayerType.Leadout
}
```
The contour-follow retraces the beginning of the reindexed shape:
1. Walking the reindexed shape's entities from the start
2. Accumulating distance until overtravel is reached
3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed)
4. Tagging all emitted moves as `LayerType.Leadout`
This produces a clean overcut that ensures the contour fully closes.
### Lead-out behavior summary
| Contour Type | Pierce Location | Lead-Out Behavior |
|---|---|---|
| External | Corner | `LineLeadOut.Generate()` — extends past corner |
| External | Mid-entity | Contour-follow overtravel moves |
| Internal | Corner | `LineLeadOut.Generate()` — extends past corner |
| Internal | Mid-entity | Contour-follow overtravel moves |
| ArcCircle | N/A (always mid-entity) | Contour-follow overtravel moves |
## File Structure
```
OpenNest.Core/
├── Part.cs # add ApplyLeadIns method
└── CNC/CuttingStrategy/
└── ContourCuttingStrategy.cs # LayerType tagging, Overtravel, corner detection
OpenNest.Engine/
└── Sequencing/
└── PlateHelper.cs # change internal → public
OpenNest/
├── Forms/
│ ├── LeadInForm.cs # new dialog
│ ├── LeadInForm.Designer.cs # new dialog designer
│ ├── MainForm.Designer.cs # add menu item
│ ├── MainForm.cs # add click handler
│ └── EditNestForm.cs # add AssignLeadIns method
└── LeadInSettings.cs # settings DTO
```
## Out of Scope
- Tabbed (V lead-in/out) parameters and `Part.IsTabbed` — deferred until tab assignment UI
- Slug destruct for internal cutouts
- Lead-in visualization colors in PlateView (separate enhancement)
- Database storage of lead-in presets by material/thickness
- MicrotabLeadOut integration
- Nest file serialization changes

View File

@@ -1,82 +0,0 @@
# Polylabel Part Label Positioning
**Date:** 2026-03-16
**Status:** Approved
## Problem
Part ID labels in `PlateView` are drawn at `PathPoints[0]` — the first point of the graphics path, which sits on the part contour edge. This causes labels to overlap adjacent parts and be unreadable, especially in dense nests.
## Solution
Implement the polylabel algorithm (pole of inaccessibility) to find the point inside each part's polygon with maximum distance from all edges, including hole edges. Draw the part ID label centered on that point.
## Design
### Part 1: Polylabel Algorithm
Add `PolyLabel` static class in `OpenNest.Geometry` namespace (file: `OpenNest.Core/Geometry/PolyLabel.cs`).
**Public API:**
```csharp
public static class PolyLabel
{
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5);
}
```
**Algorithm:**
1. Compute bounding box of the outer polygon.
2. Divide into a grid of cells (cell size = shorter bbox dimension).
3. For each cell, compute signed distance from cell center to nearest edge on any ring (outer boundary + all holes). Use `Polygon.ContainsPoint` for sign (negative if outside outer polygon or inside a hole).
4. Track the best interior point found so far.
5. Use a priority queue (sorted list) ordered by maximum possible distance for each cell.
6. Subdivide promising cells that could beat the current best; discard the rest.
7. Stop when the best cell's potential improvement over the current best is less than the precision tolerance.
**Dependencies within codebase:**
- `Polygon.ContainsPoint(Vector)` — ray-casting point-in-polygon test (already exists).
- Point-to-segment distance — compute from `Line` or inline (distance from point to each polygon edge).
**Fallback:** If the polygon is degenerate (< 3 vertices) or the program has no geometry, fall back to the bounding box center.
**No external dependencies.**
### Part 2: Label Rendering in LayoutPart
Modify `LayoutPart` in `OpenNest/LayoutPart.cs`.
**Changes:**
1. Add a cached `Vector? _labelPoint` field in **program-local coordinates** (pre-transform). Invalidated when `IsDirty` is set.
2. When computing the label point (on first draw after invalidation):
- Convert the part's `Program` to geometry via `ConvertProgram.ToGeometry`.
- Build shapes via `ShapeBuilder.GetShapes`.
- Identify the outer contour using `ShapeProfile` (the `Perimeter` shape) and convert cutouts to hole polygons.
- Run `PolyLabel.Find(outer, holes)` on the result.
- Cache the `Vector` in program-local coordinates.
3. In `Draw(Graphics g, string id)`:
- Offset the cached label point by `BasePart.Location`.
- Transform through the current view matrix (handles zoom/pan without cache invalidation).
- Draw the ID string centered using `StringFormat` with `Alignment = Center` and `LineAlignment = Center`.
**Coordinate pipeline:** polylabel runs once in program-local coordinates (expensive, cached). Location offset + matrix transform happen every frame (cheap, no caching needed). This matches how the existing `GraphicsPath` pipeline works and avoids stale cache on zoom/pan.
## Scope
- **In scope:** polylabel algorithm, label positioning change in `LayoutPart.Draw`.
- **Out of scope:** changing part origins, modifying the nesting engine, any changes to `Part`, `Drawing`, or `Program` classes.
## Testing
- Unit tests for `PolyLabel.Find()` with known polygons:
- Square — label at center.
- L-shape — label in the larger lobe.
- C-shape — label inside the concavity, not at bounding box center.
- Triangle — label at incenter.
- Thin rectangle (10:1 aspect ratio) — label centered along the short axis.
- Square with large centered hole — label avoids the hole.
- Verify the returned point is inside the polygon and has the expected distance from edges.

View File

@@ -1,143 +0,0 @@
# Remnant Finder Design
## Problem
Remnant detection is currently scattered across four places in the codebase, all using simple edge-strip heuristics that miss interior gaps and produce unreliable results:
- `Plate.GetRemnants()` — finds strips along plate edges from global min/max of part bounding boxes
- `DefaultNestEngine.TryRemainderImprovement()` / `TryStripRefill()` / `ClusterParts()` — clusters parts into rows/columns and refills the last incomplete cluster
- `FillScore.ComputeUsableRemnantArea()` — estimates remnant area from rightmost/topmost part edges for fill scoring
- `NestEngineBase.ComputeRemainderWithin()` — picks the larger of one horizontal or vertical strip
These approaches only find single edge strips and cannot discover multiple or interior empty regions.
## Solution
A standalone `RemnantFinder` class in `OpenNest.Engine` that uses edge projection to find all rectangular empty regions in a work area given a set of obstacle bounding boxes. This decouples remnant detection from the nesting engine and enables an iterative workflow:
1. Fill an area
2. Get all remnants
3. Pick a remnant, fill it
4. Get all remnants again (repeat)
## API
### `RemnantFinder` — `OpenNest.Engine`
```csharp
public class RemnantFinder
{
// Constructor
public RemnantFinder(Box workArea, List<Box> obstacles = null);
// Mutable obstacle management
public List<Box> Obstacles { get; }
public void AddObstacle(Box obstacle);
public void AddObstacles(IEnumerable<Box> obstacles);
public void ClearObstacles();
// Core method
public List<Box> FindRemnants(double minDimension = 0);
// Convenience factory
public static RemnantFinder FromPlate(Plate plate);
}
```
### `FindRemnants` Algorithm (Edge Projection)
1. Collect all unique X coordinates from obstacle left/right edges + work area left/right.
2. Collect all unique Y coordinates from obstacle bottom/top edges + work area bottom/top.
3. Form candidate rectangles from every adjacent `(x[i], x[i+1])` x `(y[j], y[j+1])` cell in the grid.
4. Filter out any candidate that overlaps any obstacle.
5. Merge adjacent empty cells into larger rectangles — greedy row-first merge: scan cells left-to-right within each row and merge horizontally where cells share the same Y span, then merge vertically where resulting rectangles share the same X span and are adjacent in Y. This produces "good enough" large rectangles without requiring maximal rectangle decomposition.
6. Filter by `minDimension` — both width and height must be >= the threshold.
7. Return sorted by area descending.
### `FromPlate` Factory
Extracts `plate.WorkArea()` as the work area and each part's bounding box offset by `plate.PartSpacing` as obstacles.
## Scoping
The `RemnantFinder` operates on whatever work area it's given. When used within the strip nester or sub-region fills, pass the sub-region's work area and only the parts placed within it — not the full plate. This prevents remnants from spanning into unrelated layout regions.
## Thread Safety
`RemnantFinder` is not thread-safe. Each thread/task should use its own instance. The `FromPlate` factory creates a snapshot of obstacles at construction time, so concurrent reads of the plate during construction should be avoided.
## Removals
### `DefaultNestEngine`
Remove the entire remainder phase:
- `TryRemainderImprovement()`
- `TryStripRefill()`
- `ClusterParts()`
- `NestPhase.Remainder` reporting in both `Fill()` overrides
The engine's `Fill()` becomes single-pass. Iterative remnant filling is the caller's responsibility.
### `NestPhase.Remainder`
Remove the `Remainder` value from the `NestPhase` enum. Clean up corresponding switch cases in:
- `NestEngineBase.FormatPhaseName()`
- `NestProgressForm.FormatPhase()`
### `Plate`
Remove `GetRemnants()` — fully replaced by `RemnantFinder.FromPlate(plate)`.
### `FillScore`
Remove remnant-related members:
- `MinRemnantDimension` constant
- `UsableRemnantArea` property
- `ComputeUsableRemnantArea()` method
- Remnant area from the `CompareTo` ordering
Constructor simplifies from `FillScore(int count, double usableRemnantArea, double density)` to `FillScore(int count, double density)`. The `Compute` factory method drops the `ComputeUsableRemnantArea` call accordingly.
### `NestProgress`
Remove `UsableRemnantArea` property. Update `NestEngineBase.ReportProgress()` to stop computing/setting it. Update `NestProgressForm` to stop displaying it.
### `NestEngineBase`
Replace `ComputeRemainderWithin()` with `RemnantFinder` in the `Nest()` method. The current `Nest()` fills an item, then calls `ComputeRemainderWithin` to get a single remainder box for the next item. Updated behavior: after filling, create a `RemnantFinder` with the current work area and all placed parts, call `FindRemnants()`, and use the largest remnant as the next work area. If no remnants exist, the fill loop stops.
### `StripNestResult`
Remove `RemnantBox` property. The `StripNestEngine.TryOrientation` assignment to `result.RemnantBox` is removed — the value was stored but never read externally. The `StripNestResult` class itself is retained (it still carries `Parts`, `StripBox`, `Score`, `Direction`).
## Caller Updates
### `NestingTools` (MCP)
`fill_remnants` switches from `plate.GetRemnants()` to:
```csharp
var finder = RemnantFinder.FromPlate(plate);
var remnants = finder.FindRemnants(minDimension);
```
### `InspectionTools` (MCP)
`get_plate_info` switches from `plate.GetRemnants()` to `RemnantFinder.FromPlate(plate).FindRemnants()`.
### Debug Logging
`DefaultNestEngine.FillWithPairs()` logs `bestScore.UsableRemnantArea` — update to log only count and density after the `FillScore` simplification.
### UI / Console callers
Any caller that previously relied on `TryRemainderImprovement` getting called automatically inside `Fill()` will need to implement the iterative loop: fill -> find remnants -> fill remnant -> repeat.
## PlateView Work Area Visualization
When an area is being filled (during the iterative workflow), the `PlateView` control displays the active work area's outline as a dashed orange rectangle. The outline persists while that area is being filled and disappears when the fill completes.
**Implementation:** Add a `Box ActiveWorkArea` property to `PlateView` (`Box` is a reference type, so `null` means no overlay). When set, the paint handler draws a dashed rectangle at that location. The `NestProgress` class gets a new `Box ActiveWorkArea` property so the progress pipeline carries the current work area from the engine to the UI. The existing progress callbacks in `PlateView.FillWithProgress` and `MainForm` set `PlateView.ActiveWorkArea` from the progress object, alongside the existing `SetTemporaryParts` calls. `NestEngineBase.ReportProgress` populates `ActiveWorkArea` from its `workArea` parameter.
## Future
The edge projection algorithm is embarrassingly parallel — each candidate rectangle's overlap check is independent. This makes it a natural fit for GPU acceleration via `OpenNest.Gpu` in the future.

View File

@@ -1,111 +0,0 @@
# Shape Library Design Spec
## Overview
A parametric shape library for OpenNest that provides reusable, self-describing shape classes for generating `Drawing` objects. Each shape is its own class with typed parameters, inheriting from an abstract `ShapeDefinition` base class. Inspired by PEP's WINSHAPE library.
## Location
- Project: `OpenNest.Core`
- Folder: `Shapes/`
- Namespace: `OpenNest.Shapes`
## Architecture
### Base Class — `ShapeDefinition`
Abstract base class that all shapes inherit from. `Name` defaults to the shape type name (e.g. `"Rectangle"`) but can be overridden.
```csharp
public abstract class ShapeDefinition
{
public string Name { get; set; }
protected ShapeDefinition()
{
// Default name to the concrete class name, stripping "Shape" suffix
var typeName = GetType().Name;
Name = typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public abstract Drawing GetDrawing();
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
if (pgm == null)
throw new InvalidOperationException(
$"Failed to create program for shape '{Name}'. Check that parameters produce valid geometry.");
return new Drawing(Name, pgm);
}
}
```
- `Name`: The name assigned to the resulting `Drawing`. Defaults to the shape class name without the "Shape" suffix. Never null.
- `GetDrawing()`: Each shape implements this to build its geometry and return a `Drawing`.
- `CreateDrawing()`: Shared helper that converts a list of geometry entities into a `Drawing` via `ConvertGeometry.ToProgram()`. Throws `InvalidOperationException` if the geometry is degenerate (prevents null `Program` from reaching `Drawing.UpdateArea()`).
### Shape Classes
#### Tier 1 — Basics (extracted from MCP InputTools)
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RectangleShape` | `Width`, `Height` | Axis-aligned rectangle from origin |
| `CircleShape` | `Diameter` | Circle centered at origin. Implementation divides by 2 for the `Circle` entity's radius parameter. |
| `LShape` | `Width`, `Height`, `LegWidth`?, `LegHeight`? | L-shaped profile. `LegWidth` defaults to `Width/2`, `LegHeight` defaults to `Height/2`. |
| `TShape` | `Width`, `Height`, `StemWidth`?, `BarHeight`? | T-shaped profile. `StemWidth` defaults to `Width/3`, `BarHeight` defaults to `Height/3`. |
#### Tier 2 — Common CNC shapes
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RingShape` | `OuterDiameter`, `InnerDiameter` | Annular ring (two concentric circles). Both converted to radius internally. |
| `RightTriangleShape` | `Width`, `Height` | Right triangle with the right angle at origin |
| `IsoscelesTriangleShape` | `Base`, `Height` | Isosceles triangle centered on base |
| `TrapezoidShape` | `TopWidth`, `BottomWidth`, `Height` | Trapezoid with bottom edge centered under top |
| `OctagonShape` | `Width` | Regular octagon where `Width` is the flat-to-flat distance |
| `RoundedRectangleShape` | `Width`, `Height`, `Radius` | Rectangle with 90-degree CCW arc corners |
### File Structure
```
OpenNest.Core/
Shapes/
ShapeDefinition.cs
CircleShape.cs
RectangleShape.cs
RingShape.cs
RightTriangleShape.cs
IsoscelesTriangleShape.cs
TrapezoidShape.cs
OctagonShape.cs
RoundedRectangleShape.cs
LShape.cs
TShape.cs
```
### Geometry Construction
Each shape builds a `List<Entity>` (using `Line`, `Arc`, `Circle` from `OpenNest.Geometry`) and passes it to the base `CreateDrawing()` helper. Shapes are constructed at the origin (0,0) with positive X/Y extents.
- **Lines** for straight edges — endpoints must chain end-to-end for `ShapeBuilder` to detect closed shapes.
- **Arcs** for rounded corners (`RoundedRectangleShape`). Arcs use CCW direction (not reversed) with angles in radians.
- **Circles** for `CircleShape` and `RingShape` outer/inner boundaries.
### MCP Integration
`InputTools.CreateDrawing` in `OpenNest.Mcp` will be refactored to instantiate the appropriate `ShapeDefinition` subclass and call `GetDrawing()`, replacing the existing private `CreateRectangle`, `CreateCircle`, `CreateLShape`, `CreateTShape` methods. The MCP tool's existing flat parameter names (`radius`, `width`, `height`) are mapped to the shape class properties at the MCP layer. The `gcode` case remains as-is.
New Tier 2 shapes can be exposed via the MCP tool by extending the `shape` parameter's accepted values and mapping to the new shape classes, with additional MCP parameters as needed.
## Future Expansion
- Additional shapes (Tier 3): Single-D, Parallelogram, House, Stair, Rectangle with chamfer(s), Ring segment, Slot rectangle
- UI shape picker with per-shape parameter editors
- Shape discovery via reflection or static registry
- `LShape`/`TShape` additional sub-dimension parameters for full parametric control

View File

@@ -1,65 +0,0 @@
# Pattern Tile Layout Window
## Summary
A standalone tool window for designing two-part tile patterns and previewing how they fill a plate. The user selects two drawings, arranges them into a unit cell by dragging, and sees the pattern tiled across a configurable plate. The unit cell compacts on release using the existing angle-based `Compactor.Push`. The tiled result can be applied to the current plate or a new plate.
## Window Layout
`PatternTileForm` is a non-MDI dialog opened from the main menu/toolbar. It receives a reference to the active `Nest` (for drawing list and plate creation). Horizontal `SplitContainer`:
- **Left panel (Unit Cell Editor):** A `PlateView` with `Plate.Size = (0, 0)` — no plate outline drawn. `Plate.Quantity = 0` to prevent `Drawing.Quantity.Nested` side-effects when parts are added/removed. Shows only the two parts. The user drags parts freely to position them relative to each other. Standard `PlateView` interactions (shift+scroll rotation, middle-click 90-degree rotation) are available. On mouse up after a drag, gravity compaction fires toward the combined center of gravity. Part spacing from the preview plate is used as the minimum gap during compaction.
- **Right panel (Tile Preview):** A read-only `PlateView` (`AllowSelect = false`, `AllowDrop = false`) with `Plate.Quantity = 0` (same isolation from quantity tracking). Shows the unit cell pattern tiled across a plate with a visible plate outline. Plate size is user-configurable, defaulting to the current nest's `PlateDefaults` size. Rebuilds on mouse up in the unit cell editor (not during drag).
- **Top control strip:** Two `ComboBox` dropdowns ("Drawing A", "Drawing B") populated from the active nest's `DrawingCollection`. Both may select the same drawing. Plate size inputs (length, width). An "Auto-Arrange" button. An "Apply" button.
## Drawing Selection & Unit Cell
When both dropdowns have a selection, two parts are created and placed side by side horizontally in the left `PlateView`, centered in the view. Selecting the same drawing for both is allowed.
When only one dropdown has a selection, a single part is shown in the unit cell editor. The tile preview tiles that single part across the plate (simple grid fill). The compaction step is skipped since there is only one part.
When neither dropdown has a selection, both panels are empty.
## Compaction on Mouse Up
On mouse up after a drag, each part is pushed individually toward the combined centroid of both parts:
1. Compute the centroid of the two parts' combined bounding box.
2. For each part, compute the angle from that part's bounding box center to the centroid.
3. Call the existing `Compactor.Push(List<Part>, List<Part>, Box, double, double angle)` overload for each part individually, treating the other part as the sole obstacle. Use a large synthetic work area (e.g., `new Box(-10000, -10000, 20000, 20000)`) since the unit cell editor has no real plate boundary — the work area just needs to not constrain the push.
4. The push uses part spacing from the preview plate as the minimum gap.
This avoids the zero-size plate `WorkArea()` issue and uses the existing angle-based push that already exists in `Compactor`.
## Auto-Arrange
A button that tries rotation combinations (0, 90, 180, 270 for each part — 16 combinations) and picks the pair arrangement with the tightest bounding box after compaction. The user can fine-tune from there.
## Tiling Algorithm
1. Compute the unit cell bounding box from the two parts' combined bounds.
2. Add half the part spacing as a margin on all sides of the cell, so adjacent cells have the correct spacing between parts at cell boundaries.
3. Calculate grid dimensions: `cols = floor(plateWorkAreaWidth / cellWidth)`, `rows = floor(plateWorkAreaHeight / cellHeight)`.
4. For each grid position `(col, row)`, clone the two parts offset by `(col * cellWidth, row * cellHeight)`.
5. Place all cloned parts on the preview plate.
Tiling recalculates only on mouse up in the unit cell editor, or when drawing selection or plate size changes.
## Apply to Plate
The "Apply" button opens a dialog with two choices:
- **Apply to current plate** — clears the current plate, then places the tiled parts onto it in `EditNestForm`.
- **Apply to new plate** — creates a new plate in the nest with the preview plate's size, then places the parts.
`PatternTileForm` returns a result object containing the list of parts and the target choice. The caller (`EditNestForm`) handles actual placement and quantity updates.
## Components
| Component | Project | Purpose |
|-----------|---------|---------|
| `PatternTileForm` | OpenNest (WinForms) | The dialog window with split layout, controls, and apply logic |
| Menu/toolbar integration | OpenNest (WinForms) | Entry point from `EditNestForm` toolbar |
Note: The angle-based `Compactor.Push(movingParts, obstacleParts, workArea, partSpacing, angle)` overload already exists in `OpenNest.Engine/Compactor.cs` — no engine changes are needed.

View File

@@ -1,295 +0,0 @@
# Pluggable Fill Strategies Design
## Problem
`DefaultNestEngine.FindBestFill` is a monolithic method that hard-wires four fill phases (Pairs, Linear, RectBestFit, Extents) in a fixed order. Adding a new fill strategy or changing the execution order requires modifying `DefaultNestEngine` directly. The Linear phase is expensive and rarely wins, but there's no way to skip or reorder it without editing the orchestration code.
## Goal
Extract fill strategies into pluggable components behind a common interface. Engines compose strategies in a pipeline where each strategy receives the current best result from prior strategies and can decide whether to run. New strategies can be added by implementing the interface — including from plugin DLLs discovered via reflection.
## Scope
This refactoring targets only the **single-item fill path** (`DefaultNestEngine.FindBestFill`, called from the `Fill(NestItem, ...)` overload). The following are explicitly **out of scope** and remain unchanged:
- `Fill(List<Part> groupParts, ...)` — group-fill overload, has its own inline orchestration with different conditions (multi-phase block only runs when `groupParts.Count == 1`). May be refactored to use strategies in a future pass once the single-item pipeline is proven.
- `PackArea` — packing is a different operation (bin-packing single-quantity items).
- `Nest` — multi-item orchestration on `NestEngineBase`, uses `Fill` and `PackArea` as building blocks.
## Design
### `IFillStrategy` Interface
```csharp
public interface IFillStrategy
{
string Name { get; }
NestPhase Phase { get; }
int Order { get; } // lower runs first; gaps of 100 for plugin insertion
List<Part> Fill(FillContext context);
}
```
Strategies must be **stateless**. All mutable state lives in `FillContext`. This avoids leaking state between calls when strategies are shared across invocations.
Strategies **may** call `NestEngineBase.ReportProgress` for intermediate progress updates (e.g., `LinearFillStrategy` reports per-angle progress). The `FillContext` carries `Progress` and `PlateNumber` for this purpose. The pipeline orchestrator reports progress only when the overall best improves; strategies report their own internal progress as they work.
For plugin strategies that don't map to a built-in `NestPhase`, use `NestPhase.Custom` (a new enum value added as part of this work). The `Name` property provides the human-readable label.
### `FillContext`
Carries inputs and pipeline state through the strategy chain:
```csharp
public class FillContext
{
// Inputs
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; }
// Pipeline state
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();
// Shared resources (populated by earlier strategies, available to later ones)
public Dictionary<string, object> SharedState { get; } = new();
}
```
`SharedState` enables cross-strategy data sharing without direct coupling. Well-known keys:
| Key | Type | Producer | Consumer |
|-----|------|----------|----------|
| `"BestFits"` | `List<BestFitResult>` | `PairsFillStrategy` | `ExtentsFillStrategy` |
| `"BestRotation"` | `double` | Pipeline setup | `ExtentsFillStrategy`, `LinearFillStrategy` |
| `"AngleCandidates"` | `List<double>` | Pipeline setup | `LinearFillStrategy` |
### Pipeline Setup
Before iterating strategies, `RunPipeline` performs shared pre-computation and stores results in `SharedState`:
```csharp
private void RunPipeline(FillContext context)
{
// Pre-pipeline setup: shared across strategies
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
context.SharedState["BestRotation"] = bestRotation;
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
context.SharedState["AngleCandidates"] = angles;
foreach (var strategy in FillStrategyRegistry.Strategies)
{
// ... strategy loop ...
}
// Post-pipeline: record productive angles for cross-run learning
angleBuilder.RecordProductive(context.AngleResults);
}
```
The `AngleCandidateBuilder` instance stays on `DefaultNestEngine` (not inside a strategy) because it accumulates cross-run learning state via `RecordProductive`. Strategies read the pre-built angle list from `SharedState["AngleCandidates"]`.
### `FillStrategyRegistry`
Discovers strategies via reflection, similar to `NestEngineRegistry.LoadPlugins`. Stores strategy **instances** (not factories) because strategies are stateless:
```csharp
public static class FillStrategyRegistry
{
private static readonly List<IFillStrategy> strategies = new();
static FillStrategyRegistry()
{
LoadFrom(typeof(FillStrategyRegistry).Assembly);
}
private static List<IFillStrategy> sorted;
public static IReadOnlyList<IFillStrategy> Strategies =>
sorted ??= strategies.OrderBy(s => s.Order).ToList();
public static void LoadFrom(Assembly assembly)
{
/* scan for IFillStrategy implementations */
sorted = null; // invalidate cache
}
public static void LoadPlugins(string directory)
{
/* load DLLs and scan each */
sorted = null; // invalidate cache
}
}
```
Strategy plugins use a `Strategies/` directory (separate from the `Engines/` directory used by `NestEngineRegistry`). Note: plugin strategies cannot use `internal` types like `BinConverter` from `OpenNest.Engine`. If a plugin needs rectangle packing, `BinConverter` would need to be made `public` — defer this until a plugin actually needs it.
### Built-in Strategy Order
| Strategy | Order | Notes |
|----------|-------|-------|
| `PairsFillStrategy` | 100 | Populates `SharedState["BestFits"]` for Extents |
| `RectBestFitStrategy` | 200 | |
| `ExtentsFillStrategy` | 300 | Reads `SharedState["BestFits"]` from Pairs |
| `LinearFillStrategy` | 400 | Expensive, rarely wins, runs last |
Gaps of 100 allow plugins to slot in between (e.g., Order 150 runs after Pairs, before RectBestFit).
### Strategy Implementations
Each strategy is a thin stateless adapter around the existing filler class. Strategies construct filler instances using `context.Plate` properties:
```csharp
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);
// Share the BestFitCache for Extents to use later.
// This is a cache hit (PairFiller already called GetOrCompute internally),
// so it's a dictionary lookup, not a recomputation.
var bestFits = BestFitCache.GetOrCompute(
context.Item.Drawing, context.Plate.Size.Length,
context.Plate.Size.Width, context.Plate.PartSpacing);
context.SharedState["BestFits"] = bestFits;
return result;
}
}
```
Summary of all four:
- **`PairsFillStrategy`** — constructs `PairFiller(context.Plate.Size, context.Plate.PartSpacing)`, stores `BestFitCache` in `SharedState`
- **`RectBestFitStrategy`** — uses `BinConverter.ToItem(item, partSpacing)` and `BinConverter.CreateBin(workArea, partSpacing)` to delegate to `FillBestFit`
- **`ExtentsFillStrategy`** — constructs `FillExtents(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["BestRotation"]` for angles, reads `SharedState["BestFits"]` from Pairs
- **`LinearFillStrategy`** — constructs `FillLinear(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["AngleCandidates"]` for angle list. Internally iterates all angle candidates, tracks its own best, writes per-angle `AngleResults` to context, and calls `ReportProgress` for per-angle updates (preserving the existing UX). Returns only its single best result.
The underlying classes (`PairFiller`, `FillLinear`, `FillExtents`, `FillBestFit`) are unchanged.
### Changes to `DefaultNestEngine`
`FindBestFill` is replaced by `RunPipeline`:
```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();
context.PhaseResults.Add(new PhaseResult(
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds));
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)
{
// Graceful degradation: return whatever best has been accumulated so far.
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
}
angleBuilder.RecordProductive(context.AngleResults);
}
```
After `RunPipeline`, the engine copies `context.PhaseResults` and `context.AngleResults` back to the `NestEngineBase` properties so existing UI and test consumers continue to work:
```csharp
PhaseResults.AddRange(context.PhaseResults);
AngleResults.AddRange(context.AngleResults);
WinnerPhase = context.WinnerPhase;
```
**Removed from `DefaultNestEngine`:**
- `FindBestFill` method (replaced by `RunPipeline`)
- `FillRectangleBestFit` method (moves into `RectBestFitStrategy`)
- `QuickFillCount` method (dead code — has zero callers, delete it)
**Stays on `DefaultNestEngine`:**
- `AngleCandidateBuilder` field — owns cross-run learning state, used in pipeline setup/teardown
- `ForceFullAngleSweep` property — forwards to `angleBuilder.ForceFullSweep`, keeps existing public API for `BruteForceRunner` and tests
- `Fill(List<Part> groupParts, ...)` overload — out of scope (see Scope section)
- `PackArea` — out of scope
**Static helpers `BuildRotatedPattern` and `FillPattern`** move to `Strategies/FillHelpers.cs`.
### File Layout
```
OpenNest.Engine/
Strategies/
IFillStrategy.cs
FillContext.cs
FillStrategyRegistry.cs
FillHelpers.cs
PairsFillStrategy.cs
LinearFillStrategy.cs
RectBestFitStrategy.cs
ExtentsFillStrategy.cs
```
### What Doesn't Change
- `PairFiller.cs`, `FillLinear.cs`, `FillExtents.cs`, `RectanglePacking/FillBestFit.cs` — underlying implementations
- `FillScore.cs`, `NestProgress.cs`, `Compactor.cs` — shared infrastructure
- `NestEngineBase.cs` — base class
- `NestEngineRegistry.cs` — engine-level registry (separate concern)
- `StripNestEngine.cs` — delegates to `DefaultNestEngine` internally
### Minor Changes to `NestPhase`
Add `Custom` to the `NestPhase` enum for plugin strategies that don't map to a built-in phase:
```csharp
public enum NestPhase
{
Linear,
RectBestFit,
Pairs,
Nfp,
Extents,
Custom
}
```
### Testing
- Existing `EngineRefactorSmokeTests` serve as the regression gate — they must pass unchanged after refactoring.
- `BruteForceRunner` continues to access `ForceFullAngleSweep` via the forwarding property on `DefaultNestEngine`.
- Individual strategy adapters do not need their own unit tests initially — the existing smoke tests cover the end-to-end pipeline. Strategy-level tests can be added as the strategy count grows.

View File

@@ -1,235 +0,0 @@
# NestProgressForm Redesign v2
## Problem
The current `NestProgressForm` is a flat `TableLayoutPanel` of label/value pairs with default WinForms styling, MS Sans Serif font, and no visual hierarchy. It's functional but looks plain and gives no sense of where the engine is in its process or whether results are improving.
## Solution
Four combined improvements:
1. A custom-drawn **phase stepper** control showing all 6 nesting phases with visited/active/pending states
2. **Grouped sections** separating Results from Status with white panels on a gray background
3. **Modern typography** — Segoe UI for labels, Consolas for values (monospaced so numbers don't shift width)
4. **Flash & fade with color-coded density** — values flash green on change and fade back; density flash color varies by quality (red < 50%, yellow 50-70%, green > 70%)
## Phase Stepper Control
**New file: `OpenNest/Controls/PhaseStepperControl.cs`**
A custom `UserControl` that draws 6 circles with labels beneath, connected by lines:
```
●━━━●━━━●━━━○━━━○━━━○
Linear BestFit Pairs NFP Extents Custom
```
### All 6 phases
The stepper displays all values from the `NestPhase` enum in enum order: `Linear`, `RectBestFit` (labeled "BestFit"), `Pairs`, `Nfp` (labeled "NFP"), `Extents`, `Custom`. This ensures the control stays accurate as new phases are added or existing ones start being used.
### Non-sequential design
The engine does not execute phases in a fixed order. `DefaultNestEngine` runs strategies in registration order (Linear → Pairs → RectBestFit → Extents by default), and custom engines may run any subset in any order. Some phases may never execute.
The stepper tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress. The connecting lines are purely decorative (always light gray).
### Visual states
- **Active:** Filled circle with accent color (`#0078D4`), slightly larger radius (11px vs 9px), subtle glow (`Color.FromArgb(60, 0, 120, 212)` drawn as a larger circle behind), bold label
- **Visited:** Filled circle with accent color, normal radius, bold label
- **Pending:** Hollow circle with gray border (`#C0C0C0`), dimmed label text (`#999999`)
- **All complete:** All 6 circles filled (set when `IsComplete = true`)
- **Initial state:** All 6 circles in Pending state
### Implementation
- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray (`#D0D0D0`).
- Colors and fonts defined as `static readonly` fields. Fonts cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates.
- State: `HashSet<NestPhase> VisitedPhases`, `NestPhase? ActivePhase` property. Setting `ActivePhase` adds to `VisitedPhases` and calls `Invalidate()`. `bool IsComplete` marks all phases done.
- `DoubleBuffered = true`.
- Fixed height: 60px. Docks to fill width.
- Namespace: `OpenNest.Controls`.
- Phase display order matches `NestPhase` enum order. Display names: `RectBestFit` → "BestFit", `Nfp` → "NFP", others use `ToString()`.
## Form Layout
Four vertical zones using `DockStyle.Top` stacking:
```
┌──────────────────────────────────────────────┐
│ ●━━━●━━━●━━━○━━━○━━━○ │ Phase stepper
│ Linear BestFit Pairs Extents NFP Custom │
├──────────────────────────────────────────────┤
│ RESULTS │ Results group
│ Parts: 156 │
│ Density: 68.3% ████████░░ │
│ Nested: 24.1 x 36.0 (867.6 sq in) │
├──────────────────────────────────────────────┤
│ STATUS │ Status group
│ Plate: 2 │
│ Elapsed: 1:24 │
│ Detail: Trying best fit... │
├──────────────────────────────────────────────┤
│ [ Stop ] │ Button bar
└──────────────────────────────────────────────┘
```
### Group panels
Each group is a `Panel` containing:
- A header label ("RESULTS" / "STATUS") — Segoe UI 9pt bold, uppercase, color `#555555`, with `0.5px` letter spacing effect (achieved by drawing or just using uppercase text)
- A `TableLayoutPanel` with label/value rows beneath
Group panels use `Color.White` `BackColor` against the form's `SystemColors.Control` (gray) background. Small padding (10px horizontal, 4px vertical gap between groups).
### Typography
- All fonts: Segoe UI
- Group headers: 9pt bold, uppercase, color `#555555`
- Row labels: 8.25pt bold, color `#333333`
- Row values: Consolas 8.25pt regular — monospaced so numeric values don't shift width as digits change
- Detail value: Segoe UI 8.25pt regular (not monospaced, since it's descriptive text)
### Sizing
- Width: ~450px
- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~115px) + status group (~95px) + button bar (~45px) + padding
- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false`
### Plate row visibility
The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior).
### Phase description text
The phase stepper replaces the old Phase row. The descriptive text ("Trying rotations...") moves to the Detail row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when set.
### Unused row removed
The current form has `remnantLabel`/`remnantValue` but `NestProgress` has no unused/remnant property — these labels are never updated and always show "—". The redesign drops this row entirely.
### FormatPhase updates
`FormatPhase` currently handles Linear, RectBestFit, and Pairs. Add entries for the three remaining phases:
- `Extents` → "Trying extents..."
- `Nfp` → "Trying NFP..."
- `Custom` → phase name via `ToString()`
## Density Sparkline Bar
A small inline visual next to the density percentage value:
- Size: 60px wide, 8px tall
- Background: `#E0E0E0` (light gray track)
- Fill: gradient from orange (`#F5A623`) on the left to green (`#4CAF50`) on the right, clipped to the density percentage width
- Border radius: 4px
- Position: inline, 8px margin-left from the density text
### Implementation
Owner-drawn directly in a custom `Label` subclass or a small `Panel` placed next to the density value in the table. The simplest approach: a small `Panel` with `OnPaint` override that draws the track and fill. Updated whenever density changes.
**New file: `OpenNest/Controls/DensityBar.cs`** — a lightweight `Control` subclass:
- `double Value` property (0.0 to 1.0), calls `Invalidate()` on set
- `OnPaint`: fills rounded rect background, then fills gradient portion proportional to `Value`
- Fixed size: 60 x 8px
- `DoubleBuffered = true`
## Flash & Fade
### Current implementation (keep, with modification)
Values flash green (`Color.FromArgb(0, 160, 0)`) when they change and fade back to `SystemColors.ControlText` over ~1 second (20 steps at 50ms). A `SetValueWithFlash` helper checks if text actually changed before triggering. A single `System.Windows.Forms.Timer` drives all active fades.
### Color-coded density flash
Extend the flash color for the density value based on quality:
- Below 50%: red (`Color.FromArgb(200, 40, 40)`)
- 50% to 70%: yellow/orange (`Color.FromArgb(200, 160, 0)`)
- Above 70%: green (`Color.FromArgb(0, 160, 0)`) — same as current
### Fade state changes
The `SetValueWithFlash` method gains an optional `Color? flashColor` parameter. The fade dictionary changes from `Dictionary<Label, int>` to `Dictionary<Label, (int remaining, Color flashColor)>` so that each label fades from its own flash color. `FadeTimer_Tick` reads the per-label `flashColor` from the tuple when interpolating back to `SystemColors.ControlText`, rather than using the static `FlashColor` constant. `FlashColor` becomes the default when `flashColor` is null.
`UpdateProgress` passes the density-appropriate color when updating `densityValue`. All other values continue using the default green.
## Accept & Stop Buttons
Currently the form has a single "Stop" button that cancels the `CancellationTokenSource`. Callers check `token.IsCancellationRequested` and discard results when true. This means there's no way to stop early and keep the current best result.
### New button layout
Two buttons in the button bar, right-aligned:
```
[ Accept ] [ Stop ]
```
- **Accept:** Stops the engine and keeps the current best result. Sets `Accepted = true`, then cancels the token.
- **Stop:** Stops the engine and discards results. Leaves `Accepted = false`, cancels the token.
Both buttons are disabled until the first progress update arrives (so there's something to accept). After `ShowCompleted()`, both are replaced by a single "Close" button (same as current behavior).
### Accepted property
`bool Accepted { get; private set; }` — defaults to `false`. Set to `true` only by the Accept button click handler.
### Caller changes
Four callsites create a `NestProgressForm`. Each needs to honor the `Accepted` property:
**`MainForm.cs``RunAutoNest_Click`** (line ~868):
```csharp
// Before:
if (nestParts.Count > 0 && !token.IsCancellationRequested)
// After:
if (nestParts.Count > 0 && (!token.IsCancellationRequested || progressForm.Accepted))
```
**`MainForm.cs``FillPlate_Click`** (line ~983): No change needed — this path already accepts regardless of cancellation state (`if (parts.Count > 0)`).
**`MainForm.cs``FillArea_Click`** (line ~1024): No change needed — this path delegates to `ActionFillArea` which handles its own completion via a callback.
**`PlateView.cs``FillWithProgress`** (line ~933):
```csharp
// Before:
if (parts.Count > 0 && !cts.IsCancellationRequested)
// After:
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
```
## Public API
### Constructor
`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged.
### Properties
- `bool Accepted { get; }`**new**. True if user clicked Accept, false if user clicked Stop or form was closed.
### UpdateProgress(NestProgress progress)
Same as today, plus:
- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper
- Updates `densityBar.Value = progress.BestDensity`
- Passes color-coded flash color for density value
- Writes `FormatPhase(progress.Phase)` to Detail row as fallback when `progress.Description` is empty
- Enables Accept/Stop buttons on first call (if not already enabled)
### ShowCompleted()
Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control |
| `OpenNest/Controls/DensityBar.cs` | New — small density sparkline bar control |
| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration, color-coded flash, Accept/Stop buttons |
| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout |
| `OpenNest/Forms/MainForm.cs` | Update `RunAutoNest_Click` to check `progressForm.Accepted` |
| `OpenNest/Controls/PlateView.cs` | Update `FillWithProgress` to check `progressForm.Accepted` |

View File

@@ -1,138 +0,0 @@
# Two-Bucket Preview Parts
## Problem
During nesting, the PlateView preview shows whatever the latest progress report contains. When the engine runs multiple strategies sequentially (Pairs, Linear, RectBestFit, Extents), each strategy reports its own intermediate results. A later strategy starting fresh can report fewer parts than an earlier winner, causing the preview to visually regress. The user sees the part count drop and the layout change, even though the engine internally tracks the overall best.
A simple high-water-mark filter at the UI level prevents regression but freezes the preview and can diverge from the engine's actual result, causing the wrong layout to be accepted.
## Solution
Split the preview into two visual layers:
- **Stationary parts**: The overall best result found so far across all strategies. Never regresses. Drawn at full preview opacity.
- **Active parts**: The current strategy's work-in-progress. Updates freely as strategies iterate. Drawn at reduced opacity (~50% alpha).
The engine flags progress reports as `IsOverallBest` so the UI knows which bucket to update. On acceptance, the engine's returned result is used directly, not the preview state. This also fixes an existing bug where `AcceptTemporaryParts()` could accept stale preview parts instead of the engine's actual output.
## Changes
### NestProgress
Add one property:
```csharp
public bool IsOverallBest { get; set; }
```
Default `false`. Set to `true` by `RunPipeline` when reporting the overall winner, and by the final `ReportProgress` calls in `DefaultNestEngine.Fill`.
### NestEngineBase.ReportProgress
Add an optional `isOverallBest` parameter:
```csharp
internal static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description,
bool isOverallBest = false)
```
Pass through to the `NestProgress` object.
### DefaultNestEngine.RunPipeline
Remove the existing `ReportProgress` call from inside the `if (IsBetterFill(...))` block. Replace it with an unconditional report of the overall best after each strategy completes:
```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);
}
```
Strategy-internal progress reports (PairFiller, LinearFillStrategy, etc.) continue using the default `isOverallBest: false`.
### DefaultNestEngine.Fill — final reports
Both `Fill` overloads have a final `ReportProgress` call after the pipeline/fill completes. These must pass `isOverallBest: true` so the final preview goes to stationary parts at full opacity:
- `Fill(NestItem, Box, ...)` line 58 — reports the pipeline winner after quantity trimming
- `Fill(List<Part>, Box, ...)` line 85 — reports the single-strategy linear result
### ColorScheme
Add two members for the active (transparent) preview style, created alongside the existing preview resources in the `PreviewPartColor` setter with the same disposal pattern:
- `ActivePreviewPartBrush` — same color as `PreviewPartBrush` at ~50% alpha
- `ActivePreviewPartPen` — same color as `PreviewPartPen` at ~50% alpha
### PlateView
Rename `temporaryParts` to `activeParts`. Add `stationaryParts` (both `List<LayoutPart>`).
**New public API:**
- `SetStationaryParts(List<Part>)` — sets the overall-best preview, calls `Invalidate()`
- `SetActiveParts(List<Part>)` — sets the current-strategy preview, calls `Invalidate()`
- `ClearPreviewParts()` — clears both lists, calls `Invalidate()` (replaces `ClearTemporaryParts()`)
- `AcceptPreviewParts(List<Part> parts)` — adds the engine's returned `parts` directly to the plate, clears both lists. Decouples acceptance from preview state.
**Internal references:** `Refresh()`, `UpdateMatrix()`, and `SetPlate()` currently reference `temporaryParts`. All must be updated to handle both `stationaryParts` and `activeParts` (clear both in `SetPlate`, update both in `Refresh`/`UpdateMatrix`).
**Drawing order in `DrawParts`:**
1. Stationary parts: `PreviewPartBrush` / `PreviewPartPen` (full opacity)
2. Active parts: `ActivePreviewPartBrush` / `ActivePreviewPartPen` (~50% alpha)
**Remove:** `SetTemporaryParts`, `ClearTemporaryParts`, `AcceptTemporaryParts`.
### Progress callbacks
All four progress callback sites (PlateView.FillWithProgress, 3x MainForm) change from:
```csharp
SetTemporaryParts(p.BestParts);
```
to:
```csharp
if (p.IsOverallBest)
plateView.SetStationaryParts(p.BestParts);
else
plateView.SetActiveParts(p.BestParts);
```
### Acceptance points
All acceptance points change from `AcceptTemporaryParts()` to `AcceptPreviewParts(engineResult)` where `engineResult` is the `List<Part>` returned by the engine. The multi-plate nest path in MainForm already uses `plate.Parts.AddRange(nestParts)` directly and needs no change beyond clearing preview parts.
## Threading
All `SetStationaryParts`/`SetActiveParts` calls arrive on the UI thread via `Progress<T>` (which captures `SynchronizationContext` at construction). `DrawParts` also runs on the UI thread. No concurrent access to either list.
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/NestProgress.cs` | Add `IsOverallBest` property |
| `OpenNest.Engine/NestEngineBase.cs` | Add `isOverallBest` parameter to `ReportProgress` |
| `OpenNest.Engine/DefaultNestEngine.cs` | Report overall best after each strategy; flag final reports |
| `OpenNest/Controls/PlateView.cs` | Two-bucket temp parts, new API, update internal references |
| `OpenNest/Controls/ColorScheme.cs` | Add active preview brush/pen with disposal |
| `OpenNest/Forms/MainForm.cs` | Update 3 progress callbacks and acceptance points |
| `OpenNest/Actions/ActionFillArea.cs` | No change needed (uses PlateView API) |

View File

@@ -1,88 +0,0 @@
# Iterative Shrink-Fill Design
## Problem
`StripNestEngine` currently picks a single "strip" drawing (the highest-area item), shrink-fills it into the tightest sub-region, then fills remnants with remaining drawings. This wastes potential density — every drawing benefits from shrink-filling into its tightest sub-region, not just the first one.
## Design
### 1. IterativeShrinkFiller
New static class in `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`.
**Responsibility:** Given an ordered list of multi-quantity `NestItem` and a work area, iteratively shrink-fill each item into the tightest sub-region using `RemnantFiller` + `ShrinkFiller`, returning placed parts and leftovers.
**Algorithm:**
1. Create a `RemnantFiller` with the work area and spacing.
2. Build a single fill function (closure) that wraps the caller-provided raw fill function with dual-direction shrink logic:
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Height` (bottom strip direction).
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Width` (left strip direction).
- Compares results using `FillScore.Compute(parts, box)` where `box` is the remnant box passed by `RemnantFiller`. Since `FillScore` density is derived from placed parts' bounding box (not the work area parameter), the comparison is valid regardless of which box is used.
- Returns the parts from whichever direction scores better.
3. Pass this wrapper function and all items to `RemnantFiller.FillItems`, which drives the iteration — discovering free rectangles, iterating over items and boxes, and managing obstacle tracking.
4. After `RemnantFiller.FillItems` returns, collect any unfilled quantities (including `Quantity <= 0` items which mean "unlimited") into a leftovers list.
5. Return placed parts and leftovers. Remaining free space for the pack pass is reconstructed from placed parts by the caller (existing pattern), not by returning `RemnantFinder` state.
**Data flow:** Caller provides a raw single-item fill function (e.g., `DefaultNestEngine.Fill`) → `IterativeShrinkFiller` wraps it in a dual-direction shrink closure → passes the wrapper to `RemnantFiller.FillItems` which drives the loop.
**Note on quantities:** `Quantity <= 0` means "fill as many as possible" (unlimited). These items are included in the fill bucket (qty != 1), not the pack bucket.
**Interface:**
```csharp
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; }
public List<NestItem> Leftovers { get; set; }
}
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token);
}
```
The class composes `RemnantFiller` and `ShrinkFiller` — it does not duplicate their logic.
### 2. Revised StripNestEngine.Nest
**Note:** The rotating calipers angle is already included via `RotationAnalysis.FindBestRotation`, which calls `RotatingCalipers.MinimumBoundingRectangle` and feeds the result as `bestRotation` into `AngleCandidateBuilder.Build`. No changes needed to the angle pipeline.
The `Nest` override becomes a thin orchestrator:
1. Separate items into multi-quantity (qty != 1) and singles (qty == 1).
2. Sort multi-quantity items by `Priority` ascending, then `Drawing.Area` descending.
3. Call `IterativeShrinkFiller.Fill` with the sorted multi-quantity items.
4. Collect leftovers: unfilled multi-quantity remainders + all singles.
5. If leftovers exist and free space remains, run `PackArea` into the remaining area.
6. Deduct placed quantities from the original items. Return all parts.
**Deleted code:**
- `SelectStripItemIndex` method
- `EstimateStripDimension` method
- `TryOrientation` method
- `ShrinkFill` method
**Deleted files:**
- `StripNestResult.cs`
- `StripDirection.cs`
## Files Changed
| File | Change |
|------|--------|
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | New — orchestrates RemnantFiller + ShrinkFiller with dual-direction selection |
| `OpenNest.Engine/StripNestEngine.cs` | Rewrite Nest to use IterativeShrinkFiller + pack leftovers |
| `OpenNest.Engine/StripNestResult.cs` | Delete |
| `OpenNest.Engine/StripDirection.cs` | Delete |
## Not In Scope
- Trying multiple item orderings and picking the best overall `FillScore` — future follow-up once we confirm the iterative approach is fast enough.
- Changes to `NestEngineBase`, `DefaultNestEngine`, `RemnantFiller`, `ShrinkFiller`, `RemnantFinder`, `AngleCandidateBuilder`, `NestItem`, or UI code.

View File

@@ -1,51 +0,0 @@
# Lead Item Rotation for Strip Nesting
## Problem
`StripNestEngine.Nest()` sorts multi-quantity items by priority then area descending, always placing the largest-area drawing first. This fixed ordering can produce suboptimal layouts — a different starting drawing may create a tighter shrink region that leaves more usable remnant space for subsequent items.
## Solution
Try multiple candidate orderings by promoting each of the top N largest drawings to the front of the fill list. Run the full pipeline for each ordering, score the results, and keep the best.
## Candidate Generation
- Take the multi-quantity fill items (already filtered from singles)
- Identify the top `MaxLeadCandidates` (default 3) unique drawings by `Drawing.Area`, deduplicated by `Drawing` reference equality
- If there is only one unique drawing, skip the multi-ordering loop entirely (no-op — only one possible ordering)
- For each candidate drawing, create a reordered copy of the fill list where that drawing's items move to the front, preserving the original relative order for the remaining items
- The default ordering (largest area first) is always the first candidate, so the feature never regresses
- Lead promotion intentionally overrides the existing priority-then-area sort — the purpose is to explore whether a different lead item produces a better overall layout regardless of the default priority ordering
## Scoring
Use `FillScore` semantics for cross-ordering comparison: total placed part count as the primary metric, plate utilization (`sum(part.BaseDrawing.Area) / plate.WorkArea().Area()`) as tiebreaker. This is consistent with how `FillScore` works elsewhere in the codebase (count > density). Keep the first (default) result unless a later candidate is strictly better, so ties preserve the default ordering.
## Execution
- Run each candidate ordering sequentially through the existing pipeline: `IterativeShrinkFiller` → compaction → packing
- No added parallelism — each run already uses `Parallel.Invoke` internally for shrink axes
- `IterativeShrinkFiller.Fill` is a static method that creates fresh internal state (`RemnantFiller`, `placedSoFar` list) on each call, so the same input item list can be passed to multiple runs without interference. Neither `IterativeShrinkFiller` nor `RemnantFiller` mutate `NestItem.Quantity`. Each run also produces independent `Part` instances (created by `DefaultNestEngine.Fill`), so compaction mutations on one run's parts don't affect another.
- Only the winning result gets applied to the quantity deduction at the end of `Nest()`
## Progress Reporting
- Each candidate run reports progress normally (user sees live updates during shrink iterations)
- Between candidates, report a status message like "Lead item 2/3: [drawing name]"
- Only the final winning result is reported with `isOverallBest: true` to avoid the UI flashing between intermediate results
## Early Exit
- If a candidate meets all requested quantities **and** plate utilization exceeds 50%, skip remaining candidates
- Unlimited-quantity items (`Quantity <= 0`) never satisfy the quantity condition, so all candidates are always tried
- Cancellation token is respected — if cancelled mid-run, return the best result across all completed candidates
- The 50% threshold is a constant (`MinEarlyExitUtilization`) that can be tuned if typical nesting utilization proves higher or lower
## Scope
Changes are confined to `StripNestEngine.Nest()`. No modifications to `IterativeShrinkFiller`, `ShrinkFiller`, `DefaultNestEngine`, fill strategies, or the UI.
## Files
- Modify: `OpenNest.Engine/StripNestEngine.cs`
- Add test: `OpenNest.Tests/StripNestEngineTests.cs` (verify multiple orderings are tried, early exit works)

View File

@@ -1,238 +0,0 @@
# Nest API Design
## Overview
A new `OpenNest.Api` project providing a clean programmatic facade for nesting operations. A single `NestRequest` goes in, a self-contained `NestResponse` comes out. Designed for external callers (MCP server, console app, LaserQuote, future web API) that don't want to manually wire engine + timing + IO.
## Motivation
Today, running a nest programmatically requires manually coordinating:
1. DXF import (IO layer)
2. Plate/NestItem setup (Core)
3. Engine selection and execution (Engine layer)
4. Timing calculation (Core's `Timing` class)
5. File persistence (IO layer)
This design wraps all five steps behind a single stateless call. The response captures the original request, making nests reproducible and re-priceable months later.
## New Project: `OpenNest.Api`
Class library targeting `net8.0-windows`. References Core, Engine, and IO.
```
OpenNest.Api/
CutParameters.cs
NestRequest.cs
NestRequestPart.cs
NestStrategy.cs
NestResponse.cs
NestRunner.cs
```
All types live in the `OpenNest.Api` namespace.
## Types
### CutParameters
Unified timing and quoting parameters. Replaces the existing `OpenNest.CutParameters` in Core.
```csharp
namespace OpenNest.Api;
public class CutParameters
{
public double Feedrate { get; init; } // in/min or mm/sec depending on Units
public double RapidTravelRate { get; init; } // in/min or mm/sec
public TimeSpan PierceTime { get; init; }
public double LeadInLength { get; init; } // forward-looking: unused until Timing rework
public string PostProcessor { get; init; } // forward-looking: unused until Timing rework
public Units Units { get; init; }
public static CutParameters Default => new()
{
Feedrate = 100,
RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5),
Units = Units.Inches
};
}
```
`LeadInLength` and `PostProcessor` are included for forward compatibility but will not be wired into `Timing.CalculateTime` until the Timing rework. Implementers should not attempt to use them in the initial implementation.
### NestRequestPart
A part to nest, identified by DXF file path. No `Drawing` reference — keeps the request fully serializable for persistence.
```csharp
namespace OpenNest.Api;
public class NestRequestPart
{
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
public int Priority { get; init; } = 0;
}
```
### NestStrategy
```csharp
namespace OpenNest.Api;
public enum NestStrategy { Auto }
```
- `Auto` maps to `DefaultNestEngine` (multi-phase fill).
- Additional strategies (`Linear`, `BestFit`, `Pack`) will be added later, driven by ML-based auto-detection of part type during the training work. The intelligence for selecting the best strategy for a given part will live inside `DefaultNestEngine`.
### NestRequest
Immutable input capturing everything needed to run and reproduce a nest.
```csharp
namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
public Size SheetSize { get; init; } = new(60, 120); // OpenNest.Geometry.Size(width, length)
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1; // part-to-part spacing; edge spacing defaults to zero
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}
```
- `Parts` uses `IReadOnlyList<T>` to prevent mutation after construction, preserving reproducibility when the request is stored in the response.
- `Spacing` maps to `Plate.PartSpacing`. `Plate.EdgeSpacing` defaults to zero on all sides.
- `SheetSize` is `OpenNest.Geometry.Size` (not `System.Drawing.Size`).
### NestResponse
Immutable output containing computed metrics, the resulting `Nest`, and the original request for reproducibility.
```csharp
namespace OpenNest.Api;
public class NestResponse
{
public int SheetCount { get; init; }
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
public Task SaveAsync(string path) => ...;
public static Task<NestResponse> LoadAsync(string path) => ...;
}
```
`SaveAsync`/`LoadAsync` live on the data class for API simplicity — a pragmatic choice over a separate IO helper class.
### NestRunner
Stateless orchestrator. Single public method.
```csharp
namespace OpenNest.Api;
public static class NestRunner
{
public static async Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
// 1. Validate request (non-empty parts list, all DXF paths exist)
// 2. Import DXFs → Drawings via DxfImporter + ConvertGeometry.ToProgram
// 3. Create Plate from request.SheetSize / Thickness / Spacing
// 4. Convert NestRequestParts → NestItems
// 5. Multi-plate loop:
// a. Create engine via NestEngineRegistry
// b. Fill plate
// c. Deduct placed quantities
// d. If remaining quantities > 0, create next plate and repeat
// 6. Compute TimingInfo → CutTime using request.Cutting (placeholder for Timing rework)
// 7. Build and return NestResponse (with stopwatch for Elapsed)
}
}
```
- Static class, no state, no DI. If we need dependency injection later, we add an instance-based overload.
- `Timing.CalculateTime` is called with the new `CutParameters` (placeholder integration — `Timing` will be reworked later).
#### Multi-plate Loop
`NestRunner` handles multi-plate nesting: it fills a plate, deducts placed quantities from the remaining request, creates a new plate, and repeats until all quantities are met or no progress is made (a part doesn't fit on a fresh sheet). This is new logic — `AutoNester` and `NestEngineBase` are single-plate only.
#### Error Handling
- If any DXF file path does not exist or fails to import (empty geometry, conversion failure), `RunAsync` throws `FileNotFoundException` or `InvalidOperationException` with a message identifying the failing file. Fail-fast on first bad DXF — no partial results.
- If cancellation is requested, the method throws `OperationCanceledException` per standard .NET patterns.
## Renames
| Current | New | Reason |
|---------|-----|--------|
| `OpenNest.NestResult` (Engine) | `OpenNest.OptimizationResult` | Frees the "result" name for the public API; this type is engine-internal (sequence/score/iterations) |
| `OpenNest.CutParameters` (Core) | Deleted | Replaced by `OpenNest.Api.CutParameters` |
| `.opnest` file extension | `.nest` | Standardize file extensions |
All references to the renamed types and extensions must be updated across the solution: Engine, Core, IO, MCP, Console, Training, Tests, and WinForms.
The WinForms project gains a reference to `OpenNest.Api` to use the new `CutParameters` type (it already references Core and Engine, so no circular dependency).
## Persistence
### File Extensions
- **`.nest`** — nest files (renamed from `.opnest`)
- **`.nestquote`** — quote files (new)
### `.nestquote` Format
ZIP archive containing:
```
quote.nestquote (ZIP)
├── request.json ← serialized NestRequest
├── response.json ← computed metrics (SheetCount, Utilization, CutTime, Elapsed)
└── nest.nest ← embedded .nest file (existing format, produced by NestWriter)
```
- `NestResponse.SaveAsync(path)` writes this ZIP. The embedded `nest.nest` is written to a `MemoryStream` via `NestWriter`, then added as a ZIP entry alongside the JSON files.
- `NestResponse.LoadAsync(path)` reads it back using `NestReader` for the `.nest` payload and JSON deserialization for the metadata.
- Source DXF files are **not** embedded — they are referenced by path in `request.json`. The actual geometry is captured in the `.nest`. Paths exist so the request can be re-run with different parameters if the DXFs are still available.
## Consumer Integration
### MCP Server
The MCP server can expose a single `nest_and_quote` tool that takes request parameters and calls `NestRunner.RunAsync()` internally, replacing the current multi-tool orchestration for batch nesting workflows.
### Console App
The console app gains a one-liner for batch nesting:
```csharp
var response = await NestRunner.RunAsync(request, progress, token);
await response.SaveAsync(outputPath);
```
### WinForms
The WinForms app continues using the engine directly for its interactive workflow. It gains a reference to `OpenNest.Api` only for the shared `CutParameters` type used by `TimingForm` and `CutParametersForm`.
## Out of Scope
- ML-based auto-strategy detection in `DefaultNestEngine` (future, part of training work)
- `Timing` rework (will happen separately; placeholder integration for now)
- Embedding source DXFs in `.nestquote` files
- Builder pattern for `NestRequest` (C# `init` properties suffice)
- DI/instance-based `NestRunner` (add later if needed)
- Additional `NestStrategy` enum values beyond `Auto` (added with ML work)

View File

@@ -1,66 +0,0 @@
# Trim-to-Count: Replace ShrinkFiller Loop with Edge-Sorted Trim
## Problem
When a fill produces more parts than needed, `ShrinkFiller` iteratively shrinks the work area and re-fills from scratch until the count drops below target. Each iteration runs the full fill pipeline (pairs, bestfit, linear), making this expensive. Meanwhile, `DefaultNestEngine.Fill` trims excess parts with a blind `Take(N)` that ignores spatial position.
## Solution
Add `ShrinkFiller.TrimToCount` — a static method that sorts parts by their trailing edge and removes from the far end until the target count is reached. Replace the shrink loop and the blind `Take(N)` with calls to this method.
## Design
### New method: `ShrinkFiller.TrimToCount`
```csharp
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
```
- Returns input unchanged if `parts.Count <= targetCount`
- Sorts ascending by trailing edge, takes the first `targetCount` parts (keeps parts nearest to origin, discards farthest):
- `ShrinkAxis.Width` → sort ascending by `BoundingBox.Right`
- `ShrinkAxis.Height` → sort ascending by `BoundingBox.Top`
- Returns a new list (does not mutate input)
### Changes to `ShrinkFiller.Shrink`
Replace the iterative shrink loop:
1. Fill once using existing `EstimateStartBox` + fallback logic (unchanged)
2. If count exceeds `shrinkTarget`, call `TrimToCount(parts, shrinkTarget, axis)`
3. Measure dimension from trimmed result via existing `MeasureDimension`
4. Report progress once after trim
5. Return `ShrinkResult`
Parameters removed from `Shrink`: `maxIterations` (no loop). The `spacing` parameter is kept (used by `EstimateStartBox`). `CancellationToken` is kept in the signature for API consistency even though the loop no longer uses it.
### Changes to `DefaultNestEngine.Fill`
Replace line 55-56:
```csharp
// Before:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
// After:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
```
Defaults to `ShrinkAxis.Width` (trim by right edge) since this is the natural "end of nest" direction outside of a shrink context.
## Design Decisions
- **Axis-aware trimming**: Height shrink trims by top edge, width shrink trims by right edge. This respects the strip direction.
- **No pair integrity**: Trimming may split interlocking pairs. This is acceptable because if the layout is suboptimal, a better candidate will replace it during evaluation.
- **No edge spacing concerns**: The new dimension is simply the max edge of remaining parts. No snapping to spacing increments.
- **`MeasureDimension` unchanged**: It measures the occupied extent of remaining parts relative to `box.X`/`box.Y` (the work area origin). This works correctly after trimming.
- **`EstimateStartBox` preserved**: It was designed to accelerate the iterative loop, which is now gone. It still helps by producing a smaller starting fill, but could be simplified in a future pass.
- **Behavioral trade-off**: The shrink loop found the smallest box fitting N parts; trim-to-count reports the actual extent of the N nearest parts, which may be slightly less tight if there are gaps. In practice this is negligible since fill algorithms pack densely.
## Files Changed
- `OpenNest.Engine/Fill/ShrinkFiller.cs` — add `TrimToCount`, replace shrink loop, remove `maxIterations`
- `OpenNest.Engine/DefaultNestEngine.cs` — replace `Take(N)` with `TrimToCount`
- `OpenNest.Tests/ShrinkFillerTests.cs` — delete `Shrink_RespectsMaxIterations` test (concept no longer exists), update remaining tests, add `TrimToCount` tests

View File

@@ -1,129 +0,0 @@
# NFP-Based Best-Fit Strategy
## Problem
The current best-fit pair generation uses `RotationSlideStrategy`, which samples Part2 positions by sliding it toward Part1 from 4 directions at discrete step sizes. This is brute-force: more precision requires more samples, it can miss optimal interlocking positions between steps, and it generates hundreds of candidates per rotation angle.
## Solution
Replace the slide-based sampling with NFP (No-Fit Polygon) computation. The NFP of two polygons gives the exact mathematical boundary of all valid positions where Part2 can touch Part1 without overlapping. Every point on that boundary is a guaranteed-valid candidate offset.
## Approach
Implement `NfpSlideStrategy : IBestFitStrategy` that plugs into the existing `BestFitFinder` pipeline. No changes to `PairEvaluator`, `BestFitFilter`, `BestFitResult`, tiling, or caching.
## Design
### New class: `NfpSlideStrategy`
**Location:** `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`
**Implements:** `IBestFitStrategy`
**Constructor parameters:**
- `double part2Rotation` — rotation angle for Part2 (same as `RotationSlideStrategy`)
- `int type` — strategy type id (same as `RotationSlideStrategy`)
- `string description` — human-readable description
- `Polygon stationaryPoly` (optional) — pre-extracted stationary polygon to avoid redundant extraction across rotation angles
**`GenerateCandidates(Drawing drawing, double spacing, double stepSize)`:**
1. Extract perimeter polygon from the drawing inflated by `spacing / 2` using `PolygonHelper.ExtractPerimeterPolygon` (shared helper, extracted from `AutoNester`)
2. If polygon extraction fails (null), return empty list
3. Create a rotated copy of the polygon at `part2Rotation` using `PolygonHelper.RotatePolygon` (also extracted)
4. Compute `NoFitPolygon.Compute(stationaryPoly, orbitingPoly)` — single call
5. If the NFP is null or has fewer than 3 vertices, return empty list
6. Convert NFP vertices from polygon-space to Part-space (see Coordinate Correction below)
7. Walk the NFP boundary:
- Each vertex becomes a `PairCandidate` with that vertex as `Part2Offset`
- For edges longer than `stepSize`, add intermediate sample points starting at `stepSize` from the edge start, exclusive of endpoints (to avoid duplicates with vertex candidates)
- Skip the closing vertex if the polygon is closed (first == last)
8. Part1 is always at rotation 0, matching existing `RotationSlideStrategy` behavior
9. Return the candidates list
### Coordinate correction
`ExtractPerimeterPolygon` inflates by `halfSpacing` and re-normalizes to origin based on the inflated bounding box. `Part.CreateAtOrigin` normalizes using the raw program bounding box — a different reference point. NFP offsets are in polygon-space and must be mapped to Part-space.
**Correction:** Compute the offset between the two reference points:
```
programOrigin = (program.BoundingBox.Left, program.BoundingBox.Bottom)
polygonOrigin = (inflatedPerimeter.BoundingBox.Left, inflatedPerimeter.BoundingBox.Bottom) → (0, 0) after normalization
correction = programOrigin - polygonOrigin
```
Since both are normalized to (0,0), the actual correction is the difference between where the inflated perimeter's bottom-left sits relative to the program's bottom-left *before* normalization. In practice:
- The program bbox includes all entities (rapid moves, all layers)
- The perimeter polygon only uses non-rapid cut geometry, inflated outward
`PolygonHelper` will compute this correction vector once per drawing and return it alongside the polygon. `NfpSlideStrategy` applies it to each NFP vertex before creating `PairCandidate` offsets.
### Floating-point boundary tolerance
NFP boundary positions represent exact touching. Floating-point imprecision may cause `PairEvaluator`'s shape-intersection test to falsely detect overlap at valid boundary points. The `PairEvaluator` overlap check serves as a safety net — a few boundary positions may be filtered out, but the best results should remain valid since we sample many boundary points.
### Shared helper: `PolygonHelper`
**Location:** `OpenNest.Engine/BestFit/PolygonHelper.cs`
**Static methods extracted from `AutoNester`:**
- `ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)` — extracts and inflates the perimeter polygon
- `RotatePolygon(Polygon polygon, double angle)` — creates a rotated copy normalized to origin
After extraction, `AutoNester` delegates to these methods to avoid duplication.
### Changes to `BestFitFinder.BuildStrategies`
Replace `RotationSlideStrategy` instances with `NfpSlideStrategy` instances. Same rotation angles from `GetRotationAngles(drawing)`, different strategy class. No `ISlideComputer` dependency needed.
Extract the stationary polygon once and pass it to each strategy to avoid redundant computation (strategies run in `Parallel.ForEach`):
```csharp
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
// Extract stationary polygon once, shared across all rotation strategies.
var stationaryPoly = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
foreach (var angle in angles)
{
var desc = $"{Angle.ToDegrees(angle):F1} deg NFP";
strategies.Add(new NfpSlideStrategy(angle, type++, desc, stationaryPoly));
}
return strategies;
}
```
Note: spacing inflation is applied inside `GenerateCandidates` since it depends on the `spacing` parameter, not at strategy construction time.
### No changes required
- `PairEvaluator` — still evaluates candidates (overlap check becomes redundant but harmless and fast)
- `BestFitFilter` — still filters results by aspect ratio, plate fit, etc.
- `BestFitResult` — unchanged
- `BestFitCache` — unchanged
- Tiling pipeline — unchanged
- `PairsFillStrategy` — unchanged
## Edge Sampling
NFP vertices alone may miss optimal positions along long straight edges. For each edge of the NFP polygon where `edgeLength > stepSize`, interpolate additional points at `stepSize` intervals. This reuses the existing `stepSize` parameter meaningfully — it controls resolution along NFP edges rather than grid spacing.
## Files Changed
| File | Change |
|------|--------|
| `OpenNest.Engine/BestFit/NfpSlideStrategy.cs` | New — `IBestFitStrategy` implementation |
| `OpenNest.Engine/BestFit/PolygonHelper.cs` | New — shared polygon extraction/rotation |
| `OpenNest.Engine/Nfp/AutoNester.cs` | Delegate to `PolygonHelper` methods |
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | Swap `RotationSlideStrategy` for `NfpSlideStrategy` in `BuildStrategies` |
## What This Does NOT Change
- The `RotationSlideStrategy` class stays in the codebase (not deleted) in case GPU slide computation is still wanted
- The `ISlideComputer` / GPU pipeline remains available
- `BestFitFinder` constructor still accepts `ISlideComputer` but it won't be passed to NFP strategies (they don't need it)