diff --git a/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md new file mode 100644 index 0000000..b80e3f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md @@ -0,0 +1,1003 @@ +# ML Angle Pruning 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:** Instrument the nesting engine to collect per-angle results during training runs, store them in the database, and add a `Description` field to the progress window for real-time visibility. + +**Architecture:** The engine gains a `ForceFullAngleSweep` flag and an `AngleResults` collection populated during `FindBestFill`. `BruteForceResult` passes these through. The training project stores them in a new `AngleResults` SQLite table. The WinForms progress form gains a description row. ONNX inference (`AnglePredictor`) is a separate future task — it requires trained model data that doesn't exist yet. + +**Tech Stack:** C# / .NET 8, EF Core SQLite, WinForms + +**Spec:** `docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md` + +--- + +## Chunk 1: Engine Instrumentation + +### Task 1: Add `AngleResult` class and `Description` to `NestProgress` + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs` + +- [ ] **Step 1: Add `AngleResult` class and `Description` property** + +Add to `OpenNest.Engine/NestProgress.cs`, after the `PhaseResult` class: + +```csharp +public class AngleResult +{ + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } +} +``` + +Add to `NestProgress`: + +```csharp +public string Description { get; set; } +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs +git commit -m "feat(engine): add AngleResult class and Description to NestProgress" +``` + +--- + +### Task 2: Add `ForceFullAngleSweep` and `AngleResults` to `NestEngine` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add properties to `NestEngine`** + +Add after the existing `PhaseResults` property (line 29): + +```csharp +public bool ForceFullAngleSweep { get; set; } +public List AngleResults { get; } = new(); +``` + +- [ ] **Step 2: Clear `AngleResults` at the start of `Fill`** + +In `Fill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 52), add `AngleResults.Clear();` right after `PhaseResults.Clear();` (line 55). + +- [ ] **Step 3: Force full angle sweep when flag is set** + +In `FindBestFill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 163), after the existing narrow-work-area angle expansion block (lines 182-190), add a second block: + +```csharp +if (ForceFullAngleSweep) +{ + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); + } +} +``` + +Also apply the same pattern to the non-progress overload `FindBestFill(NestItem item, Box workArea)` (line 84) — add the same `ForceFullAngleSweep` block after line 115. + +- [ ] **Step 4: Collect per-angle results in the progress overload** + +In `FindBestFill` (progress overload, line 163), inside the `Parallel.ForEach` over angles (lines 209-221), replace the parallel body to also collect angle results. Use a `ConcurrentBag` alongside the existing `linearBag`: + +Before the `Parallel.ForEach` (after line 207), add: +```csharp +var angleBag = new System.Collections.Concurrent.ConcurrentBag(); +``` + +Inside the parallel body, after computing `h` and `v`, add: +```csharp +var angleDeg = Angle.ToDegrees(angle); +if (h != null && h.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); +if (v != null && v.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); +``` + +After the `Parallel.ForEach` completes and `linearSw.Stop()` (line 222), add: +```csharp +AngleResults.AddRange(angleBag); +``` + +- [ ] **Step 5: Add `ForceFullAngleSweep` to non-progress overload (angle sweep only, no result collection)** + +The non-progress `FindBestFill` at line 84 is only called from `TryStripRefill` for sub-strip fills. Do NOT collect angle results there — sub-strip data would contaminate the main plate's results. Only add the `ForceFullAngleSweep` block (same as Step 3) after line 115. + +Do NOT add `AngleResults.Clear()` to `Fill(NestItem, Box)` at line 41 — it delegates to the progress overload at line 52 which already clears `AngleResults`. + +- [ ] **Step 6: Report per-angle progress descriptions** + +In the progress overload's `Parallel.ForEach` body, after computing h and v for an angle, report progress with a description. Since we're inside a parallel loop, use a simple approach — report after computing each angle: + +```csharp +var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; +var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); +progress?.Report(new NestProgress +{ + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {angleDeg:F0}\u00b0 {bestDir} - {bestCount} parts" +}); +``` + +- [ ] **Step 7: Report per-candidate progress in Pairs phase** + +In `FillWithPairs(NestItem item, Box workArea, CancellationToken token)` (line 409), inside the `Parallel.For` body (line 424), add progress reporting. Since `FillWithPairs` doesn't have access to the `progress` parameter, this requires adding a progress parameter. + +Change the signature of the cancellation-token overload at line 409 to: + +```csharp +private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) +``` + +Update the call site at line 194 (`FindBestFill` progress overload) to pass `progress`: + +```csharp +var pairResult = FillWithPairs(item, workArea, token, progress); +``` + +Inside the `Parallel.For` body (line 424), after computing `filled`, add: + +```csharp +progress?.Report(new NestProgress +{ + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" +}); +``` + +- [ ] **Step 8: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 9: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add ForceFullAngleSweep flag and per-angle result collection" +``` + +--- + +### Task 3: Add `AngleResults` to `BruteForceResult` and `BruteForceRunner` + +**Files:** +- Modify: `OpenNest.Engine/ML/BruteForceRunner.cs` + +- [ ] **Step 1: Add `AngleResults` property to `BruteForceResult`** + +Add to `BruteForceResult` class (after `ThirdPlaceTimeMs`): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +- [ ] **Step 2: Populate `AngleResults` in `BruteForceRunner.Run`** + +In the `return new BruteForceResult` block (line 47), add: + +```csharp +AngleResults = engine.AngleResults.ToList(), +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs +git commit -m "feat(engine): pass per-angle results through BruteForceResult" +``` + +--- + +## Chunk 2: Training Database & Runner + +### Task 4: Add `TrainingAngleResult` EF Core entity + +**Files:** +- Create: `OpenNest.Training/Data/TrainingAngleResult.cs` +- Modify: `OpenNest.Training/Data/TrainingDbContext.cs` + +- [ ] **Step 1: Create the entity class** + +Create `OpenNest.Training/Data/TrainingAngleResult.cs`: + +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("AngleResults")] + public class TrainingAngleResult + { + [Key] + public long Id { get; set; } + + public long RunId { get; set; } + public double AngleDeg { get; set; } + public string Direction { get; set; } + public int PartCount { get; set; } + + [ForeignKey(nameof(RunId))] + public TrainingRun Run { get; set; } + } +} +``` + +- [ ] **Step 2: Add navigation property to `TrainingRun`** + +In `OpenNest.Training/Data/TrainingRun.cs`, add at the end of the class (after the `Part` navigation property): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +Add `using System.Collections.Generic;` to the top if not already present. + +- [ ] **Step 3: Register `DbSet` and configure in `TrainingDbContext`** + +In `OpenNest.Training/Data/TrainingDbContext.cs`: + +Add DbSet: +```csharp +public DbSet AngleResults { get; set; } +``` + +Add configuration in `OnModelCreating`: +```csharp +modelBuilder.Entity(e => +{ + e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid"); + e.HasOne(a => a.Run) + .WithMany(r => r.AngleResults) + .HasForeignKey(a => a.RunId); +}); +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Training/Data/TrainingAngleResult.cs OpenNest.Training/Data/TrainingRun.cs OpenNest.Training/Data/TrainingDbContext.cs +git commit -m "feat(training): add TrainingAngleResult entity and DbSet" +``` + +--- + +### Task 5: Extend `TrainingDatabase` for angle result storage and migration + +**Files:** +- Modify: `OpenNest.Training/TrainingDatabase.cs` + +- [ ] **Step 1: Create `AngleResults` table in `MigrateSchema`** + +Add to the end of the `MigrateSchema` method, after the existing column migration loop: + +```csharp +try +{ + _db.Database.ExecuteSqlRaw(@" + CREATE TABLE IF NOT EXISTS AngleResults ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId INTEGER NOT NULL, + AngleDeg REAL NOT NULL, + Direction TEXT NOT NULL, + PartCount INTEGER NOT NULL, + FOREIGN KEY (RunId) REFERENCES Runs(Id) + )"); + _db.Database.ExecuteSqlRaw( + "CREATE INDEX IF NOT EXISTS idx_angleresults_runid ON AngleResults (RunId)"); +} +catch +{ + // Table already exists or other non-fatal issue. +} +``` + +- [ ] **Step 2: Extend `AddRun` to accept and batch-insert angle results** + +Change the `AddRun` signature to accept angle results: + +```csharp +public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List angleResults = null) +``` + +Add `using OpenNest;` at the top if not already present (for `AngleResult` type). + +After `_db.Runs.Add(run);` and before `_db.SaveChanges();`, add: + +```csharp +if (angleResults != null && angleResults.Count > 0) +{ + foreach (var ar in angleResults) + { + _db.AngleResults.Add(new Data.TrainingAngleResult + { + Run = run, + AngleDeg = ar.AngleDeg, + Direction = ar.Direction.ToString(), + PartCount = ar.PartCount + }); + } +} +``` + +The single `SaveChanges()` call will batch-insert both the run and all angle results in one transaction. + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/TrainingDatabase.cs +git commit -m "feat(training): add AngleResults table migration and batch insert" +``` + +--- + +### Task 6: Wire up training runner to collect angle data + +**Files:** +- Modify: `OpenNest.Training/Program.cs` + +- [ ] **Step 1: Set `ForceFullAngleSweep` on the engine** + +In `Program.cs`, inside the `foreach (var size in sheetSuite)` loop, after creating the `BruteForceRunner.Run` call (line 203), we need to change the approach. Currently `BruteForceRunner.Run` creates the engine internally. We need to modify `BruteForceRunner.Run` to accept the `ForceFullAngleSweep` flag. + +Actually, looking at the code, `BruteForceRunner.Run` creates a `NestEngine` internally (line 29 of BruteForceRunner.cs). The cleanest approach: add an overload or optional parameter. + +In `OpenNest.Engine/ML/BruteForceRunner.cs`, change the `Run` method signature to: + +```csharp +public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false) +``` + +And set it on the engine after creation: + +```csharp +var engine = new NestEngine(plate); +engine.ForceFullAngleSweep = forceFullAngleSweep; +``` + +- [ ] **Step 2: Pass `forceFullAngleSweep = true` from the training runner** + +In `OpenNest.Training/Program.cs`, change the `BruteForceRunner.Run` call (line 203) to: + +```csharp +var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true); +``` + +- [ ] **Step 3: Pass angle results to `AddRun`** + +Change the `db.AddRun` call (line 266) to: + +```csharp +db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults); +``` + +- [ ] **Step 4: Add angle result count to console output** + +In the console output line (line 223), append angle result count. Change: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}]"); +``` + +To: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}"); +``` + +- [ ] **Step 5: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs OpenNest.Training/Program.cs +git commit -m "feat(training): enable forced full angle sweep and store per-angle results" +``` + +--- + +## Chunk 3: Progress Window Enhancement + +### Task 7: Add `Description` row to the progress form + +**Files:** +- Modify: `OpenNest/Forms/NestProgressForm.Designer.cs` +- Modify: `OpenNest/Forms/NestProgressForm.cs` + +- [ ] **Step 1: Add description label and value controls in Designer** + +In `NestProgressForm.Designer.cs`, add field declarations alongside the existing ones (after `elapsedValue` on line 231): + +```csharp +private System.Windows.Forms.Label descriptionLabel; +private System.Windows.Forms.Label descriptionValue; +``` + +In `InitializeComponent()`, add control creation (after the `elapsedValue` creation, around line 43): + +```csharp +this.descriptionLabel = new System.Windows.Forms.Label(); +this.descriptionValue = new System.Windows.Forms.Label(); +``` + +Add the description row to the table. Exact changes: +- Line 71: Change `this.table.RowCount = 6;` to `this.table.RowCount = 7;` +- After line 77 (last `RowStyles.Add`), add: `this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));` +- After line 66 (elapsedValue table.Controls.Add), add the description controls to the table +- Line 197: Change `this.ClientSize = new System.Drawing.Size(264, 207);` to `this.ClientSize = new System.Drawing.Size(264, 230);` (taller to fit the new row) + +Add table controls (after the elapsed row controls): +```csharp +this.table.Controls.Add(this.descriptionLabel, 0, 6); +this.table.Controls.Add(this.descriptionValue, 1, 6); +``` + +Configure the labels (in the label configuration section, after elapsedValue config): +```csharp +// descriptionLabel +this.descriptionLabel.AutoSize = true; +this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); +this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4); +this.descriptionLabel.Name = "descriptionLabel"; +this.descriptionLabel.Text = "Detail:"; +// descriptionValue +this.descriptionValue.AutoSize = true; +this.descriptionValue.Margin = new System.Windows.Forms.Padding(4); +this.descriptionValue.Name = "descriptionValue"; +this.descriptionValue.Text = "\u2014"; +``` + +Add field declarations after `elapsedValue` (line 230), before `stopButton` (line 231): + +- [ ] **Step 2: Display `Description` in `UpdateProgress`** + +In `NestProgressForm.cs`, in the `UpdateProgress` method (line 30), add after the existing updates: + +```csharp +if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest/Forms/NestProgressForm.cs OpenNest/Forms/NestProgressForm.Designer.cs +git commit -m "feat(ui): add description row to nest progress form" +``` + +--- + +## Chunk 4: ONNX Inference Scaffolding + +### Task 8: Add `Microsoft.ML.OnnxRuntime` NuGet package + +**Files:** +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +- [ ] **Step 1: Add the package reference** + +Add to `OpenNest.Engine/OpenNest.Engine.csproj` inside the existing `` (or create a new one for packages): + +```xml + + + +``` + +- [ ] **Step 2: Restore and build** + +Run: `dotnet restore OpenNest.Engine && dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/OpenNest.Engine.csproj +git commit -m "chore(engine): add Microsoft.ML.OnnxRuntime package" +``` + +--- + +### Task 9: Create `AnglePredictor` with ONNX inference + +**Files:** +- Create: `OpenNest.Engine/ML/AnglePredictor.cs` + +- [ ] **Step 1: Create the predictor class** + +Create `OpenNest.Engine/ML/AnglePredictor.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using OpenNest.Math; + +namespace OpenNest.Engine.ML +{ + public static class AnglePredictor + { + private static InferenceSession _session; + private static bool _loadAttempted; + private static readonly object _lock = new(); + + public static List PredictAngles( + PartFeatures features, double sheetWidth, double sheetHeight, + double threshold = 0.3) + { + var session = GetSession(); + if (session == null) + return null; + + try + { + var input = new float[11]; + input[0] = (float)features.Area; + input[1] = (float)features.Convexity; + input[2] = (float)features.AspectRatio; + input[3] = (float)features.BoundingBoxFill; + input[4] = (float)features.Circularity; + input[5] = (float)features.PerimeterToAreaRatio; + input[6] = features.VertexCount; + input[7] = (float)sheetWidth; + input[8] = (float)sheetHeight; + input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0)); + input[10] = (float)(features.Area / (sheetWidth * sheetHeight)); + + var tensor = new DenseTensor(input, new[] { 1, 11 }); + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("features", tensor) + }; + + using var results = session.Run(inputs); + var probabilities = results.First().AsEnumerable().ToArray(); + + var angles = new List<(double angleDeg, float prob)>(); + for (var i = 0; i < 36 && i < probabilities.Length; i++) + { + if (probabilities[i] >= threshold) + angles.Add((i * 5.0, probabilities[i])); + } + + // Minimum 3 angles — take top by probability if fewer pass threshold. + if (angles.Count < 3) + { + angles = probabilities + .Select((p, i) => (angleDeg: i * 5.0, prob: p)) + .OrderByDescending(x => x.prob) + .Take(3) + .ToList(); + } + + // Always include 0 and 90 as safety fallback. + var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList(); + + if (!result.Any(a => a.IsEqualTo(0))) + result.Add(0); + if (!result.Any(a => a.IsEqualTo(Angle.HalfPI))) + result.Add(Angle.HalfPI); + + return result; + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}"); + return null; + } + } + + private static InferenceSession GetSession() + { + if (_loadAttempted) + return _session; + + lock (_lock) + { + if (_loadAttempted) + return _session; + + _loadAttempted = true; + + try + { + var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location); + var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx"); + + if (!File.Exists(modelPath)) + { + Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}"); + return null; + } + + _session = new InferenceSession(modelPath); + Debug.WriteLine("[AnglePredictor] Model loaded successfully"); + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}"); + } + + return _session; + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded (model file doesn't exist yet — that's expected, `GetSession` returns null gracefully) + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/ML/AnglePredictor.cs +git commit -m "feat(engine): add AnglePredictor ONNX inference class" +``` + +--- + +### Task 10: Wire `AnglePredictor` into `NestEngine.FindBestFill` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add ML angle prediction to the progress overload** + +In `FindBestFill` (progress overload), after the narrow-work-area angle expansion block and after the `ForceFullAngleSweep` block, add ML prediction logic. This replaces the full sweep when the model is available: + +```csharp +// When the work area triggers a full sweep (and we're not forcing it for training), +// try ML angle prediction to reduce the sweep. +if (!ForceFullAngleSweep && angles.Count > 2) +{ + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + // Use predicted angles, but always keep bestRotation and bestRotation + 90. + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } +} +``` + +Add `using OpenNest.Engine.ML;` at the top of the file if not already present. + +- [ ] **Step 2: Apply the same pattern to the non-progress overload** + +Add the identical ML prediction block to `FindBestFill(NestItem item, Box workArea)` after its `ForceFullAngleSweep` block. + +- [ ] **Step 3: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): integrate AnglePredictor into FindBestFill angle selection" +``` + +--- + +## Chunk 5: Training Notebook Scaffolding + +### Task 11: Create Python training notebook and requirements + +**Files:** +- Create: `OpenNest.Training/notebooks/requirements.txt` +- Create: `OpenNest.Training/notebooks/train_angle_model.ipynb` + +- [ ] **Step 1: Create requirements.txt** + +Create `OpenNest.Training/notebooks/requirements.txt`: + +``` +pandas>=2.0 +scikit-learn>=1.3 +xgboost>=2.0 +onnxmltools>=1.12 +skl2onnx>=1.16 +matplotlib>=3.7 +jupyter>=1.0 +``` + +- [ ] **Step 2: Create training notebook skeleton** + +Create `OpenNest.Training/notebooks/train_angle_model.ipynb` as a Jupyter notebook with the following cells: + +Cell 1 (markdown): +``` +# Angle Prediction Model Training +Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size. + +**Input:** SQLite database from OpenNest.Training data collection runs +**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/` +``` + +Cell 2 (code): imports and setup +```python +import sqlite3 +import pandas as pd +import numpy as np +from pathlib import Path + +DB_PATH = "../OpenNestTraining.db" # Adjust to your database location +OUTPUT_PATH = "../../OpenNest.Engine/Models/angle_predictor.onnx" +COMPETITIVE_THRESHOLD = 0.95 # Angle is "competitive" if >= 95% of best +``` + +Cell 3 (code): data extraction +```python +# Extract training data from SQLite +conn = sqlite3.connect(DB_PATH) + +query = """ +SELECT + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight, r.Id as RunId, + a.AngleDeg, a.Direction, a.PartCount +FROM AngleResults a +JOIN Runs r ON a.RunId = r.Id +JOIN Parts p ON r.PartId = p.Id +WHERE a.PartCount > 0 +""" + +df = pd.read_sql_query(query, conn) +conn.close() + +print(f"Loaded {len(df)} angle result rows") +print(f"Unique runs: {df['RunId'].nunique()}") +print(f"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}") +``` + +Cell 4 (code): label generation +```python +# For each run, find best PartCount (max of H and V per angle), +# then label angles within 95% of best as positive. + +# Best count per angle per run (max of H and V) +angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index() +angle_best.columns = ['RunId', 'AngleDeg', 'BestCount'] + +# Best count per run (overall best angle) +run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index() +run_best.columns = ['RunId', 'RunBest'] + +# Merge and compute labels +labels = angle_best.merge(run_best, on='RunId') +labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int) + +# Pivot to 36-column binary label matrix +label_matrix = labels.pivot_table( + index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0 +) + +# Ensure all 36 angle columns exist (0, 5, 10, ..., 175) +all_angles = [i * 5 for i in range(36)] +for a in all_angles: + if a not in label_matrix.columns: + label_matrix[a] = 0 +label_matrix = label_matrix[all_angles] + +print(f"Label matrix: {label_matrix.shape}") +print(f"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}") +``` + +Cell 5 (code): feature engineering +```python +# Build feature matrix — one row per run +features_query = """ +SELECT DISTINCT + r.Id as RunId, p.FileName, + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight +FROM Runs r +JOIN Parts p ON r.PartId = p.Id +WHERE r.Id IN ({}) +""".format(','.join(str(x) for x in label_matrix.index)) + +conn = sqlite3.connect(DB_PATH) +features_df = pd.read_sql_query(features_query, conn) +conn.close() + +features_df = features_df.set_index('RunId') + +# Derived features +features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight'] +features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight']) + +# Filter outliers (title blocks, etc.) +mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1) +print(f"Filtering: {(~mask).sum()} outlier runs removed") +features_df = features_df[mask] +label_matrix = label_matrix.loc[features_df.index] + +feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity', + 'PerimeterToAreaRatio', 'VertexCount', + 'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio'] + +X = features_df[feature_cols].values +y = label_matrix.values + +print(f"Features: {X.shape}, Labels: {y.shape}") +``` + +Cell 6 (code): train/test split and training +```python +from sklearn.model_selection import GroupShuffleSplit +from sklearn.multioutput import MultiOutputClassifier +import xgboost as xgb + +# Split by part (all sheet sizes for a part stay in the same split) +groups = features_df['FileName'] +splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) +train_idx, test_idx = next(splitter.split(X, y, groups)) + +X_train, X_test = X[train_idx], X[test_idx] +y_train, y_test = y[train_idx], y[test_idx] + +print(f"Train: {len(train_idx)}, Test: {len(test_idx)}") + +# Train XGBoost multi-label classifier +base_clf = xgb.XGBClassifier( + n_estimators=200, + max_depth=6, + learning_rate=0.1, + use_label_encoder=False, + eval_metric='logloss', + random_state=42 +) + +clf = MultiOutputClassifier(base_clf, n_jobs=-1) +clf.fit(X_train, y_train) +print("Training complete") +``` + +Cell 7 (code): evaluation +```python +from sklearn.metrics import recall_score, precision_score +import matplotlib.pyplot as plt + +y_pred = clf.predict(X_test) +y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T + +# Per-angle metrics +recalls = [] +precisions = [] +for i in range(36): + if y_test[:, i].sum() > 0: + recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0)) + precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0)) + +print(f"Mean recall: {np.mean(recalls):.3f}") +print(f"Mean precision: {np.mean(precisions):.3f}") + +# Average angles predicted per run +avg_predicted = y_pred.sum(axis=1).mean() +print(f"Avg angles predicted per run: {avg_predicted:.1f}") + +# Plot +fig, axes = plt.subplots(1, 2, figsize=(12, 4)) +axes[0].bar(range(len(recalls)), recalls) +axes[0].set_title('Recall per Angle Bin') +axes[0].set_xlabel('Angle (5-deg bins)') +axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%') +axes[0].legend() + +axes[1].bar(range(len(precisions)), precisions) +axes[1].set_title('Precision per Angle Bin') +axes[1].set_xlabel('Angle (5-deg bins)') +axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%') +axes[1].legend() + +plt.tight_layout() +plt.show() +``` + +Cell 8 (code): export to ONNX +```python +from skl2onnx import convert_sklearn +from skl2onnx.common.data_types import FloatTensorType +from pathlib import Path + +initial_type = [('features', FloatTensorType([None, 11]))] +onnx_model = convert_sklearn(clf, initial_types=initial_type) + +output_path = Path(OUTPUT_PATH) +output_path.parent.mkdir(parents=True, exist_ok=True) + +with open(output_path, 'wb') as f: + f.write(onnx_model.SerializeToString()) + +print(f"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)") +``` + +- [ ] **Step 3: Create the `Models` directory placeholder** + +Run: `mkdir -p OpenNest.Engine/Models` + +Create `OpenNest.Engine/Models/.gitkeep` (empty file to track the directory). + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/notebooks/ OpenNest.Engine/Models/.gitkeep +git commit -m "feat(training): add training notebook skeleton and requirements" +``` + +--- + +## Summary + +| Chunk | Tasks | Purpose | +|-------|-------|---------| +| 1 | 1-3 | Engine instrumentation: `AngleResult`, `ForceFullAngleSweep`, per-angle collection | +| 2 | 4-6 | Training DB: `AngleResults` table, migration, runner wiring | +| 3 | 7 | Progress window: `Description` display | +| 4 | 8-10 | ONNX inference: `AnglePredictor` class, NuGet package, `FindBestFill` integration | +| 5 | 11 | Python notebook: training pipeline skeleton | + +**Dependency order:** Chunks 1-2 must be sequential (2 depends on 1). Chunks 3, 4, 5 are independent of each other and can be done in parallel after Chunk 1. + +**After this plan:** Run training data collection with `--force-sweep` (the existing training runner + new angle collection). Once data exists, run the notebook to train and export the ONNX model. The engine will automatically use it once `angle_predictor.onnx` is placed in the `Models/` directory. diff --git a/docs/superpowers/plans/2026-03-15-fill-exact.md b/docs/superpowers/plans/2026-03-15-fill-exact.md new file mode 100644 index 0000000..addf0b3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-fill-exact.md @@ -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 +/// +/// 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. +/// +private (List 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 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 +/// +/// 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. +/// +public List FillExact(NestItem item, Box workArea, + IProgress 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 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 +{ + /// + /// 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. + /// + public static class Compactor + { + private const double ChordTolerance = 0.001; + + /// + /// Compacts movingParts toward the bottom-left of the plate work area. + /// Everything already on the plate (excluding movingParts) is treated + /// as stationary obstacles. + /// + public static void Compact(List 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 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[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 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)` diff --git a/docs/superpowers/plans/2026-03-15-helper-decomposition.md b/docs/superpowers/plans/2026-03-15-helper-decomposition.md new file mode 100644 index 0000000..42d4acd --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-helper-decomposition.md @@ -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)` +- `Optimize(IList)` +- `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)` — public +- `GetConnected(Vector, IEnumerable)` — 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, List, PushDirection)` — public +- `DirectionalDistance(List, double, double, List, PushDirection)` — public +- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public +- `FlattenLines(List)` — 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 +``` diff --git a/docs/superpowers/specs/2026-03-15-fill-exact-design.md b/docs/superpowers/specs/2026-03-15-fill-exact-design.md new file mode 100644 index 0000000..d911f8e --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-fill-exact-design.md @@ -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 FillExact(NestItem item, Box workArea, + IProgress 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 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.