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:
2026-03-16 11:04:42 -04:00
130 changed files with 16147 additions and 4197 deletions
@@ -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