docs: revise lead-in UI spec with external/internal split and LayerType tagging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# GPU Pair Evaluator — Overlap Detection Bug
|
||||
|
||||
**Date**: 2026-03-10
|
||||
**Status**: RESOLVED — commit b55aa7a
|
||||
|
||||
## Problem
|
||||
|
||||
The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization).
|
||||
|
||||
## Root Cause (identified but not yet fully fixed)
|
||||
|
||||
The bitmap coordinate system doesn't match the `Part2Offset` coordinate system.
|
||||
|
||||
### How Part2Offset is computed
|
||||
`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which:
|
||||
1. Clones the drawing's program
|
||||
2. Rotates it
|
||||
3. Calls `Program.BoundingBox()` to get the bbox
|
||||
4. Offsets by `-bbox.Location` to normalize to origin
|
||||
|
||||
`Part2Offset` is the final position of Part2 in this **normalized** coordinate space.
|
||||
|
||||
### How bitmaps are rasterized
|
||||
`PartBitmap.FromDrawing` / `FromDrawingRotated`:
|
||||
1. Extracts closed polygons from the drawing (filters out rapids, open shapes)
|
||||
2. Rotates them (for B)
|
||||
3. Rasterizes with `OriginX/Y = polygon min`
|
||||
|
||||
### The mismatch
|
||||
`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means:
|
||||
- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change
|
||||
- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3
|
||||
- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space
|
||||
|
||||
For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0).
|
||||
|
||||
## What we tried
|
||||
|
||||
### Attempt 1: BlitPair approach (correct but too slow)
|
||||
- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid
|
||||
- Eliminated all offset math from the kernel (trivial element-wise AND)
|
||||
- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds.
|
||||
|
||||
### Attempt 2: Integer offsets with gap correction
|
||||
- Kept shared-bitmap approach (one A + one B per rotation group)
|
||||
- Changed offsets from `float` to `int` with `Math.Round()` on CPU
|
||||
- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location`
|
||||
- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice.
|
||||
|
||||
### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state)
|
||||
- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()`
|
||||
- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin`
|
||||
- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize`
|
||||
- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap.
|
||||
|
||||
## Current state of code
|
||||
|
||||
### Files modified
|
||||
|
||||
**`OpenNest.Gpu/PartBitmap.cs`**:
|
||||
- Added `BlitPair()` static method (from attempt 1, still present but unused)
|
||||
- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize
|
||||
- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes
|
||||
|
||||
**`OpenNest.Gpu/GpuPairEvaluator.cs`**:
|
||||
- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated`
|
||||
- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU
|
||||
- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1`
|
||||
- `PadBitmap` helper restored
|
||||
- Removed the old `NestingKernel` with float offsets
|
||||
|
||||
**`OpenNest/Forms/MainForm.cs`**:
|
||||
- Added `using OpenNest.Engine.BestFit;`
|
||||
- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);`
|
||||
|
||||
## Next steps to debug
|
||||
|
||||
1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate:
|
||||
- Print bitmapA: OriginX, OriginY, Width, Height
|
||||
- Print bitmapB: OriginX, OriginY, Width, Height
|
||||
- Print the computed integer offset
|
||||
- Print the overlap count from the kernel
|
||||
- Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate
|
||||
|
||||
2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**:
|
||||
- `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()`
|
||||
- If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong
|
||||
- Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)?
|
||||
|
||||
3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match.
|
||||
|
||||
4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong.
|
||||
|
||||
5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A.
|
||||
|
||||
## Performance notes
|
||||
- CPU evaluator: 25.0s compute, 5954 kept, correct results
|
||||
- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps
|
||||
- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group
|
||||
- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group
|
||||
|
||||
## Key files to reference
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator
|
||||
- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference)
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values
|
||||
- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()`
|
||||
- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0)
|
||||
- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in
|
||||
- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up
|
||||
@@ -0,0 +1,367 @@
|
||||
# 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
|
||||
@@ -0,0 +1,281 @@
|
||||
# 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
@@ -0,0 +1,867 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,462 @@
|
||||
# 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)`
|
||||
@@ -0,0 +1,350 @@
|
||||
# Helper Class Decomposition
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes.
|
||||
|
||||
**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring.
|
||||
|
||||
**Tech Stack:** .NET 8, C# 12
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| New File | Namespace | Responsibility | Methods Moved |
|
||||
|----------|-----------|----------------|---------------|
|
||||
| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` |
|
||||
| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` |
|
||||
| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` |
|
||||
| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads |
|
||||
| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` |
|
||||
| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) |
|
||||
|
||||
**Files modified (call-site updates):**
|
||||
|
||||
| File | Methods Referenced |
|
||||
|------|--------------------|
|
||||
| `OpenNest.Core/Plate.cs` | `RoundUpToNearest` → `Rounding.RoundUpToNearest` |
|
||||
| `OpenNest.IO/DxfImporter.cs` | `Optimize` → `GeometryOptimizer.Optimize` |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | `Optimize` → `GeometryOptimizer.Optimize`, `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Drawing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Timing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Circle.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Line.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest/LayoutPart.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection` → `SpatialQuery.*` |
|
||||
| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder
|
||||
|
||||
### Task 1: Extract Rounding to OpenNest.Math
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Math/Rounding.cs`
|
||||
- Modify: `OpenNest.Core/Plate.cs:415-416`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 14–45)
|
||||
|
||||
- [ ] **Step 1: Create `Rounding.cs`**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
public static class Rounding
|
||||
{
|
||||
public static double RoundDownToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundUpToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update call site in `Plate.cs`**
|
||||
|
||||
Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present.
|
||||
|
||||
- [ ] **Step 3: Remove three rounding methods from `Helper.cs`**
|
||||
|
||||
Delete lines 14–45 (the three methods and their XML doc comments).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Rounding from Helper to OpenNest.Math
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract GeometryOptimizer
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs`
|
||||
- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 47–237)
|
||||
|
||||
- [ ] **Step 1: Create `GeometryOptimizer.cs`**
|
||||
|
||||
Move these 6 methods (preserving exact code):
|
||||
- `Optimize(IList<Arc>)`
|
||||
- `Optimize(IList<Line>)`
|
||||
- `TryJoinLines`
|
||||
- `TryJoinArcs`
|
||||
- `GetCollinearLines` (private extension method)
|
||||
- `GetCoradialArs` (private extension method)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `DxfImporter.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`.
|
||||
- `Shape.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed.
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract GeometryOptimizer from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract ShapeBuilder
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs`
|
||||
- Modify: 11 files (see call-site table above for `GetShapes` callers)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 239–378)
|
||||
|
||||
- [ ] **Step 1: Create `ShapeBuilder.cs`**
|
||||
|
||||
Move these 2 methods:
|
||||
- `GetShapes(IEnumerable<Entity>)` — public
|
||||
- `GetConnected(Vector, IEnumerable<Entity>)` — internal
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update all call sites**
|
||||
|
||||
Replace `Helper.GetShapes` → `ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace.
|
||||
|
||||
Files to update:
|
||||
- `OpenNest.Core/Drawing.cs`
|
||||
- `OpenNest.Core/Timing.cs`
|
||||
- `OpenNest.Core/Converters/ConvertGeometry.cs`
|
||||
- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace)
|
||||
- `OpenNest/LayoutPart.cs`
|
||||
- `OpenNest/Actions/ActionSetSequence.cs`
|
||||
- `OpenNest.Gpu/PartBitmap.cs`
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs`
|
||||
- `OpenNest.Engine/RotationAnalysis.cs`
|
||||
- `OpenNest.Engine/BestFit/BestFitFinder.cs`
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract ShapeBuilder from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Intersect + PartGeometry
|
||||
|
||||
### Task 4: Extract Intersect
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/Intersect.cs`
|
||||
- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 380–742)
|
||||
|
||||
- [ ] **Step 1: Create `Intersect.cs`**
|
||||
|
||||
Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`.
|
||||
|
||||
All methods keep their existing access modifiers (`internal` for most, none are `public`).
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites in geometry types**
|
||||
|
||||
All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects` → `Intersect.Intersects` in:
|
||||
- `Arc.cs` (10 calls)
|
||||
- `Circle.cs` (10 calls)
|
||||
- `Line.cs` (8 calls)
|
||||
- `Shape.cs` (12 calls, including the internal offset usage at line 537)
|
||||
- `Polygon.cs` (10 calls)
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Intersect from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extract PartGeometry
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/PartGeometry.cs`
|
||||
- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 744–858)
|
||||
|
||||
- [ ] **Step 1: Create `PartGeometry.cs`**
|
||||
|
||||
Move these 5 methods:
|
||||
- `GetPartLines(Part, double)` — public
|
||||
- `GetPartLines(Part, PushDirection, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, PushDirection, double)` — public
|
||||
- `GetDirectionalLines(Polygon, PushDirection)` — private
|
||||
|
||||
Namespace: `OpenNest`. Class: `public static class PartGeometry`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines` → `PartGeometry.*`
|
||||
- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines` → `PartGeometry.GetOffsetPartLines`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract PartGeometry from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: SpatialQuery + Cleanup
|
||||
|
||||
### Task 6: Extract SpatialQuery
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/SpatialQuery.cs`
|
||||
- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 860–1462, all remaining methods)
|
||||
|
||||
- [ ] **Step 1: Create `SpatialQuery.cs`**
|
||||
|
||||
Move all remaining methods (14 total):
|
||||
- `RayEdgeDistance(Vector, Line, PushDirection)` — private
|
||||
- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]`
|
||||
- `DirectionalDistance(List<Line>, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance(List<Line>, double, double, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `FlattenLines(List<Line>)` — public
|
||||
- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `OppositeDirection(PushDirection)` — public
|
||||
- `IsHorizontalDirection(PushDirection)` — public
|
||||
- `EdgeDistance(Box, Box, PushDirection)` — public
|
||||
- `DirectionToOffset(PushDirection, double)` — public
|
||||
- `DirectionalGap(Box, Box, PushDirection)` — public
|
||||
- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods)
|
||||
- `GetLargestBoxVertically/Horizontally` — public (2 methods)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
Replace `Helper.*` → `SpatialQuery.*` and add `using OpenNest.Geometry;` where needed:
|
||||
- `OpenNest.Engine/Compactor.cs` — `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset`
|
||||
- `OpenNest.Engine/FillLinear.cs` — `DirectionalDistance`, `OppositeDirection`
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — `FlattenLines`, `OppositeDirection`, `OneWayDistance`
|
||||
- `OpenNest/Actions/ActionClone.cs` — `GetLargestBoxVertically`, `GetLargestBoxHorizontally`
|
||||
- `OpenNest/Actions/ActionSelectArea.cs` — `GetLargestBoxHorizontally`, `GetLargestBoxVertically`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
At this point `Helper.cs` should be empty (just the class wrapper and usings).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract SpatialQuery from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Delete Helper.cs
|
||||
|
||||
**Files:**
|
||||
- Delete: `OpenNest.Core/Helper.cs`
|
||||
|
||||
- [ ] **Step 1: Delete the empty `Helper.cs` file**
|
||||
|
||||
- [ ] **Step 2: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with zero errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
refactor: remove empty Helper class
|
||||
```
|
||||
@@ -0,0 +1,588 @@
|
||||
# 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
@@ -0,0 +1,218 @@
|
||||
# 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
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,329 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,260 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user