chore: remove stale superpowers docs and update gitignore

Remove implemented plan/spec docs from docs/superpowers/ (recoverable
from git history). Add .superpowers/ and launchSettings.json to gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 00:13:01 -04:00
parent de527cd668
commit 4060430757
54 changed files with 4 additions and 25048 deletions
@@ -1,89 +0,0 @@
# Fill Score Design
## Problem
The nesting engine compares fill results by raw part count, which causes it to reject denser pair patterns that would yield more parts after remainder filling.
**Concrete case:** Part 4980 A24 PT02 on a 60×120" plate:
- Wider pair (90°/270°): 5 rows × 9 = **45 parts** at grid stage, no room for remainder
- Tighter pair #1 (89.7°/269.7°): 4 rows × 9 = **36 parts** at grid stage, 15.7" remainder strip → **47 total possible**
The algorithm compares 45 vs 36 at the grid stage. Pattern #1 loses before its remainder strip is fully evaluated. Two contributing issues:
1. **Scoring:** `FillWithPairs` uses raw count (`count > best.Count`) with no tiebreaker for density or compactness
2. **Remainder rotation coverage:** `FillLinear.FillRemainingStrip` only tries rotations present in the seed pattern (89.7°/269.7°), missing the 0° rotation that fits 11 parts in the strip vs ~8 at the seed angles
## Design
### 1. FillScore Struct
A value type encapsulating fill quality with lexicographic comparison:
```
Priority 1: Part count (higher wins)
Priority 2: Usable remnant area (higher wins) — largest remnant with short side ≥ MinRemnantDimension
Priority 3: Density (higher wins) — sum of part areas / bounding box area of placed parts
```
**Location:** `OpenNest.Engine/FillScore.cs`
**Fields:**
- `int Count` — number of parts placed
- `double UsableRemnantArea` — area of the largest remnant whose short side ≥ threshold (0 if none)
- `double Density` — total part area / bounding box area of all placed parts
**Constants:**
- `MinRemnantDimension = 12.0` (inches) — minimum short side for a remnant to be considered usable
**Implements** `IComparable<FillScore>` with lexicographic ordering.
**Static factory:** `FillScore.Compute(List<Part> parts, Box workArea)` — computes all three metrics from a fill result. Remnant calculation uses the same edge-strip approach as `Plate.GetRemnants()` but against the work area box and placed part bounding boxes.
### 2. Replace Comparison Points
All six comparison locations switch from raw count to `FillScore`:
| Location | File | Current Logic |
|----------|------|---------------|
| `IsBetterFill` | NestEngine.cs:299 | Count, then bbox area tiebreaker |
| `FillWithPairs` inner loop | NestEngine.cs:226 | Count only |
| `TryStripRefill` | NestEngine.cs:424 | `stripParts.Count > lastCluster.Count` (keep as-is — threshold check, not quality comparison) |
| `FillRemainingStrip` | FillLinear.cs:436 | `h.Count > best.Count` (keep as-is — internal sub-fill, count is correct) |
| `FillPattern` | NestEngine.cs:492 | `IsBetterValidFill` (overlap + count) |
| `FindBestFill` | NestEngine.cs:95-118 | `IsBetterFill` (already covered) |
`IsBetterFill(candidate, current)` becomes a `FillScore` comparison. `IsBetterValidFill` keeps its overlap check, then delegates to score comparison.
`FillLinear.FillRemainingStrip` needs access to the work area (already available via `WorkArea` property) to compute scores.
### 3. Expanded Remainder Rotations
`FillLinear.FillRemainingStrip` currently only tries rotations from the seed pattern. Add 0° and 90° (cardinal orientations) to the rotation set:
```csharp
// Current: only seed rotations
foreach (var seedPart in seedPattern.Parts) { ... }
// New: also try 0° and 90°
var rotations = new List<double> { 0, Angle.HalfPI };
foreach (var seedPart in seedPattern.Parts)
if (!rotations.Any(r => r.IsEqualTo(seedPart.Rotation)))
rotations.Add(seedPart.Rotation);
```
This is the change that actually fixes the 45→47 case by allowing `FillRemainingStrip` to discover the 0° rotation for the remainder strip.
## What This Does NOT Change
- Part count remains the primary criterion — no trading parts for remnants
- `FillWithPairs` still evaluates top 50 candidates (no extra `TryRemainderImprovement` per candidate)
- `BestFitResult` ranking/filtering is unchanged
- No new UI or configuration (MinRemnantDimension is a constant for now)
## Expected Outcome
For the 4980 A24 PT02 case:
1. Pattern #1 grid produces 36 parts
2. Expanded remainder rotations try 0° in the 15.7" strip → 11 parts → **47 total**
3. Wider pair grid produces 45 parts with ~5" remainder → ~0 extra parts → **45 total**
4. FillScore comparison: 47 > 45 → pattern #1 wins
@@ -1,263 +0,0 @@
# NFP-Based Autonesting Design
## Problem
OpenNest's current nesting engine handles single-drawing plate fills well (FillLinear, FillBestFit, FillWithPairs), but cannot produce mixed-part layouts — placing multiple different drawings together on a plate with true geometry-aware interlocking. The existing `PackBottomLeft` supports mixed parts but operates on bounding-box rectangles only, wasting the concave space around irregular shapes.
Commercial nesting software (e.g., PEP at $30k) produces mixed-part layouts but with mediocre results — significant gaps and poor material utilization.
## Goal
Build an NFP-based mixed-part autonester that:
- Places multiple different drawings on a plate with true geometry-aware collision avoidance
- Produces tighter layouts than bounding-box packing by allowing parts to interlock
- Uses simulated annealing to optimize part ordering and rotation
- Integrates cleanly alongside existing Fill/Pack methods in NestEngine
- Provides an abstracted optimizer interface for future GA/parallel upgrades
## Dependencies
**Clipper2** (NuGet: `Clipper2Lib`, MIT license) — provides polygon boolean operations (union, difference, intersection) and polygon offsetting. Required for feasible region computation and perimeter inflation. Added to `OpenNest.Core`.
## Architecture
Three layers, built bottom-up:
```
┌──────────────────────────────────┐
│ Simulated Annealing Optimizer │ Layer 3: searches orderings/rotations
│ (implements INestOptimizer) │
├──────────────────────────────────┤
│ NFP-Based Bottom-Left Fill │ Layer 2: places parts using feasible regions
│ (BLF placement engine) │
├──────────────────────────────────┤
│ NFP / IFP Computation + Cache │ Layer 1: geometric foundation
└──────────────────────────────────┘
```
## Layer 1: No-Fit Polygon (NFP) Computation
### What is an NFP?
The No-Fit Polygon between a stationary polygon A and an orbiting polygon B defines all positions where B's reference point would cause A and B to overlap. If B's reference point is:
- **Inside** the NFP → overlap
- **On the boundary** → touching
- **Outside** → no overlap
### Computation Method: Convex Decomposition
Use the **Minkowski sum** approach: NFP(A, B) = A ⊕ (-B), where -B is B reflected through its reference point.
For convex polygons this is a simple merge-sort of edge vectors — O(n+m) where n and m are vertex counts.
For concave polygons (most real CNC parts), use decomposition:
1. Decompose each concave polygon into convex sub-polygons using ear-clipping triangulation (produces O(n) triangles per polygon)
2. For each pair of convex pieces (one from A, one from -B), compute the convex Minkowski sum
3. Union all partial results using Clipper2 to produce the final NFP
The number of convex pair computations is O(t_A * t_B) where t_A and t_B are the triangle counts. Each convex Minkowski sum is O(n+m) ≈ O(6) for triangle pairs, so the cost is dominated by the Clipper2 union step.
### Input
Polygons come from the existing conversion chain:
```
Drawing.Program
→ ConvertProgram.ToGeometry() // recursively expands SubProgramCalls
→ Helper.GetShapes() // chains connected entities into Shapes
→ DefinedShape // separates perimeter from cutouts
→ .Perimeter // outer boundary
→ .OffsetEntity(halfSpacing) // inflate at Shape level (offset is implemented on Shape)
→ .ToPolygonWithTolerance(circumscribe) // polygon with arcs circumscribed
```
Only `DefinedShape.Perimeter` is used for NFP — cutouts do not affect part-to-part collision. Spacing is applied by inflating the perimeter shape by half-spacing on each part (so two adjacent parts have full spacing between them). Inflation is done at the `Shape` level via `Shape.OffsetEntity()` before polygon conversion, consistent with how `PartBoundary` works today.
### Polygon Vertex Budget
`Shape.ToPolygonWithTolerance` with tolerance 0.01 can produce hundreds of vertices for arc-heavy parts. For NFP computation, a coarser tolerance (e.g., 0.05-0.1) may be used to keep triangle counts reasonable. The exact tolerance should be tuned during implementation — start with the existing 0.01 and coarsen only if profiling shows NFP computation is a bottleneck.
### NFP Cache
NFPs are keyed by `(DrawingA.Id, RotationA, DrawingB.Id, RotationB)` and computed once per unique combination. During SA optimization, the same NFPs are reused thousands of times.
`Drawing.Id` is a new `int` property added to the `Drawing` class, auto-assigned on construction.
**Type:** `NfpCache` — dictionary-based lookup with lazy computation (compute on first access, store for reuse).
For N drawings with R candidate rotations, the cache holds up to N^2 * R^2 entries. With typical values (6 drawings, 10 rotations), that's ~3,600 entries — well within memory.
### New Types
| Type | Project | Namespace | Purpose |
|------|---------|-----------|---------|
| `NoFitPolygon` | Core | `OpenNest.Geometry` | Static methods for NFP computation between two polygons |
| `InnerFitPolygon` | Core | `OpenNest.Geometry` | Static methods for IFP (part inside plate boundary) |
| `ConvexDecomposition` | Core | `OpenNest.Geometry` | Ear-clipping triangulation of concave polygons |
| `NfpCache` | Engine | `OpenNest` | Caches computed NFPs keyed by drawing ID/rotation pairs |
## Layer 2: Inner-Fit Polygon (IFP)
The IFP answers "where can this part be placed inside the plate?" It is the NFP of the plate boundary with the part, but inverted — the feasible region is the interior.
For a rectangular plate and a convex part, the IFP is a smaller rectangle (plate shrunk by part dimensions). For concave parts the IFP boundary follows the plate edges inset by the part's profile.
### Feasible Region
For placing part P(i) given already-placed parts P(1)...P(i-1):
```
FeasibleRegion = IFP(plate, P(i)) minus union(NFP(P(1), P(i)), ..., NFP(P(i-1), P(i)))
```
Both the union and difference operations use Clipper2.
The placement point is the bottom-left-most point on the feasible region boundary.
## Layer 3: NFP-Based Bottom-Left Fill (BLF)
### Algorithm
```
BLF(sequence, plate, nfpCache):
placedParts = []
for each (drawing, rotation) in sequence:
polygon = getPerimeterPolygon(drawing, rotation)
ifp = InnerFitPolygon.Compute(plate.WorkArea, polygon)
nfps = [nfpCache.Get(placed, polygon) for placed in placedParts]
feasible = Clipper2.Difference(ifp, Clipper2.Union(nfps))
point = bottomLeftMost(feasible)
if point exists:
place part at point
placedParts.append(part)
return FillScore.Compute(placedParts, plate.WorkArea)
```
### Bottom-Left Point Selection
"Bottom-left" means: minimize Y first (lowest), then minimize X (leftmost). This is standard BLF convention in the nesting literature. The feasible region boundary is walked to find the point with the smallest Y coordinate, breaking ties by smallest X.
Note: the existing `PackBottomLeft.FindPointVertical` uses leftmost-first priority. The new BLF uses lowest-first to match established nesting algorithms. Both remain available.
### Replaces
This replaces `PackBottomLeft` for mixed-part scenarios. The existing rectangle-based `PackBottomLeft` remains available for pure-rectangle use cases.
## Layer 4: Simulated Annealing Optimizer
### Interface
```csharp
interface INestOptimizer
{
NestResult Optimize(List<NestItem> items, Plate plate, NfpCache cache,
CancellationToken cancellation = default);
}
```
This abstraction allows swapping SA for GA (or other meta-heuristics) in the future without touching the NFP or BLF layers. `CancellationToken` enables UI responsiveness and user-initiated cancellation for long-running optimizations.
### State Representation
The SA state is a sequence of `(DrawingId, RotationAngle)` tuples — one entry per physical part to place (respecting quantities from NestItem). The sequence determines placement order for BLF.
### Mutation Operators
Each iteration, one mutation is selected randomly:
| Operator | Description |
|----------|-------------|
| **Swap** | Exchange two parts' positions in the sequence |
| **Rotate** | Change one part's rotation to another candidate angle |
| **Segment reverse** | Reverse a contiguous subsequence |
### Cooling Schedule
- **Initial temperature:** Calibrated so ~80% of worse moves are accepted initially
- **Cooling rate:** Geometric (T = T * alpha, alpha ~0.995-0.999)
- **Termination:** Temperature below threshold OR no improvement for N consecutive iterations OR cancellation requested
- **Quality focus:** Since quality > speed, allow long runs (thousands of iterations)
### Candidate Rotations
Per drawing, candidate rotations are determined by existing logic:
- Hull-edge angles from `RotationAnalysis.FindHullEdgeAngles()` (needs a new overload accepting a `Drawing` or `Polygon` instead of `List<Part>`)
- 0 degrees baseline
- Fixed-increment sweep (e.g., 5 degrees) for small work areas
### Initial Solution
Generate the initial sequence by sorting parts largest-area-first (matches existing `PackBottomLeft` heuristic). This gives SA a reasonable starting point rather than random.
## Integration: NestEngine.AutoNest
New public entry point:
```csharp
public List<Part> AutoNest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
```
### Flow
1. Extract perimeter polygons via `DefinedShape` for each unique drawing
2. Inflate perimeters by half-spacing at the `Shape` level via `OffsetEntity()`
3. Convert to polygons via `ToPolygonWithTolerance(circumscribe: true)`
4. Compute candidate rotations per drawing
5. Pre-compute `NfpCache` for all (drawing, rotation) pair combinations
6. Run `INestOptimizer.Optimize()` → best sequence
7. Final BLF placement with best solution → placed `Part` instances
8. Return parts
### Error Handling
- Drawing produces no valid perimeter polygon → skip drawing, log warning
- No parts fit on plate → return empty list
- NFP produces degenerate polygon (zero area) → treat as non-overlapping (safe fallback)
### Coexistence with Existing Methods
| Method | Use Case | Status |
|--------|----------|--------|
| `Fill(NestItem)` | Single-drawing plate fill (tiling) | Unchanged |
| `Pack(List<NestItem>)` | Rectangle bin packing | Unchanged |
| `AutoNest(List<NestItem>, Plate)` | Mixed-part geometry-aware nesting | **New** |
## Future: Simplifying FillLinear with NFP
Once NFP infrastructure is in place, `FillLinear.FindCopyDistance` can be replaced with NFP projections:
- Copy distance along any axis = extreme point of NFP projected onto that axis
- Eliminates directional edge filtering, line-to-line sliding, and special-case handling in `PartBoundary`
- `BestFitFinder` pair evaluation simplifies to walking NFP boundaries
This refactor is deferred to a future phase to keep scope focused.
## Future: Nesting Inside Cutouts
`DefinedShape.Cutouts` are preserved but unused in Phase 1. Future optimization: for parts with large interior cutouts, compute IFP of the cutout boundary and attempt to place small parts inside. This requires:
- Minimum cutout size threshold
- Modified BLF that considers cutout interiors as additional placement regions
## Future: Parallel Optimization (Threadripper)
The `INestOptimizer` interface naturally supports parallelism:
- **SA parallelism:** Run multiple independent SA chains with different random seeds, take the best result
- **GA upgrade:** Population-based evaluation is embarrassingly parallel — evaluate N candidate orderings simultaneously on N threads
- NFP cache is read-only during optimization, so it's inherently thread-safe
## Future: Orbiting NFP Algorithm
If convex decomposition proves too slow for vertex-heavy parts, the orbiting (edge-tracing) method computes NFPs directly on concave polygons without decomposition. Deferred unless profiling identifies decomposition as a bottleneck.
## Scoring
Results are scored using existing `FillScore` (lexicographic: count → usable remnant area → density). `FillScore.Compute` takes `(List<Part>, Box workArea)` — pass `plate.WorkArea` as the Box. No changes to FillScore needed.
## Project Location
All new types go in existing projects:
- `NoFitPolygon`, `InnerFitPolygon`, `ConvexDecomposition``OpenNest.Core/Geometry/`
- `NfpCache`, BLF engine, SA optimizer, `INestOptimizer``OpenNest.Engine/`
- `AutoNest` method → `NestEngine.cs`
- `Drawing.Id` property → `OpenNest.Core/Drawing.cs`
- Clipper2 NuGet → `OpenNest.Core.csproj`
@@ -1,134 +0,0 @@
# Contour Re-Indexing Design
## Overview
Add entity-splitting primitives and a `Shape.ReindexAt` method so that a closed contour can be reordered to start (and end) at an arbitrary point. Then wire this into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
All geometry additions live on existing classes in `OpenNest.Geometry`. The strategy wiring is a change to the existing `ContourCuttingStrategy` in `OpenNest.CNC.CuttingStrategy`.
## Entity Splitting Primitives
### Line.SplitAt(Vector point)
```csharp
public (Line first, Line second) SplitAt(Vector point)
```
- Returns two lines: `StartPoint → point` and `point → EndPoint`.
- If the point is at `StartPoint` (within `Tolerance.Epsilon` distance), `first` is null.
- If the point is at `EndPoint` (within `Tolerance.Epsilon` distance), `second` is null.
- The point is assumed to lie on the line (caller is responsible — it comes from `ClosestPointTo`).
### Arc.SplitAt(Vector point)
```csharp
public (Arc first, Arc second) SplitAt(Vector point)
```
- Computes `splitAngle = Center.AngleTo(point)`, normalized via `Angle.NormalizeRad`.
- First arc: same center, radius, direction — `StartAngle → splitAngle`.
- Second arc: same center, radius, direction — `splitAngle → EndAngle`.
- **Endpoint tolerance**: compare `point.DistanceTo(arc.StartPoint())` and `point.DistanceTo(arc.EndPoint())` rather than comparing angles directly. This avoids wrap-around issues at the 0/2π boundary.
- If the point is at `StartPoint()` (within `Tolerance.Epsilon` distance), `first` is null.
- If the point is at `EndPoint()` (within `Tolerance.Epsilon` distance), `second` is null.
### Circle — no conversion needed
Circles are kept as-is in `ReindexAt`. The `ConvertShapeToMoves` method handles circles directly by emitting an `ArcMove` from the start point back to itself (a full circle), matching the existing `ConvertGeometry.AddCircle` pattern. This avoids the problem of constructing a "full-sweep arc" where `StartAngle == EndAngle` would produce zero sweep.
## Shape.ReindexAt
```csharp
public Shape ReindexAt(Vector point, Entity entity)
```
- `point`: the start/end point for the reindexed contour (from `ClosestPointTo`).
- `entity`: the entity containing `point` (from `ClosestPointTo`'s `out` parameter).
- Returns a **new** Shape (does not modify the original). The new shape shares entity references with the original for unsplit entities — callers must not mutate either.
- Throws `ArgumentException` if `entity` is not found in `Entities`.
### Algorithm
1. If `entity` is a `Circle`:
- Return a new Shape with that single `Circle` entity and `point` stored for `ConvertShapeToMoves` to use as the start point.
2. Find the index `i` of `entity` in `Entities`. Throw `ArgumentException` if not found.
3. Split the entity at `point`:
- `Line``line.SplitAt(point)``(firstHalf, secondHalf)`
- `Arc``arc.SplitAt(point)``(firstHalf, secondHalf)`
4. Build the new entity list (skip null entries):
- `secondHalf` (if not null)
- `Entities[i+1]`, `Entities[i+2]`, ..., `Entities[count-1]` (after the split)
- `Entities[0]`, `Entities[1]`, ..., `Entities[i-1]` (before the split, wrapping around)
- `firstHalf` (if not null)
5. Return a new Shape with this entity list.
### Edge Cases
- **Point lands on entity boundary** (start/end of an entity): one half of the split is null. The reordering still works — it just starts from the next full entity.
- **Single-entity shape that is an Arc**: split produces two arcs, reorder is just `[secondHalf, firstHalf]`.
- **Single-entity Circle**: handled by step 1 — kept as Circle, converted to a full-circle ArcMove in `ConvertShapeToMoves`.
## Wiring into ContourCuttingStrategy
### Entity-to-ICode Conversion
Add a private method to `ContourCuttingStrategy`:
```csharp
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
```
The `startPoint` parameter is needed for the Circle case (to know where the full-circle ArcMove starts).
Iterates `shape.Entities` and converts each to cutting moves using **absolute coordinates** (consistent with `ConvertGeometry`):
- `Line``LinearMove(line.EndPoint)`
- `Arc``ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)`
- `Circle``ArcMove(startPoint, circle.Center, circle.Rotation)` — full circle from start point back to itself, matching `ConvertGeometry.AddCircle`
- Any other entity type → throw `InvalidOperationException`
No `RapidMove` between entities — they are contiguous in a reindexed shape. The lead-in already positions the head at the shape's start point.
### Replace NotImplementedException
In `ContourCuttingStrategy.Apply()`, replace the two `throw new NotImplementedException(...)` blocks:
**Cutout loop** (uses `cutout` shape variable):
```csharp
var reindexed = cutout.ReindexAt(closestPt, entity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
```
**Perimeter block** (uses `profile.Perimeter`):
```csharp
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
```
The full sequence for each contour becomes:
1. Lead-in codes (rapid to pierce point, cutting moves to contour start)
2. Contour body (reindexed entity moves from `ConvertShapeToMoves`)
3. Lead-out codes (overcut moves away from contour)
### MicrotabLeadOut Handling
When the lead-out is `MicrotabLeadOut`, the last cutting move must be trimmed by `GapSize`. This is a separate concern from re-indexing — stub it with a TODO comment for now. The trimming logic will shorten the last `LinearMove` or `ArcMove` in the contour body.
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Core/Geometry/Line.cs` | Add `SplitAt(Vector)` method |
| `OpenNest.Core/Geometry/Arc.cs` | Add `SplitAt(Vector)` method |
| `OpenNest.Core/Geometry/Shape.cs` | Add `ReindexAt(Vector, Entity)` method |
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add `ConvertShapeToMoves`, replace `NotImplementedException` blocks |
## Out of Scope
- **MicrotabLeadOut trimming** (trim last move by gap size — stubbed with TODO)
- **Tab insertion** (inserting tab codes mid-contour — already stubbed)
- **Lead-in editor UI** (interactive start point selection — separate feature)
- **Contour re-indexing for open shapes** (only closed contours supported)
@@ -1,420 +0,0 @@
# CNC Cutting Strategy Design
## Overview
Add lead-in, lead-out, and tab classes to `OpenNest.Core` that generate `ICode` instructions for CNC cutting approach/exit geometry. The strategy runs at nest-time — `ContourCuttingStrategy.Apply()` produces a new `Program` with lead-ins, lead-outs, start points, and contour ordering baked in. This modified program is what gets saved to the nest file and later fed to the post-processor for machine-specific G-code translation. The original `Drawing.Program` stays untouched; the strategy output lives on the `Part`.
All new code lives in `OpenNest.Core/CNC/CuttingStrategy/`.
## File Structure
```
OpenNest.Core/CNC/CuttingStrategy/
├── LeadIns/
│ ├── LeadIn.cs
│ ├── NoLeadIn.cs
│ ├── LineLeadIn.cs
│ ├── LineArcLeadIn.cs
│ ├── ArcLeadIn.cs
│ ├── LineLineLeadIn.cs
│ └── CleanHoleLeadIn.cs
├── LeadOuts/
│ ├── LeadOut.cs
│ ├── NoLeadOut.cs
│ ├── LineLeadOut.cs
│ ├── ArcLeadOut.cs
│ └── MicrotabLeadOut.cs
├── Tabs/
│ ├── Tab.cs
│ ├── NormalTab.cs
│ ├── BreakerTab.cs
│ └── MachineTab.cs
├── ContourType.cs
├── CuttingParameters.cs
├── ContourCuttingStrategy.cs
├── SequenceParameters.cs
└── AssignmentParameters.cs
```
## Namespace
All classes use `namespace OpenNest.CNC.CuttingStrategy`.
## Type Mappings from Original Spec
The original spec used placeholder names. These are the correct codebase types:
| Spec type | Actual type | Notes |
|-----------|------------|-------|
| `PointD` | `Vector` | `OpenNest.Geometry.Vector` — struct with `X`, `Y` fields |
| `CircularMove` | `ArcMove` | Constructor: `ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation)` |
| `CircularDirection` | `RotationType` | Enum with `CW`, `CCW` |
| `value.ToRadians()` | `Angle.ToRadians(value)` | Static method on `OpenNest.Math.Angle` |
| `new Program(codes)` | Build manually | Create `Program()`, add to `.Codes` list |
## LeadIn Hierarchy
### Abstract Base: `LeadIn`
```csharp
public abstract class LeadIn
{
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
}
```
- `contourStartPoint`: where the contour cut begins (first point of the part profile).
- `contourNormalAngle`: normal angle (radians) at the contour start point, pointing **away from the part material** (outward from perimeter, into scrap for cutouts).
- `winding`: contour winding direction — arc-based lead-ins use this for their `ArcMove` rotation.
- `Generate` returns ICode instructions starting with a `RapidMove` to the pierce point, followed by cutting moves to reach the contour start.
- `GetPiercePoint` computes where the head rapids to before firing — useful for visualization and collision detection.
### NoLeadIn (Type 0)
Pierce directly on the contour start point. Returns a single `RapidMove(contourStartPoint)`.
### LineLeadIn (Type 1)
Straight line approach.
Properties:
- `Length` (double): distance from pierce point to contour start (inches)
- `ApproachAngle` (double): approach angle in degrees relative to contour tangent. 90 = perpendicular, 135 = acute angle (common for plasma). Default: 90.
Pierce point offset: `contourStartPoint + Length` along `contourNormalAngle + Angle.ToRadians(ApproachAngle)`.
Generates: `RapidMove(piercePoint)``LinearMove(contourStartPoint)`.
> **Note:** Properties are named `ApproachAngle` (not `Angle`) to avoid shadowing the `OpenNest.Math.Angle` static class. This applies to all lead-in/lead-out/tab classes.
### LineArcLeadIn (Type 2)
Line followed by tangential arc meeting the contour. Most common for plasma.
Properties:
- `LineLength` (double): straight approach segment length
- `ApproachAngle` (double): line angle relative to contour. Default: 135.
- `ArcRadius` (double): radius of tangential arc
Geometry: Pierce → [Line] → Arc start → [Arc] → Contour start. Arc center is at `contourStartPoint + ArcRadius` along normal. Arc rotation direction matches contour winding (CW for CW contours, CCW for CCW).
Generates: `RapidMove(piercePoint)``LinearMove(arcStart)``ArcMove(contourStartPoint, arcCenter, rotation)`.
### ArcLeadIn (Type 3)
Pure arc approach, no straight line segment.
Properties:
- `Radius` (double): arc radius
Pierce point is diametrically opposite the contour start on the arc circle. Arc center at `contourStartPoint + Radius` along normal.
Arc rotation direction matches contour winding.
Generates: `RapidMove(piercePoint)``ArcMove(contourStartPoint, arcCenter, rotation)`.
### LineLineLeadIn (Type 5)
Two-segment straight line approach.
Properties:
- `Length1` (double): first segment length
- `ApproachAngle1` (double): first segment angle. Default: 90.
- `Length2` (double): second segment length
- `ApproachAngle2` (double): direction change. Default: 90.
Generates: `RapidMove(piercePoint)``LinearMove(midPoint)``LinearMove(contourStartPoint)`.
### CleanHoleLeadIn
Specialized for precision circular holes. Same geometry as `LineArcLeadIn` but with hard-coded 135° angle and a `Kerf` property. The overcut (cutting past start to close the hole) is handled at the lead-out, not here.
Properties:
- `LineLength` (double)
- `ArcRadius` (double)
- `Kerf` (double)
## LeadOut Hierarchy
### Abstract Base: `LeadOut`
```csharp
public abstract class LeadOut
{
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
}
```
- `contourEndPoint`: where the contour cut ends. For closed contours, same as start.
- Returns ICode instructions appended after the contour's last cut point.
### NoLeadOut (Type 0)
Returns empty list. Cut ends exactly at contour end.
### LineLeadOut (Type 1)
Straight line overcut past contour end.
Properties:
- `Length` (double): overcut distance
- `ApproachAngle` (double): direction relative to contour tangent. Default: 90.
Generates: `LinearMove(endPoint)` where endPoint is offset from contourEndPoint.
### ArcLeadOut (Type 3)
Arc overcut curving away from the part.
Properties:
- `Radius` (double)
Arc center at `contourEndPoint + Radius` along normal. End point is a quarter turn away. Arc rotation direction matches contour winding.
Generates: `ArcMove(endPoint, arcCenter, rotation)`.
### MicrotabLeadOut (Type 4)
Stops short of contour end, leaving an uncut bridge. Laser only.
Properties:
- `GapSize` (double): uncut material length. Default: 0.03".
Does NOT add instructions — returns empty list. The `ContourCuttingStrategy` detects this type and trims the last cutting move by `GapSize` instead.
## Tab Hierarchy
Tabs are mid-contour features that temporarily lift the beam to leave bridges holding the part in place.
### Abstract Base: `Tab`
```csharp
public abstract class Tab
{
public double Size { get; set; } = 0.03;
public LeadIn TabLeadIn { get; set; }
public LeadOut TabLeadOut { get; set; }
public abstract List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle);
}
```
### NormalTab
Standard tab: cut up to tab start, lift/rapid over gap, resume cutting.
Additional properties:
- `CutoutMinWidth`, `CutoutMinHeight` (double): minimum cutout size to receive this tab
- `CutoutMaxWidth`, `CutoutMaxHeight` (double): maximum cutout size to receive this tab
- `AppliesToCutout(double width, double height)` method for size filtering
Generates: TabLeadOut codes → `RapidMove(tabEndPoint)` → TabLeadIn codes.
### BreakerTab
Like NormalTab but adds a scoring cut into the part at the tab location to make snapping easier.
Additional properties:
- `BreakerDepth` (double): how far the score cuts into the part
- `BreakerLeadInLength` (double)
- `BreakerAngle` (double)
Generates: TabLeadOut codes → `LinearMove(scoreEnd)``RapidMove(tabEndPoint)` → TabLeadIn codes.
### MachineTab
Tab behavior configured at the CNC controller level. OpenNest just signals the controller.
Additional properties:
- `MachineTabId` (int): passed to post-processor for M-code translation
Returns a placeholder `RapidMove(tabEndPoint)` — the post-processor plugin replaces this with machine-specific commands.
## CuttingParameters
One instance per material/machine combination. Ties everything together.
```csharp
public class CuttingParameters
{
public int Id { get; set; }
// Material/Machine identification
public string MachineName { get; set; }
public string MaterialName { get; set; }
public string Grade { get; set; }
public double Thickness { get; set; }
// Kerf and spacing
public double Kerf { get; set; }
public double PartSpacing { get; set; }
// External contour lead-in/out
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
// Internal contour lead-in/out
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, Angle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
// Arc/circle specific (overrides internal for circular features)
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
// Tab configuration
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; } = false;
// Sequencing and assignment
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
}
```
## SequenceParameters and AssignmentParameters
```csharp
// Values match PEP Technology's numbering scheme (value 6 intentionally skipped)
public enum SequenceMethod
{
RightSide = 1, LeastCode = 2, Advanced = 3,
BottomSide = 4, EdgeStart = 5, LeftSide = 7, RightSideAlt = 8
}
public class SequenceParameters
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public double SmallCutoutWidth { get; set; } = 1.5;
public double SmallCutoutHeight { get; set; } = 1.5;
public double MediumCutoutWidth { get; set; } = 8.0;
public double MediumCutoutHeight { get; set; } = 8.0;
public double DistanceMediumSmall { get; set; }
public bool AlternateRowsColumns { get; set; } = true;
public bool AlternateCutoutsWithinRowColumn { get; set; } = true;
public double MinDistanceBetweenRowsColumns { get; set; } = 0.25;
}
public class AssignmentParameters
{
public SequenceMethod Method { get; set; } = SequenceMethod.Advanced;
public string Preference { get; set; } = "ILAT";
public double MinGeometryLength { get; set; } = 0.01;
}
```
## ContourCuttingStrategy
The orchestrator. Uses `ShapeProfile` to decompose a part into perimeter + cutouts, then sequences and applies cutting parameters using nearest-neighbor chaining from an exit point.
### Exit Point from Plate Quadrant
The exit point is the **opposite corner** of the plate from the quadrant origin. This is where the head ends up after traversing the plate, and is the starting point for backwards nearest-neighbor sequencing.
| Quadrant | Origin | Exit Point |
|----------|--------|------------|
| 1 | TopRight | BottomLeft (0, 0) |
| 2 | TopLeft | BottomRight (width, 0) |
| 3 | BottomLeft | TopRight (width, length) |
| 4 | BottomRight | TopLeft (0, length) |
The exit point is derived from `Plate.Quadrant` and `Plate.Size` — not passed in manually.
### Approach
Instead of requiring `Program.GetStartPoint()` / `GetNormalAtStart()` (which don't exist), the strategy:
1. Computes the **exit point** from the plate's quadrant and size
2. Converts the program to geometry via `Program.ToGeometry()`
3. Builds a `ShapeProfile` from the geometry — gives `Perimeter` (Shape) and `Cutouts` (List&lt;Shape&gt;)
4. Uses `Shape.ClosestPointTo(point, out Entity entity)` to find lead-in points and the entity for normal computation
5. Chains cutouts by nearest-neighbor distance from the perimeter closest point
6. Reverses the chain → cut order is cutouts first (nearest-last), perimeter last
### Contour Re-Indexing
After `ClosestPointTo` finds the lead-in point on a shape, the shape's entity list must be reordered so that cutting starts at that point. This means:
1. Find which entity in `Shape.Entities` contains the closest point
2. Split that entity at the closest point into two segments
3. Reorder: second half of split entity → remaining entities in order → first half of split entity
4. The contour now starts and ends at the lead-in point (for closed contours)
This produces the `List<ICode>` for the contour body that goes between the lead-in and lead-out codes.
### ContourType Detection
- `ShapeProfile.Perimeter``ContourType.External`
- Each cutout in `ShapeProfile.Cutouts`:
- If single entity and entity is `Circle``ContourType.ArcCircle`
- Otherwise → `ContourType.Internal`
### Normal Angle Computation
Derived from the `out Entity` returned by `ClosestPointTo`:
- **Line**: normal is perpendicular to line direction. Use the line's tangent angle, then add π/2 for the normal pointing away from the part interior.
- **Arc/Circle**: normal is radial direction from arc center to the closest point: `closestPoint.AngleFrom(arc.Center)`.
Normal direction convention: always points **away from the part material** (outward from perimeter, inward toward scrap for cutouts). The lead-in approaches from this direction.
### Arc Rotation Direction
Lead-in/lead-out arcs must match the **contour winding direction**, not be hardcoded CW. Determine winding from the shape's entity traversal order. Pass the appropriate `RotationType` to `ArcMove`.
### Method Signature
```csharp
public class ContourCuttingStrategy
{
public CuttingParameters Parameters { get; set; }
/// <summary>
/// Apply cutting strategy to a part's program.
/// </summary>
/// <param name="partProgram">Original part program (unmodified).</param>
/// <param name="plate">Plate for quadrant/size to compute exit point.</param>
/// <returns>New Program with lead-ins, lead-outs, and tabs applied. Cutouts first, perimeter last.</returns>
public Program Apply(Program partProgram, Plate plate)
{
// 1. Compute exit point from plate quadrant + size
// 2. Convert to geometry, build ShapeProfile
// 3. Find closest point on perimeter from exitPoint
// 4. Chain cutouts by nearest-neighbor from perimeter point
// 5. Reverse chain → cut order
// 6. For each contour:
// a. Re-index shape entities to start at closest point
// b. Detect ContourType
// c. Compute normal angle from entity
// d. Select lead-in/out from CuttingParameters by ContourType
// e. Generate lead-in codes + contour body + lead-out codes
// 7. Handle MicrotabLeadOut by trimming last segment
// 8. Assemble and return new Program
}
}
```
### ContourType Enum
```csharp
public enum ContourType
{
External,
Internal,
ArcCircle
}
```
## Integration Point
`ContourCuttingStrategy.Apply()` runs at nest-time (when parts are placed or cutting parameters are assigned), not at post-processing time. The output `Program` — with lead-ins, lead-outs, start points, and contour ordering — is stored on the `Part` and saved through the normal `NestWriter` path. The post-processor receives this already-complete program and only translates it to machine-specific G-code.
## Out of Scope (Deferred)
- **Serialization** of CuttingParameters (JSON/XML discriminators)
- **UI integration** (parameter editor forms in WinForms app)
- **Part.CutProgram property** (storing the strategy-applied program on `Part`, separate from `Drawing.Program`)
- **Tab insertion logic** (`InsertTabs` / `TrimLastSegment` — stubbed with `NotImplementedException`)
@@ -1,134 +0,0 @@
# Nest File Format v2 Design
## Problem
The current nest file format stores metadata across three separate XML files (`info`, `drawing-info`, `plate-info`) plus per-plate G-code files for part placements inside a ZIP archive. This results in ~400 lines of hand-written XML read/write code, fragile dictionary-linking to reconnect drawings/plates by ID after parsing, and the overhead of running the full G-code parser just to extract part positions.
## Design
### File Structure
The nest file remains a ZIP archive. Contents:
```
nest.json
programs/
program-1
program-2
...
```
- **`nest.json`** — single JSON file containing all metadata and part placements.
- **`programs/program-N`** — G-code text for each drawing's CNC program (1-indexed, no zero-padding). Previously stored at the archive root as `program-NNN` (zero-padded). Parsed by `ProgramReader`, written by existing G-code serialization logic. Format unchanged.
Plate G-code files (`plate-NNN`) are removed. Part placements are stored inline in `nest.json`.
### JSON Schema
```json
{
"version": 2,
"name": "string",
"units": "Inches | Millimeters",
"customer": "string",
"dateCreated": "2026-03-12T10:30:00",
"dateLastModified": "2026-03-12T14:00:00",
"notes": "string (plain JSON, no URI-escaping)",
"plateDefaults": {
"size": { "width": 0.0, "height": 0.0 },
"thickness": 0.0,
"quadrant": 1,
"partSpacing": 0.0,
"material": { "name": "string", "grade": "string", "density": 0.0 },
"edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 }
},
"drawings": [
{
"id": 1,
"name": "string",
"customer": "string",
"color": { "a": 255, "r": 0, "g": 0, "b": 0 },
"quantity": { "required": 0 },
"priority": 0,
"constraints": {
"stepAngle": 0.0,
"startAngle": 0.0,
"endAngle": 0.0,
"allow180Equivalent": false
},
"material": { "name": "string", "grade": "string", "density": 0.0 },
"source": {
"path": "string",
"offset": { "x": 0.0, "y": 0.0 }
}
}
],
"plates": [
{
"id": 1,
"size": { "width": 0.0, "height": 0.0 },
"thickness": 0.0,
"quadrant": 1,
"quantity": 1,
"partSpacing": 0.0,
"material": { "name": "string", "grade": "string", "density": 0.0 },
"edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 },
"parts": [
{ "drawingId": 1, "x": 0.0, "y": 0.0, "rotation": 0.0 }
]
}
]
}
```
Key details:
- **Version**: `"version": 2` at the top level for future format migration.
- Drawing `id` values are 1-indexed, matching `programs/program-N` filenames.
- Part `rotation` is stored in **radians** (matches internal domain model, no conversion needed).
- Part `drawingId` references the drawing's `id` in the `drawings` array.
- **Dates**: local time, serialized via `DateTime.ToString("o")` (ISO 8601 round-trip format with timezone offset).
- **Notes**: stored as plain JSON strings. The v1 URI-escaping (`Uri.EscapeDataString`) is not needed since JSON handles special characters natively.
- `quantity.required` is the only quantity persisted; `nested` is computed at load time from part placements.
- **Units**: enum values match the domain model: `Inches` or `Millimeters`.
- **Size**: uses `width`/`height` matching the `OpenNest.Geometry.Size` struct.
- **Drawing.Priority** and **Drawing.Constraints** (stepAngle, startAngle, endAngle, allow180Equivalent) are now persisted (v1 omitted these).
- **Empty collections**: `drawings` and `plates` arrays are always present (may be empty `[]`). The `programs/` folder is empty when there are no drawings.
### Serialization Approach
Use `System.Text.Json` with small DTO (Data Transfer Object) classes for serialization. The DTOs map between the domain model and the JSON structure, keeping serialization concerns out of the domain classes.
### What Changes
| File | Change |
|------|--------|
| `NestWriter.cs` | Replace all XML writing and plate G-code writing with JSON serialization. Programs written to `programs/` folder. |
| `NestReader.cs` | Replace all XML parsing, plate G-code parsing, and dictionary-linking with JSON deserialization. Programs read from `programs/` folder. |
### What Stays the Same
| File | Reason |
|------|--------|
| `ProgramReader.cs` | G-code parsing for CNC programs is unchanged. |
| `NestWriter` G-code writing (`WriteDrawing`, `GetCodeString`) | G-code serialization for programs is unchanged. |
| `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs` | Unrelated to nest file format. |
| Domain model classes | No changes needed. |
### Public API
The public API is unchanged:
- `NestReader(string file)` and `NestReader(Stream stream)` constructors preserved.
- `NestReader.Read()` returns `Nest`.
- `NestWriter(Nest nest)` constructor preserved.
- `NestWriter.Write(string file)` returns `bool`.
### Callers (no changes needed)
- `MainForm.cs:329``new NestReader(path)`
- `MainForm.cs:363``new NestReader(dlg.FileName)`
- `EditNestForm.cs:212``new NestWriter(Nest)`
- `EditNestForm.cs:223``new NestWriter(nst)`
- `Document.cs:27``new NestWriter(Nest)`
- `OpenNest.Console/Program.cs:94``new NestReader(nestFile)`
- `OpenNest.Console/Program.cs:190``new NestWriter(nest)`
- `OpenNest.Mcp/InputTools.cs:30``new NestReader(path)`
@@ -1,92 +0,0 @@
# Iterative Halving Sweep in RotationSlideStrategy
## Problem
`RotationSlideStrategy.GenerateCandidatesForAxis` sweeps the full perpendicular range at `stepSize` (default 0.25"), calling `Helper.DirectionalDistance` at every step. Profiling shows `DirectionalDistance` accounts for 62% of CPU during best-fit computation. For parts with large bounding boxes, this produces hundreds of steps per direction, making the Pairs phase take 2.5+ minutes.
## Solution
Replace the single fine sweep with an iterative halving search inside `GenerateCandidatesForAxis`. Starting at a coarse step size (16× the fine step), each iteration identifies the best offset regions by slide distance, then halves the step and re-sweeps only within narrow windows around those regions. This converges to the optimal offsets in ~85 `DirectionalDistance` calls vs ~160 for a full fine sweep.
## Design
### Modified method: `GenerateCandidatesForAxis`
Located in `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`. The public `GenerateCandidates` method and all other code remain unchanged.
**Current flow:**
1. Sweep `alignedStart` to `perpMax` at `stepSize`
2. At each offset: clone part2, position, compute offset lines, call `DirectionalDistance`, build `PairCandidate`
**New flow:**
**Constants (local to the method):**
- `CoarseMultiplier = 16` — initial step is `stepSize * 16`
- `MaxRegions = 5` — top-N regions to keep per iteration
**Algorithm:**
1. Compute `currentStep = stepSize * CoarseMultiplier`
2. Set the initial sweep range to `[alignedStart, perpMax]` where `alignedStart = Math.Ceiling(perpMin / currentStep) * currentStep`
3. **Iteration loop** — while `currentStep > stepSize`:
a. Sweep all active regions at `currentStep`, collecting `(offset, slideDist)` tuples:
- For each offset in each region: clone part2, position, compute offset lines, call `DirectionalDistance`
- Skip if `slideDist >= double.MaxValue || slideDist < 0`
b. Select top `MaxRegions` hits by `slideDist` ascending (tightest fit first), deduplicating any hits within `currentStep` of an already-selected hit
c. Build new regions: for each selected hit, the new region is `[offset - currentStep, offset + currentStep]`, clamped to `[perpMin, perpMax]`
d. Halve: `currentStep /= 2`
e. Align each region's start to a multiple of `currentStep`
4. **Final pass** — sweep all active regions at `stepSize`, generating full `PairCandidate` objects (same logic as current code: clone part2, position, compute offset lines, `DirectionalDistance`, build candidate)
**Iteration trace for a 20" range with `stepSize = 0.25`:**
| Pass | Step | Regions | Samples per region | Total samples |
|------|------|---------|--------------------|---------------|
| 1 | 4.0 | 1 (full range) | ~5 | ~5 |
| 2 | 2.0 | up to 5 | ~4 | ~20 |
| 3 | 1.0 | up to 5 | ~4 | ~20 |
| 4 | 0.5 | up to 5 | ~4 | ~20 |
| 5 (final) | 0.25 | up to 5 | ~4 | ~20 (generates candidates) |
| **Total** | | | | **~85** vs **~160 current** |
**Alignment:** Each pass aligns its sweep start to a multiple of `currentStep`. Since `currentStep` is always a power-of-two multiple of `stepSize`, offset=0 is always a sample point when it falls within a region. This preserves perfect grid arrangements for rectangular parts.
**Region deduplication:** When selecting top hits, any hit whose offset is within `currentStep` of a previously selected hit is skipped. This prevents overlapping refinement windows from wasting samples on the same area.
### Integration points
The changes are entirely within the private method `GenerateCandidatesForAxis`. The method signature, parameters, and return type (`List<PairCandidate>`) are unchanged. The only behavioral difference is that it generates fewer candidates overall (only from the promising regions), but those candidates cover the same quality range because the iterative search converges on the best offsets.
### Performance
- Current: ~160 `DirectionalDistance` calls per direction (20" range / 0.25 step)
- Iterative halving: ~85 calls (5 + 20 + 20 + 20 + 20)
- ~47% reduction in `DirectionalDistance` calls per direction
- Coarse passes are cheaper per-call since they only store `(offset, slideDist)` tuples rather than building full `PairCandidate` objects
- Total across 4 directions × N angles: proportional reduction throughout
- For larger parts (40"+ range), the savings are even greater since the coarse pass covers the range in very few samples
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Replace single sweep in `GenerateCandidatesForAxis` with iterative halving sweep |
## What Doesn't Change
- `RotationSlideStrategy.GenerateCandidates` — unchanged, calls `GenerateCandidatesForAxis` as before
- `BestFitFinder` — unchanged, calls `strategy.GenerateCandidates` as before
- `BestFitCache` — unchanged
- `PairEvaluator` / `IPairEvaluator` — unchanged
- `PairCandidate`, `BestFitResult`, `BestFitFilter` — unchanged
- `Helper.DirectionalDistance`, `Helper.GetOffsetPartLines` — reused as-is
- `NestEngine.FillWithPairs` — unchanged caller
## Edge Cases
- **Part smaller than initial coarseStep:** The first pass produces very few samples (possibly 1-2), but each subsequent halving still narrows correctly. For tiny parts, the total range may be smaller than `coarseStep`, so the algorithm effectively skips to finer passes quickly.
- **Refinement regions overlap after halving:** Deduplication at each iteration prevents selecting nearby hits. Even if two regions share a boundary after halving, at worst one offset is evaluated twice — negligible cost.
- **No valid hits at any pass:** If all offsets at a given step produce invalid slide distances, the hit list is empty, no regions are generated, and subsequent passes produce no candidates. This matches current behavior for parts that can't pair in the given direction.
- **Sweep region extends past bounds:** All regions are clamped to `[perpMin, perpMax]` at each iteration.
- **Only one valid region found:** The algorithm works correctly with 1 region — it just refines a single window instead of 5. This is common for simple rectangular parts where there's one clear best offset.
- **stepSize is not a power of two:** The halving produces steps like 4.0 → 2.0 → 1.0 → 0.5 → 0.25 regardless of whether `stepSize` is a power of two. The loop condition `currentStep > stepSize` terminates correctly because `currentStep` will eventually equal `stepSize` after enough halvings (since `CoarseMultiplier` is a power of 2).
@@ -1,163 +0,0 @@
# Nesting Progress Window Design
## Problem
The auto-nest and fill operations run synchronously on the UI thread, freezing the application until complete. The user has no visibility into what the engine is doing, no way to stop early, and no preview of intermediate results.
## Solution
Run nesting on a background thread with `IProgress<NestProgress>` callbacks. Show a modeless progress dialog with current-best stats and a Stop button. Render the current best layout as temporary parts on the PlateView in a distinct preview color.
## Progress Data Model
**New file: `OpenNest.Engine/NestProgress.cs`**
A class carrying progress updates from the engine to the UI:
- `Phase` (NestPhase enum): Current strategy — `Linear`, `RectBestFit`, `Pairs`, `Remainder`
- `PlateNumber` (int): Current plate number (for auto-nest multi-plate loop)
- `BestPartCount` (int): Part count of current best result
- `BestDensity` (double): Density percentage of current best
- `UsableRemnantArea` (double): Usable remnant area of current best (matches `FillScore.UsableRemnantArea`)
- `BestParts` (List\<Part\>): Cloned snapshot of the best parts for preview
`Phase` uses a `NestPhase` enum (defined in the same file) to prevent typos and allow the progress form to map to display-friendly text (e.g., `NestPhase.Pairs` → "Trying pairs...").
`BestParts` must be a cloned list (using `Part.Clone()`) so the UI thread can safely read it while the engine continues on the background thread. The clones share `BaseDrawing` references (not deep copies of drawings) since drawings are read-only templates during nesting. Progress is reported only after each phase completes (3-4 reports per fill call), so the cloning cost is negligible.
## Engine Changes
**Modified file: `OpenNest.Engine/NestEngine.cs`**
### Return type change
The new overloads return `List<Part>` instead of `bool`. They do **not** call `Plate.Parts.AddRange()` — the caller is responsible for committing parts to the plate. This is critical because:
1. The engine runs on a background thread and must not touch `Plate.Parts` (an `ObservableList` that fires UI events).
2. It cleanly separates the "compute" phase from the "commit" phase, allowing the UI to preview results as temporary parts before committing.
Existing `bool Fill(...)` overloads remain unchanged — they delegate to the new overloads and call `Plate.Parts.AddRange()` themselves, preserving current behavior for all existing callers (MCP tools, etc.).
### Fill overloads
New signatures:
- `List<Part> Fill(NestItem item, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
- `List<Part> Fill(List<Part> groupParts, Box workArea, IProgress<NestProgress> progress, CancellationToken token)`
**Note on `Fill(List<Part>, ...)` overload:** When `groupParts.Count > 1`, only the Linear phase runs (no RectBestFit, Pairs, or Remainder). The additional phases only apply when `groupParts.Count == 1`, matching the existing engine behavior.
Inside `FindBestFill`, after each strategy completes:
1. **Linear phase**: Try all rotation angles (already uses `Parallel.ForEach`). If new best found, report progress with `Phase=Linear`. Check cancellation token.
2. **RectBestFit phase**: If new best found, report progress with `Phase=RectBestFit`. Check cancellation token.
3. **Pairs phase**: Try all pair candidates (already uses `Parallel.For`). If new best found, report progress with `Phase=Pairs`. Check cancellation token.
4. **Remainder improvement**: If new best found, report progress with `Phase=Remainder`.
### Cancellation Behavior
On cancellation, the engine returns its current best result (not null/empty). `OperationCanceledException` is caught internally so the caller always gets a usable result. This enables "stop early, keep best result."
The `CancellationToken` is also passed into `ParallelOptions` for the existing `Parallel.ForEach` (linear phase) and `Parallel.For` (pairs phase) loops, so cancellation is responsive even mid-phase rather than only at phase boundaries.
## PlateView Temporary Parts
**Modified file: `OpenNest/Controls/PlateView.cs`**
Add a separate temporary parts list alongside the existing `parts` list:
```csharp
private List<LayoutPart> temporaryParts = new List<LayoutPart>();
```
### Drawing
In `DrawParts`, after drawing real parts, iterate `temporaryParts` and draw them using a distinct preview color. The preview color is added to `ColorScheme` (e.g., `PreviewPart`) so it follows the existing theming pattern. Same drawing logic, different pen/brush.
### Public API
- `SetTemporaryParts(List<Part> parts)` — Clears existing temp parts, builds `LayoutPart` wrappers from the provided parts, triggers redraw.
- `ClearTemporaryParts()` — Clears temp parts and redraws.
- `AcceptTemporaryParts()` — Adds the temp parts to the real `Plate.Parts` collection (which triggers quantity tracking via `ObservableList` events), then clears the temp list.
`AcceptTemporaryParts()` is the sole "commit" path. The engine never writes to `Plate.Parts` directly when using the progress overloads.
### Thread Safety
`SetTemporaryParts` is called from `IProgress<T>` callbacks. When using `Progress<T>` constructed on the UI thread, callbacks are automatically marshalled via `SynchronizationContext`. No extra marshalling needed.
## NestProgressForm (Modeless Dialog)
**New files: `OpenNest/Forms/NestProgressForm.cs`, `NestProgressForm.Designer.cs`, `NestProgressForm.resx`**
A small modeless dialog with a `TableLayoutPanel` layout:
```
┌──────────────────────────────┐
│ Phase: Trying pairs... │
│ Plate: 2 │
│ Parts: 156 │
│ Density: 68.3% │
│ Remnant: 0.0 sq in │
│ │
│ [ Stop ] │
└──────────────────────────────┘
```
Two-column `TableLayoutPanel`: left column is fixed-width labels, right column is auto-sized values. Stop button below the table.
The Plate row shows just the current plate number (no total — the total is not known in advance since it depends on how many parts fit per plate). The Plate row is hidden when running a single-plate fill.
### Behavior
- Opened via `form.Show(owner)` — modeless, stays on top of MainForm
- Receives `NestProgress` updates and refreshes labels
- Stop button triggers `CancellationTokenSource.Cancel()`, changes text to "Stopping..." (disabled)
- On engine completion (natural or cancelled), auto-closes or shows "Done" with Close button
- Closing via X button acts the same as Stop — cancels and accepts current best
## MainForm Integration
**Modified file: `OpenNest/Forms/MainForm.cs`**
### RunAutoNest_Click (auto-nest)
1. Show `AutoNestForm` dialog as before to get `NestItems`
2. Create `CancellationTokenSource`, `Progress<NestProgress>`, `NestProgressForm`
3. Open progress form modeless
4. `Task.Run` with the multi-plate loop. The background work computes results only — all UI/plate mutation happens on the UI thread via `Progress<T>` callbacks and the `await` continuation:
- The loop iterates items, calling the new `Fill(item, workArea, progress, token)` overloads which return `List<Part>` without modifying the plate.
- Progress callbacks update the preview via `SetTemporaryParts()` on the UI thread.
- When a plate's fill completes, the continuation (back on the UI thread) counts the returned parts per drawing (from the last `NestProgress.BestParts`) to decrement `NestItem.Quantity`, then calls `AcceptTemporaryParts()` to commit to the plate. `Nest.CreatePlate()` for the next plate also happens on the UI thread.
- On cancellation, breaks out of the loop and commits whatever was last previewed.
5. On completion, call `Nest.UpdateDrawingQuantities()`, close progress form
6. Disable nesting-related menu items while running, re-enable on completion
7. Dispose `CancellationTokenSource` when done
### ActionFillArea / single-plate Fill
Same pattern but simpler — no plate loop, single fill call with progress/cancellation. The progress form is created and owned by the code that launches the fill (in MainForm, not inside ActionFillArea). The Plate row is hidden. Escape key during the action cancels the token (same as clicking Stop).
### UI Lockout
While the engine runs, the user can pan/zoom the PlateView (read-only interaction) but editing actions (add/remove parts, change plates, plate navigation) are disabled. Plate navigation is locked during auto-nest to prevent the PlateView from switching away from the plate being filled. Re-enabled when nesting completes or is stopped.
If the user closes the `EditNestForm` (MDI child) while nesting is running, the cancellation token is triggered and the progress form is closed. No partial results are committed.
### Error Handling
The `Task.Run` body is wrapped in try/catch. If the engine throws an unexpected exception (not `OperationCanceledException`), the continuation shows a `MessageBox` with the error, clears temporary parts, and re-enables the UI. No partial results are committed on unexpected errors.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest.Engine/NestProgress.cs` | New — progress data model + `NestPhase` enum |
| `OpenNest.Engine/NestEngine.cs` | New `List<Part>`-returning overloads with `IProgress`/`CancellationToken` |
| `OpenNest/Controls/PlateView.cs` | Temporary parts list + drawing |
| `OpenNest/Forms/NestProgressForm.cs` (+Designer, +resx) | New — modeless progress dialog |
| `OpenNest/Forms/MainForm.cs` | Rewire auto-nest and fill to async with progress |
## Not Changed
OpenNest.Core, OpenNest.IO, OpenNest.Mcp, EditNestForm, existing engine callers (MCP tools, etc.). The existing `bool Fill(...)` overloads continue to work as before by delegating to the new overloads and calling `Plate.Parts.AddRange()` themselves.
@@ -1,94 +0,0 @@
# NFP Strategy in FindBestFill
## Problem
`NestEngine.FindBestFill()` currently runs three rectangle-based strategies (Linear, RectBestFit, Pairs) that treat parts as bounding boxes. For non-rectangular parts (L-shapes, circles, irregular profiles), this wastes significant plate area because the strategies can't interlock actual part geometry.
The NFP infrastructure already exists (used by `AutoNest`) but is completely separate from the single-drawing fill path.
## Solution
Add `FillNfpBestFit` as a new competing strategy in `FindBestFill()`. It uses the existing NFP/BLF infrastructure to place many copies of a single drawing using actual part geometry instead of bounding boxes. It only runs when the part is non-rectangular (where it can actually improve on grid packing).
## Design
### New method: `FillNfpBestFit(NestItem item, Box workArea)`
Located in `NestEngine.cs`, private method alongside `FillRectangleBestFit` and `FillWithPairs`.
**Algorithm:**
1. Compute `halfSpacing = Plate.PartSpacing / 2.0`
2. Extract the offset perimeter polygon via `ExtractPerimeterPolygon(drawing, halfSpacing)` (already exists as a private static method in NestEngine). Returns null if invalid — return empty list.
3. **Rectangularity gate:** compute `polygon.Area() / polygon.BoundingBox.Area()`. If ratio > 0.95, return empty list — grid strategies already handle rectangular parts optimally. Note: `BoundingBox` is a property (set by `UpdateBounds()` which `ExtractPerimeterPolygon` calls before returning).
4. Compute candidate rotation angles via `ComputeCandidateRotations(item, polygon, workArea)` (already exists in NestEngine — computes hull edge angles, adds 0° and 90°, adds narrow-area sweep). Then filter the results by `NestItem.RotationStart` / `NestItem.RotationEnd` window (keep angles where `RotationStart <= angle <= RotationEnd`; if both are 0, treat as unconstrained). This filtering is applied locally after `ComputeCandidateRotations` returns — the shared method is not modified, so `AutoNest` behavior is unchanged.
5. Build an `NfpCache`:
- For each candidate rotation, rotate the polygon via `RotatePolygon()` and register it via `nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon)`
- Call `nfpCache.PreComputeAll()` — since all entries share the same drawing ID, this computes NFPs between all rotation pairs of the single part shape
6. For each candidate rotation, run `BottomLeftFill.Fill()`:
- Build a sequence of N copies of `(drawing.Id, rotation, drawing)`
- N = `(int)(workArea.Area() / polygon.Area())`, capped to 500 max, and further capped to `item.Quantity` when Quantity > 0 (avoids wasting BLF cycles on parts that will be discarded)
- Convert BLF result via `BottomLeftFill.ToNestParts()` to get `List<Part>`
- Score via `FillScore.Compute(parts, workArea)`
7. Return the parts list from the highest-scoring rotation
### Integration points
**Both `FindBestFill` overloads** — insert after the Pairs phase, before remainder improvement:
```csharp
// NFP phase (non-rectangular parts only)
var nfpResult = FillNfpBestFit(item, workArea);
Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts");
if (IsBetterFill(nfpResult, best, workArea))
{
best = nfpResult;
ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea);
}
```
The progress-reporting overload also adds `token.ThrowIfCancellationRequested()` before the NFP phase.
**`Fill(List<Part> groupParts, ...)` overload** — this method runs its own RectBestFit and Pairs phases inline when `groupParts.Count == 1`, bypassing `FindBestFill`. Add the NFP phase here too, after Pairs and before remainder improvement, following the same pattern.
### NestPhase enum
Add `Nfp` after `Pairs`:
```csharp
public enum NestPhase
{
Linear,
RectBestFit,
Pairs,
Nfp,
Remainder
}
```
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/NestEngine.cs` | Add `FillNfpBestFit()` method; call from both `FindBestFill` overloads and the `Fill(List<Part>, ...)` single-drawing path, after Pairs phase |
| `OpenNest.Engine/NestProgress.cs` | Add `Nfp` to `NestPhase` enum |
## What Doesn't Change
- `FillBestFit`, `FillLinear`, `FillWithPairs` — untouched
- `AutoNest` — separate code path, untouched
- `BottomLeftFill`, `NfpCache`, `NoFitPolygon`, `InnerFitPolygon` — reused as-is, no modifications
- `ComputeCandidateRotations`, `ExtractPerimeterPolygon`, `RotatePolygon` — reused as-is
- UI callers (`ActionFillArea`, `ActionClone`, `PlateView.FillWithProgress`) — no changes
- MCP tools (`NestingTools`) — no changes
## Edge Cases
- **Part with no valid perimeter polygon:** `ExtractPerimeterPolygon` returns null → return empty list
- **Rectangularity ratio > 0.95:** skip NFP entirely, grid strategies are optimal
- **All rotations filtered out by constraints:** no BLF runs → return empty list
- **BLF places zero parts at a rotation:** skip that rotation, try others
- **Very small work area where part doesn't fit:** IFP computation returns invalid polygon → BLF places nothing → return empty list
- **Large plate with small part:** N capped to 500 to keep BLF O(N^2) cost manageable
- **item.Quantity is set:** N further capped to Quantity to avoid placing parts that will be discarded
@@ -1,218 +0,0 @@
# ML Angle Pruning Design
**Date:** 2026-03-14
**Status:** Draft
## Problem
The nesting engine's biggest performance bottleneck is `FillLinear.FillRecursive`, which consumes ~66% of total CPU time. The linear phase builds a list of rotation angles to try — normally just 2 (`bestRotation` and `bestRotation + 90`), but expanding to a full 36-angle sweep (0-175 in 5-degree increments) when the work area's short side is smaller than the part's longest side. This narrow-work-area condition triggers frequently during remainder-strip fills and for large/elongated parts. Each angle x 2 directions requires expensive ray/edge distance calculations for every tile placement.
## Goal
Train an ML model that predicts which rotation angles are competitive for a given part geometry and sheet size. At runtime, replace the full angle sweep with only the predicted angles, reducing linear phase compute time in the narrow-work-area case. The model only applies when the engine would otherwise sweep all 36 angles — for the normal 2-angle case, no change is needed.
## Design
### Training Data Collection
#### Forced Full Sweep for Training
In production, `FindBestFill` only sweeps all 36 angles when `workAreaShortSide < partLongestSide`. For training, the sweep must be forced for every part x sheet combination regardless of this condition — otherwise the model has no data to learn from for the majority of runs that only evaluate 2 angles.
`NestEngine` gains a `ForceFullAngleSweep` property (default `false`). When `true`, `FindBestFill` always builds the full 0-175 angle list. The training runner sets this to `true`; production code leaves it `false`.
#### Per-Angle Results from NestEngine
Instrument `NestEngine.FindBestFill` to collect per-angle results from the linear phase. Each call to `FillLinear.Fill(drawing, angle, direction)` produces a result that is currently only compared against the running best. With this change, each result is also accumulated into a collection on the engine instance.
New types in `NestProgress.cs`:
```csharp
public class AngleResult
{
public double AngleDeg { get; set; }
public NestDirection Direction { get; set; }
public int PartCount { get; set; }
}
```
New properties on `NestEngine`:
```csharp
public bool ForceFullAngleSweep { get; set; }
public List<AngleResult> AngleResults { get; } = new();
```
`AngleResults` is cleared at the start of `Fill` (alongside `PhaseResults.Clear()`). Populated inside the `Parallel.ForEach` over angles in `FindBestFill` — uses a `ConcurrentBag<AngleResult>` during the parallel loop, then transferred to `AngleResults` via `AddRange` after the loop completes (same pattern as the existing `linearBag`).
#### Progress Window Enhancement
`NestProgress` gains a `Description` field — a freeform status string that the progress window displays directly:
```csharp
public class NestProgress
{
// ... existing fields ...
public string Description { get; set; }
}
```
Progress is reported per-angle during the linear phase (e.g. `"Linear: 35 V - 48 parts"`) and per-candidate during the pairs phase (e.g. `"Pairs: candidate 12/50"`). This gives real-time visibility into what the engine is doing, beyond the current phase-level updates.
#### BruteForceRunner Changes
`BruteForceRunner.Run` reads `engine.AngleResults` after `Fill` completes and passes them through `BruteForceResult`:
```csharp
public class BruteForceResult
{
// ... existing fields ...
public List<AngleResult> AngleResults { get; set; }
}
```
The training runner sets `engine.ForceFullAngleSweep = true` before calling `Fill`.
#### Database Schema
New `AngleResults` table:
| Column | Type | Description |
|-----------|---------|--------------------------------------|
| Id | long | PK, auto-increment |
| RunId | long | FK to Runs table |
| AngleDeg | double | Rotation angle in degrees (0-175) |
| Direction | string | "Horizontal" or "Vertical" |
| PartCount | int | Parts placed at this angle/direction |
Each run produces up to ~72 rows (36 angles x 2 directions, minus angles where zero parts fit). With forced full sweep during training: 41k parts x 11 sheet sizes x ~72 angle results = ~32 million rows. SQLite handles this for batch writes; SQL Express on barge.lan is available as a fallback if needed.
New EF Core entity `TrainingAngleResult` in `OpenNest.Training/Data/`. `TrainingDatabase.AddRun` is extended to accept and batch-insert angle results alongside the run.
Migration: `MigrateSchema` creates the `AngleResults` table if it doesn't exist. Existing databases without the table continue to work — the table is created on first use.
### Model Architecture
**Type:** XGBoost multi-label classifier exported to ONNX.
**Input features (11 scalars):**
- Part geometry (7): Area, Convexity, AspectRatio, BBFill, Circularity, PerimeterToAreaRatio, VertexCount
- Sheet dimensions (2): Width, Height
- Derived (2): SheetAspectRatio (Width/Height), PartToSheetAreaRatio (PartArea / SheetArea)
The 32x32 bitmask is excluded from the initial model. The 7 scalar geometry features capture sufficient shape information for angle prediction. Bitmask can be added later if accuracy needs improvement.
**Output:** 36 probabilities, one per 5-degree angle bin (0, 5, 10, ..., 175). Each probability represents "this angle is competitive for this part/sheet combination."
**Label generation:** For each part x sheet run, an angle is labeled positive (1) if its best PartCount (max of H and V directions) is >= 95% of the overall best angle's PartCount for that run. This creates a multi-label target where typically 2-8 angles are labeled positive.
**Direction handling:** The model predicts angles only. Both H and V directions are always tried for each selected angle — direction computation is cheap relative to the angle setup.
### Training Pipeline
Python notebook at `OpenNest.Training/notebooks/train_angle_model.ipynb`:
1. **Extract** — Read SQLite database, join Parts + Runs + AngleResults into a flat dataframe.
2. **Filter** — Remove title block outliers using feature thresholds (e.g. BBFill < 0.01, abnormally large bounding boxes relative to actual geometry area). Log filtered parts for manual review.
3. **Label** — For each run, compute the best angle's PartCount. Mark angles within 95% as positive. Build a 36-column binary label matrix.
4. **Feature engineering** — Compute derived features (SheetAspectRatio, PartToSheetAreaRatio). Normalize if needed.
5. **Train** — XGBoost multi-label classifier. Use `sklearn.multioutput.MultiOutputClassifier` wrapping `xgboost.XGBClassifier`. Train/test split stratified by part (all sheet sizes for a part stay in the same split).
6. **Evaluate** — Primary metric: per-angle recall > 95% (must almost never skip the winning angle). Secondary: precision > 60% (acceptable to try a few extra angles). Report average angles predicted per part.
7. **Export** — Convert to ONNX via `skl2onnx` or `onnxmltools`. Save to `OpenNest.Engine/Models/angle_predictor.onnx`.
Python dependencies: `pandas`, `scikit-learn`, `xgboost`, `onnxmltools` (or `skl2onnx`), `matplotlib` (for evaluation plots).
### C# Inference Integration
New file `OpenNest.Engine/ML/AnglePredictor.cs`:
```csharp
public static class AnglePredictor
{
public static List<double> PredictAngles(
PartFeatures features, double sheetWidth, double sheetHeight);
}
```
- Loads `angle_predictor.onnx` from the `Models/` directory adjacent to the Engine DLL on first call. Caches the ONNX session for reuse.
- Runs inference with the 11 input features.
- Applies threshold (default 0.3) to the 36 output probabilities.
- Returns angles above threshold, converted to radians.
- Always includes 0 and 90 degrees as safety fallback.
- Minimum 3 angles returned (if fewer pass threshold, take top 3 by probability).
- If the model file is missing or inference fails, returns `null` — caller falls back to trying all angles (current behavior unchanged).
**NuGet dependency:** `Microsoft.ML.OnnxRuntime` added to `OpenNest.Engine.csproj`.
### NestEngine Integration
In `FindBestFill` (the progress/token overload), the angle list construction changes:
```
Current:
angles = [bestRotation, bestRotation + 90]
+ sweep 0-175 if narrow work area
With model (only when narrow work area condition is met):
predicted = AnglePredictor.PredictAngles(features, sheetW, sheetH)
if predicted != null:
angles = predicted
+ bestRotation and bestRotation + 90 (if not already included)
else:
angles = current behavior (full sweep)
ForceFullAngleSweep = true (training only):
angles = full 0-175 sweep regardless of work area condition
```
`FeatureExtractor.Extract(drawing)` is called once per drawing before the fill loop. This is cheap (~0ms) and already exists.
**Note:** The Pairs phase (`FillWithPairs`) uses hull-edge angles from each pair candidate's geometry, not the linear angle list. The ML model does not affect the Pairs phase angle selection. Pairs phase optimization (e.g. pruning pair candidates) is a separate future concern.
### Fallback and Safety
- **No model file:** Full angle sweep (current behavior). Zero regression risk.
- **Model loads but prediction fails:** Full angle sweep. Logged to Debug output.
- **Model predicts too few angles:** Minimum 3 angles enforced. 0, 90, bestRotation, and bestRotation + 90 always included.
- **Normal 2-angle case (no narrow work area):** Model is not consulted — the engine only tries bestRotation and bestRotation + 90 as it does today.
- **Model misses the optimal angle:** Recall target of 95% means ~5% of runs may not find the absolute best. The result will still be good (within 95% of optimal by definition of the training labels). Users can disable the model via a setting if needed.
## Files Changed
### OpenNest.Engine
- `NestProgress.cs` — Add `AngleResult` class, add `Description` to `NestProgress`
- `NestEngine.cs` — Add `ForceFullAngleSweep` and `AngleResults` properties, clear `AngleResults` alongside `PhaseResults`, populate per-angle results in `FindBestFill` via `ConcurrentBag` + `AddRange`, report per-angle progress with descriptions, use `AnglePredictor` for angle selection when narrow work area
- `ML/BruteForceRunner.cs` — Pass through `AngleResults` from engine
- `ML/AnglePredictor.cs` — New: ONNX model loading and inference
- `ML/FeatureExtractor.cs` — No changes (already exists)
- `Models/angle_predictor.onnx` — New: trained model file (added after training)
- `OpenNest.Engine.csproj` — Add `Microsoft.ML.OnnxRuntime` NuGet package
### OpenNest.Training
- `Data/TrainingAngleResult.cs` — New: EF Core entity for AngleResults table
- `Data/TrainingDbContext.cs` — Add `DbSet<TrainingAngleResult>`
- `Data/TrainingRun.cs` — No changes
- `TrainingDatabase.cs` — Add angle result storage, extend `MigrateSchema`
- `Program.cs` — Set `ForceFullAngleSweep = true` on engine, collect and store per-angle results from `BruteForceRunner`
### OpenNest.Training/notebooks (new directory)
- `train_angle_model.ipynb` — Training notebook
- `requirements.txt` — Python dependencies
### OpenNest (WinForms)
- Progress window UI — Display `NestProgress.Description` string (minimal change)
## Data Volume Estimates
- 41k parts x 11 sheet sizes = ~450k runs
- With forced full sweep: ~72 angle results per run = ~32 million angle result rows
- SQLite can handle this for batch writes. SQL Express on barge.lan available as fallback.
- Trained model file: ~1-5 MB ONNX
## Success Criteria
- Per-angle recall > 95% (almost never skips the winning angle)
- Average angles predicted: 4-8 per part (down from 36)
- Linear phase speedup in narrow-work-area case: 70-80% reduction
- Zero regression when model is absent — current behavior preserved exactly
- Progress window shows live angle/candidate details during nesting
@@ -1,195 +0,0 @@
# Abstract Nest Engine Design Spec
**Date:** 2026-03-15
**Goal:** Create a pluggable nest engine architecture so users can create custom nesting algorithms, switch between engines globally, and load third-party engines as plugins.
---
## Motivation
The current `NestEngine` is a concrete class with a sophisticated multi-phase fill strategy (Linear, Pairs, RectBestFit, Remainder). Different part geometries benefit from different algorithms — circles need circle-packing, strip-based layouts work better for mixed-drawing nests, and users may want to experiment with their own approaches. The engine needs to be swappable without changing the UI or other consumers.
## Architecture Overview
```
NestEngineBase (abstract, OpenNest.Engine)
├── DefaultNestEngine (current multi-phase logic)
├── StripNestEngine (strip-based multi-drawing nesting)
├── CircleNestEngine (future, circle-packing)
└── [Plugin engines loaded from DLLs]
NestEngineRegistry (static, OpenNest.Engine)
├── Tracks available engines (built-in + plugins)
├── Manages active engine selection (global)
└── Factory method: Create(Plate) → NestEngineBase
```
**Note on AutoNester:** The existing `AutoNester` static class (NFP + simulated annealing for mixed parts) is a natural future candidate for the registry but is currently unused by any caller. It is out of scope for this refactor — it can be wrapped as an engine later when it's ready for use.
## NestEngineBase
Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods.
**Instance lifetime:** Engine instances are short-lived and plate-specific — created per operation via the registry factory. Some engines (like `DefaultNestEngine`) maintain internal state across multiple `Fill` calls on the same instance (e.g., `knownGoodAngles` for angle pruning). Plugin authors should be aware that a single engine instance may receive multiple `Fill` calls within one nesting session.
### Properties
| Property | Type | Notes |
|----------|------|-------|
| `Plate` | `Plate` | The plate being nested |
| `PlateNumber` | `int` | For progress reporting |
| `NestDirection` | `NestDirection` | Fill direction preference, set by callers after creation |
| `WinnerPhase` | `NestPhase` | Which phase produced the best result (protected set) |
| `PhaseResults` | `List<PhaseResult>` | Per-phase results for diagnostics |
| `AngleResults` | `List<AngleResult>` | Per-angle results for diagnostics |
### Abstract Members
| Member | Type | Purpose |
|--------|------|---------|
| `Name` | `string` (get) | Display name for UI/registry |
| `Description` | `string` (get) | Human-readable description |
### Virtual Methods (return parts, no side effects)
These are the core methods subclasses override. Base class default implementations return empty lists — subclasses override the ones they support.
```csharp
virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
```
**`FillExact` is non-virtual.** It is orchestration logic (binary search wrapper around `Fill`) that works regardless of the underlying fill algorithm. It lives in the base class and calls the virtual `Fill` method. Any engine that implements `Fill` gets `FillExact` for free.
**`PackArea` signature change:** The current `PackArea(Box, List<NestItem>)` mutates the plate directly and returns `bool`. The new virtual method adds `IProgress<NestProgress>` and `CancellationToken` parameters and returns `List<Part>` (side-effect-free). This is a deliberate refactor — the old mutating behavior moves to the convenience overload `Pack(List<NestItem>)`.
### Convenience Overloads (non-virtual, add parts to plate)
These call the virtual methods and handle plate mutation:
```csharp
bool Fill(NestItem item)
bool Fill(NestItem item, Box workArea)
bool Fill(List<Part> groupParts)
bool Fill(List<Part> groupParts, Box workArea)
bool Pack(List<NestItem> items)
```
Pattern: call the virtual method → if parts returned → add to `Plate.Parts` → return `true`.
### Protected Utilities
Available to all subclasses:
- `ReportProgress(IProgress<NestProgress>, NestPhase, int plateNumber, List<Part>, Box, string)` — clone parts and report
- `BuildProgressSummary()` — format PhaseResults into a status string
- `IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)` — FillScore comparison
- `IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)` — with overlap check
## DefaultNestEngine
Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all virtual methods with the existing multi-phase logic.
- `Name``"Default"`
- `Description``"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"`
- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, `QuickFillCount`, etc.) remain as private methods in this class
- `ForceFullAngleSweep` property stays on `DefaultNestEngine` (not the base class) — only used by `BruteForceRunner` which references `DefaultNestEngine` directly
- `knownGoodAngles` HashSet stays as a private field — accumulates across multiple `Fill` calls for angle pruning
- No behavioral change — purely structural refactor
## StripNestEngine
The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase` subclass instead of a standalone class.
- `Name``"Strip"`
- `Description``"Strip-based nesting for mixed-drawing layouts"`
- Overrides `Fill` for multi-item scenarios with its strip+remnant strategy
- Uses `DefaultNestEngine` internally as a building block for individual strip/remnant fills (composition, not inheritance from Default)
## NestEngineRegistry
Static class in `OpenNest.Engine` managing engine discovery and selection. Accessed only from the UI thread — not thread-safe. Engines are created per-operation and used on background threads, but the registry itself is only mutated/queried from the UI thread at startup and when the user changes the active engine.
### NestEngineInfo
```csharp
class NestEngineInfo
{
string Name { get; }
string Description { get; }
Func<Plate, NestEngineBase> Factory { get; }
}
```
### API
| Member | Purpose |
|--------|---------|
| `List<NestEngineInfo> AvailableEngines` | All registered engines |
| `string ActiveEngineName` | Currently selected engine (defaults to `"Default"`) |
| `NestEngineBase Create(Plate plate)` | Creates instance of active engine |
| `void Register(string name, string description, Func<Plate, NestEngineBase> factory)` | Register a built-in engine |
| `void LoadPlugins(string directory)` | Scan DLLs for NestEngineBase subclasses |
### Built-in Registration
```csharp
Register("Default", "Multi-phase nesting...", plate => new DefaultNestEngine(plate));
Register("Strip", "Strip-based nesting...", plate => new StripNestEngine(plate));
```
### Plugin Discovery
Follows the existing `IPostProcessor` pattern from `Posts/`:
- Scan `Engines/` directory next to the executable for DLLs
- Reflect over types, find concrete subclasses of `NestEngineBase`
- Require a constructor taking `Plate`
- Register each with its `Name` and `Description` properties
- Called at application startup alongside post-processor loading (WinForms app only — Console and MCP use built-in engines only)
**Error handling:**
- DLLs that fail to load (bad assembly, missing dependencies) are logged and skipped
- Types without a `Plate` constructor are skipped
- Duplicate engine names: first registration wins, duplicates are logged and skipped
- Exceptions from plugin constructors during `Create()` are caught and surfaced to the caller
## Callsite Migration
All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`:
| Location | Count | Notes |
|----------|-------|-------|
| `MainForm.cs` | 3 | Auto-nest fill, auto-nest pack, single-drawing fill plate |
| `ActionFillArea.cs` | 2 | |
| `PlateView.cs` | 1 | |
| `NestingTools.cs` (MCP) | 6 | |
| `Program.cs` (Console) | 3 | |
| `BruteForceRunner.cs` | 1 | **Keep as `new DefaultNestEngine(plate)`** — training data must come from the known algorithm |
## UI Integration
- Global engine selector: combobox or menu item bound to `NestEngineRegistry.AvailableEngines`
- Changing selection sets `NestEngineRegistry.ActiveEngineName`
- No per-plate engine state — global setting applies to all subsequent operations
- Plugin directory: `Engines/` next to executable, loaded at startup
## File Summary
| Action | File | Project |
|--------|------|---------|
| Create | `NestEngineBase.cs` | OpenNest.Engine |
| Rename/Modify | `NestEngine.cs``DefaultNestEngine.cs` | OpenNest.Engine |
| Create | `NestEngineRegistry.cs` | OpenNest.Engine |
| Create | `NestEngineInfo.cs` | OpenNest.Engine |
| Modify | `StripNester.cs``StripNestEngine.cs` | OpenNest.Engine |
| Modify | `MainForm.cs` | OpenNest |
| Modify | `ActionFillArea.cs` | OpenNest |
| Modify | `PlateView.cs` | OpenNest |
| Modify | `NestingTools.cs` | OpenNest.Mcp |
| Modify | `Program.cs` | OpenNest.Console |
@@ -1,96 +0,0 @@
# FillExact — Exact-Quantity Fill with Binary Search
## Problem
The current `NestEngine.Fill` fills an entire work area and truncates to `item.Quantity` with `.Take(n)`. This wastes plate space — parts are spread across the full area, leaving no usable remainder strip for subsequent drawings in AutoNest.
## Solution
Add a `FillExact` method that binary-searches for the smallest sub-area of the work area that fits exactly the requested quantity. This packs parts tightly against one edge, maximizing the remainder strip available for the next drawing.
## Coordinate Conventions
`Box.Width` is the X-axis extent. `Box.Length` is the Y-axis extent. The box is anchored at `(Box.X, Box.Y)` (bottom-left corner).
- **Shrink width** means reducing `Box.Width` (X-axis), producing a narrower box anchored at the left edge. The remainder strip extends to the right.
- **Shrink length** means reducing `Box.Length` (Y-axis), producing a shorter box anchored at the bottom edge. The remainder strip extends upward.
## Algorithm
1. **Early exits:**
- Quantity is 0 (unlimited): delegate to `Fill` directly.
- Quantity is 1: delegate to `Fill` directly (a single part placement doesn't benefit from area search).
2. **Full fill** — Call `Fill(item, workArea, progress, token)` to establish the upper bound (max parts that fit). This call gets progress reporting so the user sees the phases running.
3. **Already exact or under** — If `fullCount <= quantity`, return the full fill result. The plate can't fit more than requested anyway.
4. **Estimate starting point** — Calculate an initial dimension estimate assuming 50% utilization: `estimatedDim = (partArea * quantity) / (0.5 * fixedDim)`, clamped to at least the part's bounding box dimension in that axis.
5. **Binary search** (max 8 iterations, or until `high - low < partSpacing`) — Keep one dimension of the work area fixed and binary-search on the other:
- `low = estimatedDim`, `high = workArea dimension`
- Each iteration: create a test box, call `Fill(item, testBox, null, token)` (no progress — search iterations are silent), check count.
- `count >= quantity` → record result, shrink: `high = mid`
- `count < quantity` → expand: `low = mid`
- Check cancellation token between iterations; if cancelled, return best found so far.
6. **Try both orientations** — Run the binary search twice: once shrinking length (fixed width) and once shrinking width (fixed length).
7. **Pick winner** — Compare by test box area (`testBox.Width * testBox.Length`). Return whichever orientation's result has a smaller test box area, leaving more remainder for subsequent drawings. Tie-break: prefer shrink-length (leaves horizontal remainder strip, generally more useful on wide plates).
## Method Signature
```csharp
// NestEngine.cs
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
```
Returns exactly `item.Quantity` parts packed into the smallest sub-area of `workArea`, or fewer if they don't all fit.
## Internal Helper
```csharp
private (List<Part> parts, double usedDim) BinarySearchFill(
NestItem item, Box workArea, bool shrinkWidth,
CancellationToken token)
```
Performs the binary search for one orientation. Returns the parts and the dimension value at which the exact quantity was achieved. Progress is not passed to inner Fill calls — the search iterations run silently.
## Engine State
Each inner `Fill` call clears `PhaseResults`, `AngleResults`, and overwrites `WinnerPhase`. After the winning Fill call is identified, `FillExact` runs the winner one final time with `progress` so:
- `PhaseResults` / `AngleResults` / `WinnerPhase` reflect the winning fill.
- The progress form shows the final result.
## Integration
### AutoNest (MainForm.RunAutoNest_Click)
Replace `engine.Fill(item, workArea, progress, token)` with `engine.FillExact(item, workArea, progress, token)` for multi-quantity items. The tighter packing means `ComputeRemainderStrip` returns a larger box for subsequent drawings.
### Single-drawing Fill
`FillExact` works for single-drawing fills too. When `item.Quantity` is set, the caller gets a tight layout instead of parts scattered across the full plate.
### Fallback
When `item.Quantity` is 0 (unlimited), `FillExact` falls through to the standard `Fill` behavior — fill the entire work area.
## Performance Notes
The binary search converges in at most 8 iterations per orientation. Each iteration calls `Fill` internally, which runs the pairs/linear/best-fit phases. For a typical auto-nest scenario:
- Full fill: 1 call (with progress)
- Shrink-length search: ~6-8 calls (silent)
- Shrink-width search: ~6-8 calls (silent)
- Final re-fill of winner: 1 call (with progress)
- Total: ~15-19 Fill calls per drawing
The inner `Fill` calls for reduced work areas are faster than full-plate fills since the search space is smaller. The `BestFitCache` (used by the pairs phase) is keyed on the full plate size, so it stays warm across iterations — only the linear/rect phases re-run.
Early termination (`high - low < partSpacing`) typically cuts 1-3 iterations, bringing the total closer to 12-15 calls.
## Edge Cases
- **Quantity 0 (unlimited):** Skip binary search, delegate to `Fill` directly.
- **Quantity 1:** Skip binary search, delegate to `Fill` directly.
- **Full fill already exact:** Return immediately without searching.
- **Part doesn't fit at all:** Return empty list.
- **Binary search can't hit exact count** (e.g., jumps from N-1 to N+2): Take the smallest test box where `count >= quantity` and truncate with `.Take(quantity)`.
- **Cancellation:** Check token between iterations. Return best result found so far.
@@ -1,135 +0,0 @@
# NestProgressForm Redesign
## Problem
The current `NestProgressForm` is a flat list of label/value pairs with no visual hierarchy, no progress indicator, and default WinForms styling. It's functional but looks basic and gives no sense of where the engine is in its process.
## Solution
Redesign the form with three changes:
1. A custom-drawn **phase stepper** control showing which nesting phases have been visited
2. **Grouped sections** separating results from status information
3. **Modern styling** — Segoe UI fonts, subtle background contrast, better spacing
## Phase Stepper Control
**New file: `OpenNest/Controls/PhaseStepperControl.cs`**
A custom `UserControl` that draws 4 circles with labels beneath, connected by lines:
```
●━━━━━━━●━━━━━━━○━━━━━━━○
Linear BestFit Pairs Remainder
```
### Non-sequential design
The engine does **not** execute phases in a fixed order. `FindBestFill` runs Pairs → Linear → BestFit → Remainder, while the group fill path runs Linear → BestFit → Pairs → Remainder. Some phases may not execute at all (e.g., multi-part fills only run Linear).
The stepper therefore tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress, regardless of position. The connecting lines between circles are purely decorative (always light gray) — they do not indicate sequential flow.
### Visual States
- **Completed/visited:** Filled circle with accent color, bold label — the phase has reported at least one progress update
- **Active:** Filled circle with accent color and slightly larger radius, bold label — the phase currently executing
- **Pending:** Hollow circle with border only, dimmed label text — the phase has not yet reported progress
- **Skipped:** Same as Pending — phases that never execute simply remain hollow. No special "skipped" visual needed.
- **All complete:** All 4 circles filled (used when `ShowCompleted()` is called)
- **Initial state (before first `UpdateProgress`):** All 4 circles in Pending (hollow) state
### Implementation
- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray.
- Colors and fonts defined as `static readonly` fields at the top of the class. Fonts are cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates.
- Tracks state via a `HashSet<NestPhase> VisitedPhases` and a `NestPhase? ActivePhase` property. When `ActivePhase` is set, it is added to `VisitedPhases` and `Invalidate()` is called. A `bool IsComplete` property marks all phases as done.
- `DoubleBuffered = true` to prevent flicker on repaint.
- Fixed height (~60px), docks to fill width.
- Namespace: `OpenNest.Controls` (follows existing convention, e.g., `QuadrantSelect`).
## Form Layout
Three vertical zones using `DockStyle.Top` stacking:
```
┌─────────────────────────────────────┐
│ ●━━━━━━━●━━━━━━━○━━━━━━━○ │ Phase stepper
│ Linear BestFit Pairs Remainder │
├─────────────────────────────────────┤
│ Results │ Results group
│ Parts: 156 │
│ Density: 68.3% │
│ Nested: 24.1 x 36.0 (867.6 sq in)│
│ Unused: 43.2 sq in │
├─────────────────────────────────────┤
│ Status │ Status group
│ Plate: 2 │
│ Elapsed: 1:24 │
│ Detail: Trying 45° rotation... │
├─────────────────────────────────────┤
│ [ Stop ] │ Button bar
└─────────────────────────────────────┘
```
### Group Panels
Each group is a `Panel` containing:
- A header label (Segoe UI 9pt bold) at the top
- A `TableLayoutPanel` with label/value rows beneath
Group panels use `Color.White` (or very light gray) `BackColor` against the form's `SystemColors.Control` background to create visual separation without borders. Small padding/margins between groups.
### Typography
- All fonts: Segoe UI (replaces MS Sans Serif)
- Group headers: 9pt bold
- Row labels: 8.25pt bold
- Row values: 8.25pt regular
- Value labels use `ForeColor = SystemColors.ControlText`
### Sizing
- Width: ~450px (slightly wider than current 425px for breathing room)
- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~110px) + status group (~90px) + button bar (~45px) + padding. The form uses `FixedToolWindow` which does not auto-resize, so the height is set explicitly in the designer.
- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false`
### Plate Row Visibility
The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior).
### Phase description text
The current form's `FormatPhase()` method produces friendly text like "Trying rotations..." which was displayed in the Phase row. Since the phase stepper replaces the Phase row visually, this descriptive text moves to the **Detail** row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when it's set (the engine's per-iteration descriptions like "Linear: 3/12 angles" take precedence).
## Public API
No signature changes. The form remains a drop-in replacement.
### Constructor
`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged.
### UpdateProgress(NestProgress progress)
Same as today, plus:
- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper
- Writes `FormatPhase(progress.Phase)` to the Detail row as a fallback when `progress.Description` is empty
### ShowCompleted()
Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles.
Note: `MainForm.FillArea_Click` currently calls `progressForm.Close()` without calling `ShowCompleted()` first. This is existing behavior and is fine — the form closes immediately so the "all complete" visual is not needed in that path.
## No External Changes
- `NestProgress` and `NestPhase` are unchanged.
- All callers (`MainForm`, `PlateView.FillWithProgress`) continue calling `UpdateProgress` and `ShowCompleted` with no code changes.
- The form file paths remain the same — this is a modification, not a new form.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control |
| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration |
| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout |
@@ -1,329 +0,0 @@
# Plate Processor Design — Per-Part Lead-In Assignment & Cut Sequencing
## Overview
Add a plate-level orchestrator (`PlateProcessor`) to `OpenNest.Engine` that sequences parts across a plate, assigns lead-ins per-part based on approach direction, and plans safe rapid paths between parts. This replaces the current `ContourCuttingStrategy` usage model where the exit point is derived from the plate corner alone — instead, each part's lead-in pierce point is computed from the actual approach direction (the previous part's last cut point).
The motivation is laser head safety: on a CL-980 fiber laser, head-down rapids are significantly faster than raising the head, but traversing over already-cut areas risks collision with tipped-up slugs. The orchestrator must track cut areas and choose safe rapid paths.
## Architecture
Three pipeline stages, wired by a thin orchestrator:
```
IPartSequencer → ContourCuttingStrategy → IRapidPlanner
↓ ↓ ↓
ordered parts lead-ins applied safe rapid paths
└──────────── PlateProcessor ─────────────┘
```
All new code lives in `OpenNest.Engine/` except the `ContourCuttingStrategy` signature change and `Part.HasManualLeadIns` flag which are in `OpenNest.Core`.
## Model Changes
### Part (OpenNest.Core)
Add a flag to indicate the user has manually assigned lead-ins to this part:
```csharp
public bool HasManualLeadIns { get; set; }
```
When `true`, the orchestrator skips `ContourCuttingStrategy.Apply()` for this part and uses the program as-is.
### ContourCuttingStrategy (OpenNest.Core)
Change the `Apply` signature to accept an approach point instead of a plate:
```csharp
// Before
public Program Apply(Program partProgram, Plate plate)
// After
public CuttingResult Apply(Program partProgram, Vector approachPoint)
```
Remove `GetExitPoint(Plate)` — the caller provides the approach point in part-local coordinates.
### CuttingResult (OpenNest.Core, namespace OpenNest.CNC.CuttingStrategy)
New readonly struct returned by `ContourCuttingStrategy.Apply()`. Lives in `CNC/CuttingStrategy/CuttingResult.cs`:
```csharp
public readonly struct CuttingResult
{
public Program Program { get; init; }
public Vector LastCutPoint { get; init; }
}
```
- `Program`: the program with lead-ins/lead-outs applied.
- `LastCutPoint`: where the last contour cut ends, in part-local coordinates. The orchestrator transforms this to plate coordinates to compute the approach point for the next part.
## Stage 1: IPartSequencer
### Interface
```csharp
namespace OpenNest.Engine
{
public interface IPartSequencer
{
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}
```
### SequencedPart
```csharp
public readonly struct SequencedPart
{
public Part Part { get; init; }
}
```
The sequencer only determines cut order. Approach points are computed by the orchestrator as it loops, since each part's approach point depends on the previous part's `CuttingResult.LastCutPoint`.
### Implementations
One class per `SequenceMethod`. All live in `OpenNest.Engine/Sequencing/`.
| Class | SequenceMethod | Algorithm |
|-------|---------------|-----------|
| `RightSideSequencer` | RightSide | Sort parts by X descending (rightmost first) |
| `LeftSideSequencer` | LeftSide | Sort parts by X ascending (leftmost first) |
| `BottomSideSequencer` | BottomSide | Sort parts by Y ascending (bottom first) |
| `LeastCodeSequencer` | LeastCode | Nearest-neighbor from exit point, then 2-opt improvement |
| `AdvancedSequencer` | Advanced | Nearest-neighbor with row/column grouping from `SequenceParameters` |
| `EdgeStartSequencer` | EdgeStart | Sort by distance from nearest plate edge, closest first |
#### Directional sequencers (RightSide, LeftSide, BottomSide)
Sort parts by their bounding box center along the relevant axis. Ties broken by the perpendicular axis. These are simple positional sorts — no TSP involved.
#### LeastCodeSequencer
1. Start from the plate exit point.
2. Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position.
3. 2-opt improvement: iterate over the sequence, try swapping pairs. If total travel distance decreases, keep the swap. Repeat until no improvement found (or max iterations).
#### AdvancedSequencer
Uses `SequenceParameters` to group parts into rows/columns based on `MinDistanceBetweenRowsColumns`, then sequences within each group. `AlternateRowsColumns` and `AlternateCutoutsWithinRowColumn` control serpentine vs. unidirectional ordering within rows.
#### EdgeStartSequencer
Sort parts by distance from the nearest plate edge (minimum of distances to all four edges). Parts closest to an edge cut first. Ties broken by nearest-neighbor.
### Parameter Flow
Sequencers that need configuration accept it through their constructor:
- `LeastCodeSequencer(int maxIterations = 100)` — max 2-opt iterations
- `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config
- Directional sequencers and `EdgeStartSequencer` need no configuration
### Factory
A static `PartSequencerFactory.Create(SequenceParameters parameters)` method in `OpenNest.Engine/Sequencing/` maps `parameters.Method` to the correct `IPartSequencer` implementation, passing constructor args as needed. Throws `NotSupportedException` for `RightSideAlt`.
## Stage 2: ContourCuttingStrategy
Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change:
1. `Apply(Program partProgram, Plate plate)``Apply(Program partProgram, Vector approachPoint)`
2. Return `CuttingResult` instead of `Program`
3. Remove `GetExitPoint(Plate)` — replaced by the `approachPoint` parameter
4. Set `CuttingResult.LastCutPoint` to the end point of the last contour (perimeter), which is the same as the perimeter's reindexed start point for closed contours
The internal logic (cutout sequencing, contour type detection, normal computation, lead-in/out selection) remains unchanged — only the source of the approach direction changes.
## Stage 3: IRapidPlanner
### Interface
```csharp
namespace OpenNest.Engine
{
public interface IRapidPlanner
{
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
}
}
```
All coordinates are in plate space.
### RapidPath
```csharp
public readonly struct RapidPath
{
public bool HeadUp { get; init; }
public List<Vector> Waypoints { get; init; }
}
```
- `HeadUp = true`: the post-processor should raise Z before traversing. `Waypoints` is empty (direct move).
- `HeadUp = false`: head-down rapid. `Waypoints` contains the path (may be empty for a direct move, or contain intermediate points for obstacle avoidance in future implementations).
### Implementations
Both live in `OpenNest.Engine/RapidPlanning/`.
#### SafeHeightRapidPlanner
Always returns `HeadUp = true` with empty waypoints. Guaranteed safe, simplest possible implementation.
#### DirectRapidPlanner
Checks if the straight line from `from` to `to` intersects any shape in `cutAreas`:
- If clear: returns `HeadUp = false`, empty waypoints (direct head-down rapid).
- If blocked: returns `HeadUp = true`, empty waypoints (fall back to safe height).
Uses existing `Intersect` class from `OpenNest.Geometry` for line-shape intersection checks.
Future enhancement: obstacle-avoidance pathfinding that routes around cut areas with head down. This is a 2D pathfinding problem (visibility graph or similar) and is out of scope for the initial implementation.
## PlateProcessor (Orchestrator)
Lives in `OpenNest.Engine/PlateProcessor.cs`.
```csharp
public class PlateProcessor
{
public IPartSequencer Sequencer { get; set; }
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
{
// 1. Sequence parts
var ordered = Sequencer.Sequence(plate.Parts, plate);
var results = new List<ProcessedPart>();
var cutAreas = new List<Shape>();
var currentPoint = GetExitPoint(plate); // plate-space starting point
foreach (var sequenced in ordered)
{
var part = sequenced.Part;
// 2. Transform approach point from plate space to part-local space
var localApproach = ToPartLocal(currentPoint, part);
// 3. Apply lead-ins (or skip if manual)
CuttingResult cutResult;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
cutResult = CuttingStrategy.Apply(part.Program, localApproach);
}
else
{
cutResult = new CuttingResult
{
Program = part.Program,
LastCutPoint = GetProgramEndPoint(part.Program)
};
}
// 4. Get pierce point in plate space for rapid planning
var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part);
// 5. Plan rapid from current position to this part's pierce point
var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
{
Part = part,
ProcessedProgram = cutResult.Program,
RapidPath = rapid
});
// 6. Track cut area (part perimeter in plate space) for future rapid planning
cutAreas.Add(GetPartPerimeter(part));
// 7. Update current position to this part's last cut point (plate space)
currentPoint = ToPlateSpace(cutResult.LastCutPoint, part);
}
return new PlateResult { Parts = results };
}
}
```
### Coordinate Transforms
Part programs already have rotation baked in (the `Part` constructor calls `Program.Rotate()`). `Part.Location` is a pure translation offset. Therefore, coordinate transforms are simple vector addition/subtraction — no rotation involved:
- `ToPartLocal(Vector platePoint, Part part)`: `platePoint - part.Location`
- `ToPlateSpace(Vector localPoint, Part part)`: `localPoint + part.Location`
This matches how `Part.Intersects` converts to plate space (offset by `Location` only).
### Helper Methods
- `GetExitPoint(Plate)`: moved from `ContourCuttingStrategy` — returns the plate corner opposite the quadrant origin.
- `GetProgramStartPoint(Program)`: first `RapidMove` position in the program (the pierce point).
- `GetProgramEndPoint(Program)`: last move's end position in the program.
- `GetPartPerimeter(Part)`: converts the part's program to geometry, builds `ShapeProfile`, returns the perimeter `Shape` offset by `part.Location` (translation only — rotation is already baked in).
### PlateResult
```csharp
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged)
public RapidPath RapidPath { get; init; }
}
```
The orchestrator is non-destructive — it does not mutate `Part.Program` (which has a `private set`). Instead, the processed program with lead-ins is stored in `ProcessedPart.ProcessedProgram`. The post-processor consumes `PlateResult` to generate machine-specific G-code, using `ProcessedProgram` for cut paths and `RapidPath.HeadUp` for Z-axis commands.
Note: the caller is responsible for configuring `CuttingStrategy.Parameters` (the `CuttingParameters` instance with lead-in/lead-out settings) before calling `Process()`. Parameters typically vary by material/thickness.
## File Structure
```
OpenNest.Core/
├── Part.cs # add HasManualLeadIns property
└── CNC/CuttingStrategy/
├── ContourCuttingStrategy.cs # signature change + CuttingResult return
└── CuttingResult.cs # new struct
OpenNest.Engine/
├── PlateProcessor.cs # orchestrator
├── Sequencing/
│ ├── IPartSequencer.cs
│ ├── SequencedPart.cs # removed ApproachPoint (orchestrator tracks it)
│ ├── RightSideSequencer.cs
│ ├── LeftSideSequencer.cs
│ ├── BottomSideSequencer.cs
│ ├── LeastCodeSequencer.cs
│ ├── AdvancedSequencer.cs
│ └── EdgeStartSequencer.cs
└── RapidPlanning/
├── IRapidPlanner.cs
├── RapidPath.cs
├── SafeHeightRapidPlanner.cs
└── DirectRapidPlanner.cs
```
## Known Limitations
- `DirectRapidPlanner` checks edge intersection only — a rapid that passes entirely through the interior of a concave cut part without crossing a perimeter edge would not be detected. Unlikely in practice (parts have material around them) but worth noting.
- `LeastCodeSequencer` uses bounding box centers for nearest-neighbor distance. For highly irregular parts, closest-point-on-perimeter could yield better results, but the simpler approach is sufficient for the initial implementation.
## Out of Scope
- Obstacle-avoidance pathfinding for head-down rapids (future enhancement to `DirectRapidPlanner`)
- UI integration (selecting sequencing method, configuring rapid planner)
- Post-processor changes to consume `PlateResult` — interim state: `PlateResult` is returned from `Process()` and the caller bridges it to the existing `IPostProcessor` interface
- `RightSideAlt` sequencer (unclear how it differs from `RightSide` — defer until behavior is defined; `PlateProcessor` should throw `NotSupportedException` if selected)
- Serialization of `PlateResult`
@@ -1,133 +0,0 @@
# Strip Nester Design Spec
## Problem
The current multi-drawing nesting strategies (AutoNester with NFP/simulated annealing, sequential FillExact) produce scattered, unstructured layouts. For jobs with multiple part types, a structured strip-based approach can pack more densely by dedicating a tight strip to the highest-area drawing and filling the remnant with the rest.
## Strategy Overview
1. Pick the drawing that consumes the most plate area (bounding box area x quantity) as the "strip item." All others are "remainder items."
2. Try two orientations — bottom strip and left strip.
3. For each orientation, find the tightest strip that fits the strip item's full quantity.
4. Fill the remnant area with remainder items using existing fill strategies.
5. Compare both orientations. The denser overall result wins.
## Algorithm Detail
### Step 1: Select Strip Item
Sort `NestItem`s by `Drawing.Program.BoundingBox().Area() * quantity` descending — bounding box area, not `Drawing.Area`, because the bounding box represents the actual plate space consumed by each part. The first item becomes the strip item. If quantity is 0 (unlimited), estimate max capacity from `workArea.Area() / bboxArea` as a stand-in for sorting.
### Step 2: Estimate Initial Strip Height
For the strip item, calculate at both 0 deg and 90 deg rotation. These two angles are sufficient since this is only an estimate for the shrink loop starting point — the actual fill in Step 3 uses `NestEngine.Fill` which tries many rotation angles internally.
- Parts per row: `floor(stripLength / bboxWidth)`
- Rows needed: `ceil(quantity / partsPerRow)`
- Strip height: `rows * bboxHeight`
Pick the rotation with the shorter strip height. The strip length is the work area dimension along the strip's long axis (work area width for bottom strip, work area length for left strip).
### Step 3: Initial Fill
Create a `Box` for the strip area:
- **Bottom strip**: `(workArea.X, workArea.Y, workArea.Width, estimatedStripHeight)`
- **Left strip**: `(workArea.X, workArea.Y, estimatedStripWidth, workArea.Length)`
Fill using `NestEngine.Fill(stripItem, stripBox)`. Measure the actual strip dimension from placed parts: for a bottom strip, `actualStripHeight = placedParts.GetBoundingBox().Top - workArea.Y`; for a left strip, `actualStripWidth = placedParts.GetBoundingBox().Right - workArea.X`. This may be shorter than the estimate since FillLinear packs more efficiently than pure bounding-box grid.
### Step 4: Shrink Loop
Starting from the actual placed dimension (not the estimate), capped at 20 iterations:
1. Reduce strip height by `plate.PartSpacing` (typically 0.25").
2. Create new strip box with reduced dimension.
3. Fill with `NestEngine.Fill(stripItem, newStripBox)`.
4. If part count equals the initial fill count, record this as the new best and repeat.
5. If part count drops, stop. Use the previous iteration's result (tightest strip that still fits).
For unlimited quantity (qty = 0), the initial fill count becomes the target.
### Step 5: Remnant Fill
Calculate the remnant box from the tightest strip's actual placed dimension, adding `plate.PartSpacing` between the strip and remnant to prevent spacing violations:
- **Bottom strip remnant**: `(workArea.X, workArea.Y + actualStripHeight + partSpacing, workArea.Width, workArea.Length - actualStripHeight - partSpacing)`
- **Left strip remnant**: `(workArea.X + actualStripWidth + partSpacing, workArea.Y, workArea.Width - actualStripWidth - partSpacing, workArea.Length)`
Fill remainder items in descending order by `bboxArea * quantity` (largest first, same as strip selection). If the strip item was only partially placed (fewer than target quantity), add the leftover quantity as a remainder item so it participates in the remnant fill.
For each remainder item, fill using `NestEngine.Fill(remainderItem, remnantBox)`.
### Step 6: Compare Orientations
Score each orientation using `FillScore.Compute` over all placed parts (strip + remnant) against `plate.WorkArea()`. The orientation with the better `FillScore` wins. Apply the winning parts to the plate.
## Classes
### `StripNester` (new, `OpenNest.Engine`)
```csharp
public class StripNester
{
public StripNester(Plate plate) { }
public List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress,
CancellationToken token);
}
```
**Constructor**: Takes the target plate (for work area, part spacing, quadrant).
**`Nest` method**: Runs the full strategy. Returns the combined list of placed parts. The caller adds them to `plate.Parts`. Same instance-based pattern as `NestEngine`.
### `StripNestResult` (new, internal, `OpenNest.Engine`)
```csharp
internal class StripNestResult
{
public List<Part> Parts { get; set; } = new();
public Box StripBox { get; set; }
public Box RemnantBox { get; set; }
public FillScore Score { get; set; }
public StripDirection Direction { get; set; }
}
```
Holds intermediate results for comparing bottom vs left orientations.
### `StripDirection` (new enum, `OpenNest.Engine`)
```csharp
public enum StripDirection { Bottom, Left }
```
## Integration
### MCP (`NestingTools`)
`StripNester` becomes an additional strategy in the autonest flow. When multiple items are provided, both `StripNester` and the current approach run, and the better result wins.
### UI (`AutoNestForm`)
Can be offered as a strategy option alongside existing NFP-based auto-nesting.
### No changes to `NestEngine`
`StripNester` is a consumer of `NestEngine.Fill`, not a modification of it.
## Edge Cases
- **Single item**: Strategy reduces to strip optimization only (shrink loop with no remnant fill). Still valuable for finding the tightest area.
- **Strip item can't fill target quantity**: Use the partial result. Leftover quantity is added to remainder items for the remnant fill.
- **Remnant too small**: `NestEngine.Fill` returns empty naturally. No special handling needed.
- **Quantity = 0 (unlimited)**: Initial fill count becomes the shrink loop target.
- **Strip already one part tall**: Skip the shrink loop.
## Dependencies
- `NestEngine.Fill(NestItem, Box)` — existing API, no changes needed.
- `FillScore.Compute` — existing scoring, no changes needed.
- `Part.GetBoundingBox()` / list extensions — existing geometry utilities.
@@ -1,197 +0,0 @@
# Engine Refactor: Extract Shared Algorithms from DefaultNestEngine and StripNestEngine
## Problem
`DefaultNestEngine` (~550 lines) mixes phase orchestration with strategy-specific logic (pair candidate selection, angle building, pattern helpers). `StripNestEngine` (~450 lines) duplicates patterns that DefaultNestEngine also uses: shrink-to-fit loops, iterative remnant filling, and progress accumulation. Both engines would benefit from extracting shared algorithms into focused, reusable classes.
## Approach
Extract five classes from the two engines. No new interfaces or strategy patterns — just focused helper classes that each engine composes.
## Extracted Classes
### 1. PairFiller
**Source:** DefaultNestEngine lines 362-489 (`FillWithPairs`, `SelectPairCandidates`, `BuildRemainderPatterns`, `MinPairCandidates`, `PairTimeLimit`).
**API:**
```csharp
public class PairFiller
{
public PairFiller(Size plateSize, double partSpacing) { }
public List<Part> Fill(NestItem item, Box workArea,
int plateNumber = 0,
CancellationToken token = default,
IProgress<NestProgress> progress = null);
}
```
**Details:**
- Constructor takes plate size and spacing — decoupled from `Plate` object.
- `SelectPairCandidates` and `BuildRemainderPatterns` become private methods.
- Uses `BestFitCache.GetOrCompute()` internally (same as today).
- Calls `BuildRotatedPattern` and `FillPattern` — these become `internal static` methods on DefaultNestEngine so PairFiller can call them without ceremony.
- Returns `List<Part>` (empty list if no result), same contract as today.
- Progress reporting: PairFiller accepts `IProgress<NestProgress>` and `int plateNumber` in its `Fill` method to maintain per-candidate progress updates. The caller passes these through from the engine.
**Caller:** `DefaultNestEngine.FindBestFill` replaces `this.FillWithPairs(...)` with `new PairFiller(Plate.Size, Plate.PartSpacing).Fill(...)`.
### 2. AngleCandidateBuilder
**Source:** DefaultNestEngine lines 279-347 (`BuildCandidateAngles`, `knownGoodAngles` HashSet, `ForceFullAngleSweep` property).
**API:**
```csharp
public class AngleCandidateBuilder
{
public bool ForceFullSweep { get; set; }
public List<double> Build(NestItem item, double bestRotation, Box workArea);
public void RecordProductive(List<AngleResult> angleResults);
}
```
**Details:**
- Owns `knownGoodAngles` state — lives as long as the engine instance so pruning accumulates across fills.
- `Build()` encapsulates the full pipeline: base angles, sweep check, ML prediction, known-good pruning.
- `RecordProductive()` replaces the inline loop that feeds `knownGoodAngles` after the linear phase.
- `ForceFullAngleSweep` moves from DefaultNestEngine to `AngleCandidateBuilder.ForceFullSweep`. DefaultNestEngine keeps a forwarding property `ForceFullAngleSweep` that delegates to its `AngleCandidateBuilder` instance, so `BruteForceRunner` (which sets `engine.ForceFullAngleSweep = true`) continues to work without changes.
**Caller:** DefaultNestEngine creates one `AngleCandidateBuilder` instance as a field and calls `Build()`/`RecordProductive()` from `FindBestFill`.
### 3. ShrinkFiller
**Source:** StripNestEngine `TryOrientation` shrink loop (lines 188-215) and `ShrinkFill` (lines 358-418).
**API:**
```csharp
public static class ShrinkFiller
{
public static ShrinkResult Shrink(
Func<NestItem, Box, List<Part>> fillFunc,
NestItem item, Box box,
double spacing,
ShrinkAxis axis,
CancellationToken token = default,
int maxIterations = 20);
}
public enum ShrinkAxis { Width, Height }
public class ShrinkResult
{
public List<Part> Parts { get; set; }
public double Dimension { get; set; }
}
```
**Details:**
- `fillFunc` delegate decouples ShrinkFiller from any specific engine — the caller provides how to fill.
- `ShrinkAxis` determines which dimension to reduce. `TryOrientation` maps strip direction to axis: `StripDirection.Bottom``ShrinkAxis.Height`, `StripDirection.Left``ShrinkAxis.Width`. `ShrinkFill` calls `Shrink` twice (width then height).
- Loop logic: fill initial box, measure placed bounding box, reduce dimension by `spacing`, retry until count drops below initial count. Dimension is measured as `placedBox.Right - box.X` for Width or `placedBox.Top - box.Y` for Height.
- Returns both the best parts and the final tight dimension (needed by `TryOrientation` to compute the remnant box).
- **Two-axis independence:** When `ShrinkFill` calls `Shrink` twice, each axis shrinks against the **original** box dimensions, not the result of the prior axis. This preserves the current behavior where width and height are shrunk independently.
**Callers:**
- `StripNestEngine.TryOrientation` replaces its inline shrink loop.
- `StripNestEngine.ShrinkFill` replaces its two-axis inline shrink loops.
### 4. RemnantFiller
**Source:** StripNestEngine remnant loop (lines 253-343) and the simpler version in NestEngineBase.Nest (lines 74-97).
**API:**
```csharp
public class RemnantFiller
{
public RemnantFiller(Box workArea, double spacing) { }
public void AddObstacles(IEnumerable<Part> parts);
public List<Part> FillItems(
List<NestItem> items,
Func<NestItem, Box, List<Part>> fillFunc,
CancellationToken token = default,
IProgress<NestProgress> progress = null);
}
```
**Details:**
- Owns a `RemnantFinder` instance internally.
- `AddObstacles` registers already-placed parts (bounding boxes offset by spacing).
- `FillItems` runs the iterative loop: find remnants, try each item in each remnant, fill, update obstacles, repeat until no progress.
- Local quantity tracking (dictionary keyed by drawing name) stays internal — does not mutate the input `NestItem` quantities. Returns the placed parts; the caller deducts quantities.
- Uses minimum-remnant-size filtering (smallest remaining part dimension), same as StripNestEngine today.
- `fillFunc` delegate allows callers to provide any fill strategy (DefaultNestEngine.Fill, ShrinkFill, etc.).
**Callers:**
- `StripNestEngine.TryOrientation` replaces its inline remnant loop with `RemnantFiller.FillItems(...)`.
- `NestEngineBase.Nest` replaces its hand-rolled largest-remnant loop. **Note:** This is a deliberate behavioral improvement — the base class currently uses only the single largest remnant, while `RemnantFiller` tries all remnants iteratively with minimum-size filtering. This may produce better fill results for engines that rely on the base `Nest` method.
**Unchanged:** `NestEngineBase.Nest` phase 2 (bin-packing single-quantity items via `PackArea`, lines 100-119) is not affected by this change.
### 5. AccumulatingProgress
**Source:** StripNestEngine nested class (lines 425-449).
**API:**
```csharp
internal class AccumulatingProgress : IProgress<NestProgress>
{
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts) { }
public void Report(NestProgress value);
}
```
**Details:**
- Moved from private nested class to standalone `internal` class in OpenNest.Engine.
- No behavioral change — wraps an `IProgress<NestProgress>` and prepends previously placed parts to each report.
## What Stays on Each Engine
### DefaultNestEngine (~200 lines after extraction)
- `Fill(NestItem, Box, ...)` — public entry point, unchanged.
- `Fill(List<Part>, Box, ...)` — group-parts overload, unchanged.
- `PackArea` — bin packing delegation, unchanged.
- `FindBestFill` — orchestration, now ~30 lines: calls `AngleCandidateBuilder.Build()`, `PairFiller.Fill()`, linear angle loop, `FillRectangleBestFit`, picks best.
- `FillRectangleBestFit` — 6-line private method, too small to extract.
- `BuildRotatedPattern` / `FillPattern` — become `internal static`, used by both the linear loop and PairFiller.
- `QuickFillCount` — stays (used by binary search, not shared).
### StripNestEngine (~200 lines after extraction)
- `Nest` — orchestration, unchanged.
- `TryOrientation` — becomes thinner: calls `DefaultNestEngine.Fill` for initial fill, `ShrinkFiller.Shrink()` for tightening, `RemnantFiller.FillItems()` for remnants.
- `ShrinkFill` — replaced by two `ShrinkFiller.Shrink()` calls.
- `SelectStripItemIndex` / `EstimateStripDimension` — stay private, strip-specific.
- `AccumulatingProgress` — removed, uses shared class.
### NestEngineBase
- `Nest` — switches from hand-rolled remnant loop to `RemnantFiller.FillItems()`.
- All other methods unchanged.
## File Layout
All new classes go in `OpenNest.Engine/`:
```
OpenNest.Engine/
PairFiller.cs
AngleCandidateBuilder.cs
ShrinkFiller.cs
RemnantFiller.cs
AccumulatingProgress.cs
```
## Non-Goals
- No new interfaces or strategy patterns.
- No changes to FillLinear, FillBestFit, PackBottomLeft, or any other existing algorithm.
- No changes to NestEngineRegistry or the plugin system.
- No changes to public API surface — all existing callers continue to work unchanged. One deliberate behavioral improvement: `NestEngineBase.Nest` gains multi-remnant filling (see RemnantFiller section).
- PatternHelper extraction deferred — `BuildRotatedPattern`/`FillPattern` become `internal static` on DefaultNestEngine for now. Extract if a third consumer appears.
- StripNestEngine continues to create fresh `DefaultNestEngine` instances per fill call. Sharing an `AngleCandidateBuilder` across sub-fills to enable angle pruning is a potential future optimization, not part of this refactor.
@@ -1,260 +0,0 @@
# Lead-In Assignment UI Design (Revised)
## Overview
Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides separate parameter sets for external (perimeter) and internal (cutout/hole) contours. Lead-in/lead-out moves are tagged with the existing `LayerType.Leadin`/`LayerType.Leadout` enum on each code, making them distinguishable from normal cut code and easy to strip and re-apply.
## Design Principles
- **LayerType tagging.** Every lead-in move gets `Layer = LayerType.Leadin`, every lead-out move gets `Layer = LayerType.Leadout`. Normal contour cuts keep `Layer = LayerType.Cut` (the default). This uses the existing `LayerType` enum and `LinearMove.Layer`/`ArcMove.Layer` properties — no new enums or flags.
- **Always rebuild from base.** `ContourCuttingStrategy.Apply` converts the input program to geometry via `Program.ToGeometry()` and `ShapeProfile`. These do NOT filter by layer — all entities (including lead-in/out codes if present) would be processed. Therefore, the strategy must always receive a clean program (cut codes only). The flow always clones from `Part.BaseDrawing.Program` and re-rotates before applying.
- **Non-destructive.** `Part.BaseDrawing.Program` is never modified. The strategy builds a fresh `Program` with lead-ins baked in. `Part.HasManualLeadIns` (existing property) is set to `true` when lead-ins are assigned, so the automated `PlateProcessor` pipeline skips these parts.
## Lead-In Dialog (`LeadInForm`)
A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two parameter groups, one checkbox, and OK/Cancel buttons.
### External Group (Perimeter)
- Lead-in angle (degrees) — default 90
- Lead-in length (inches) — default 0.125
- Overtravel (inches) — default 0.03
### Internal Group (Cutouts & Holes)
- Lead-in angle (degrees) — default 90
- Lead-in length (inches) — default 0.125
- Overtravel (inches) — default 0.03
### Update Existing Checkbox
- **"Update existing lead-ins"** — checked by default
- When checked: strip all existing lead-in/lead-out codes from every part before re-applying
- When unchecked: only process parts that have no `LayerType.Leadin` codes in their program
### Dialog Result
```csharp
public class LeadInSettings
{
// External (perimeter) parameters
public double ExternalLeadInAngle { get; set; } = 90;
public double ExternalLeadInLength { get; set; } = 0.125;
public double ExternalOvertravel { get; set; } = 0.03;
// Internal (cutout/hole) parameters
public double InternalLeadInAngle { get; set; } = 90;
public double InternalLeadInLength { get; set; } = 0.125;
public double InternalOvertravel { get; set; } = 0.03;
// Behavior
public bool UpdateExisting { get; set; } = true;
}
```
Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (not radians), converting internally via `Angle.ToRadians()`. The `LeadInSettings` values are degrees and can be passed directly.
## LeadInSettings to CuttingParameters Mapping
The caller builds one `CuttingParameters` instance with separate external and internal settings. ArcCircle shares the internal settings:
```
ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.ExternalLeadInAngle, Length = settings.ExternalLeadInLength }
ExternalLeadOut = new LineLeadOut { Length = settings.ExternalOvertravel }
InternalLeadIn = new LineLeadIn { ApproachAngle = settings.InternalLeadInAngle, Length = settings.InternalLeadInLength }
InternalLeadOut = new LineLeadOut { Length = settings.InternalOvertravel }
ArcCircleLeadIn = (same as Internal)
ArcCircleLeadOut = (same as Internal)
```
## Detecting Existing Lead-Ins
Check whether a part's program contains lead-in codes by inspecting `LayerType`:
```csharp
bool HasLeadIns(Program program)
{
foreach (var code in program.Codes)
{
if (code is LinearMove lm && lm.Layer == LayerType.Leadin)
return true;
if (code is ArcMove am && am.Layer == LayerType.Leadin)
return true;
}
return false;
}
```
## Preparing a Clean Program
**Important:** `Program.ToGeometry()` and `ShapeProfile` process ALL entities regardless of layer. They do NOT filter out lead-in/lead-out codes. If the strategy receives a program that already has lead-in codes baked in, those codes would be converted to geometry entities and corrupt the perimeter/cutout detection.
Therefore, the flow always starts from a clean base:
```csharp
var cleanProgram = part.BaseDrawing.Program.Clone() as Program;
cleanProgram.Rotate(part.Rotation);
```
This produces a program with only the original cut geometry at the part's current rotation angle, safe to feed into `ContourCuttingStrategy.Apply`.
## Menu Integration
Add "Assign Lead-Ins" to the Plate menu in `MainForm`, after "Sequence Parts" and before "Calculate Cut Time".
Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`.
## AssignLeadIns Flow (EditNestForm)
```
1. Open LeadInForm dialog
2. If user clicks OK:
a. Get LeadInSettings from dialog (includes UpdateExisting flag)
b. Build one ContourCuttingStrategy with CuttingParameters from settings
c. Get exit point: PlateHelper.GetExitPoint(plate) [now public]
d. Set currentPoint = exitPoint
e. For each part on the current plate (in sequence order):
- If !updateExisting and part already has lead-in codes → skip
- Build clean program: clone BaseDrawing.Program, rotate to part.Rotation
- Compute localApproach = currentPoint - part.Location
- Call strategy.Apply(cleanProgram, localApproach) → CuttingResult
- Call part.ApplyLeadIns(cutResult.Program)
(this sets Program, HasManualLeadIns = true, and recalculates bounds)
- Update currentPoint = cutResult.LastCutPoint + part.Location
f. Invalidate PlateView to show updated geometry
```
Note: The clean program is always rebuilt from `BaseDrawing.Program` — never from the current `Part.Program` — because `Program.ToGeometry()` and `ShapeProfile` do not filter by layer and would be corrupted by existing lead-in codes.
Note: Setting `Part.Program` requires a public method since the setter is `private`. See Model Changes below.
## Model Changes
### Part (OpenNest.Core)
Add a method to apply lead-ins and mark the part:
```csharp
public void ApplyLeadIns(Program processedProgram)
{
Program = processedProgram;
HasManualLeadIns = true;
UpdateBounds();
}
```
This atomically sets the processed program, marks `HasManualLeadIns = true` (so `PlateProcessor` skips this part), and recalculates bounds. The private setter on `Program` stays private — `ApplyLeadIns` is the public API.
### PlateHelper (OpenNest.Engine)
Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`.
## ContourCuttingStrategy Changes
### LayerType Tagging
When emitting lead-in moves, stamp each code with `Layer = LayerType.Leadin`. When emitting lead-out moves, stamp with `Layer = LayerType.Leadout`. This applies to all move types (`LinearMove`, `ArcMove`) generated by `LeadIn.Generate()` and `LeadOut.Generate()`.
The `LeadIn.Generate()` and `LeadOut.Generate()` methods return `List<ICode>`. After calling them, the strategy sets the `Layer` property on each returned code:
```csharp
var leadInCodes = leadIn.Generate(piercePoint, normal, winding);
foreach (var code in leadInCodes)
{
if (code is LinearMove lm) lm.Layer = LayerType.Leadin;
else if (code is ArcMove am) am.Layer = LayerType.Leadin;
}
result.Codes.AddRange(leadInCodes);
```
Same pattern for lead-out codes with `LayerType.Leadout`.
### Corner vs Mid-Entity Auto-Detection
When generating the lead-out, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks:
```csharp
private static bool IsCornerPierce(Vector closestPt, Entity entity)
{
if (entity is Line line)
return closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon
|| closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon;
if (entity is Arc arc)
return closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon
|| closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon;
return false;
}
```
Note: `Entity` has no polymorphic `StartPoint`/`EndPoint``Line` has properties, `Arc` has methods, `Circle` has neither.
### Corner Lead-Out
Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal. Moves are tagged `LayerType.Leadout`.
### Mid-Entity Lead-Out (Contour-Follow Overtravel)
Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). The overtravel distance is read from the selected `LeadOut` for the current contour type — `SelectLeadOut(contourType)`. Since external and internal have separate `LineLeadOut` instances in `CuttingParameters`, the overtravel distance automatically varies by contour type.
```csharp
var leadOut = SelectLeadOut(contourType);
if (IsCornerPierce(closestPt, entity))
{
// Corner: delegate to LeadOut.Generate() as normal
var codes = leadOut.Generate(closestPt, normal, winding);
// tag as LayerType.Leadout
}
else if (leadOut is LineLeadOut lineLeadOut && lineLeadOut.Length > 0)
{
// Mid-entity: retrace the start of the contour for overtravel distance
var codes = GenerateOvertravelMoves(reindexed, lineLeadOut.Length);
// tag as LayerType.Leadout
}
```
The contour-follow retraces the beginning of the reindexed shape:
1. Walking the reindexed shape's entities from the start
2. Accumulating distance until overtravel is reached
3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed)
4. Tagging all emitted moves as `LayerType.Leadout`
This produces a clean overcut that ensures the contour fully closes.
### Lead-out behavior summary
| Contour Type | Pierce Location | Lead-Out Behavior |
|---|---|---|
| External | Corner | `LineLeadOut.Generate()` — extends past corner |
| External | Mid-entity | Contour-follow overtravel moves |
| Internal | Corner | `LineLeadOut.Generate()` — extends past corner |
| Internal | Mid-entity | Contour-follow overtravel moves |
| ArcCircle | N/A (always mid-entity) | Contour-follow overtravel moves |
## File Structure
```
OpenNest.Core/
├── Part.cs # add ApplyLeadIns method
└── CNC/CuttingStrategy/
└── ContourCuttingStrategy.cs # LayerType tagging, Overtravel, corner detection
OpenNest.Engine/
└── Sequencing/
└── PlateHelper.cs # change internal → public
OpenNest/
├── Forms/
│ ├── LeadInForm.cs # new dialog
│ ├── LeadInForm.Designer.cs # new dialog designer
│ ├── MainForm.Designer.cs # add menu item
│ ├── MainForm.cs # add click handler
│ └── EditNestForm.cs # add AssignLeadIns method
└── LeadInSettings.cs # settings DTO
```
## Out of Scope
- Tabbed (V lead-in/out) parameters and `Part.IsTabbed` — deferred until tab assignment UI
- Slug destruct for internal cutouts
- Lead-in visualization colors in PlateView (separate enhancement)
- Database storage of lead-in presets by material/thickness
- MicrotabLeadOut integration
- Nest file serialization changes
@@ -1,82 +0,0 @@
# Polylabel Part Label Positioning
**Date:** 2026-03-16
**Status:** Approved
## Problem
Part ID labels in `PlateView` are drawn at `PathPoints[0]` — the first point of the graphics path, which sits on the part contour edge. This causes labels to overlap adjacent parts and be unreadable, especially in dense nests.
## Solution
Implement the polylabel algorithm (pole of inaccessibility) to find the point inside each part's polygon with maximum distance from all edges, including hole edges. Draw the part ID label centered on that point.
## Design
### Part 1: Polylabel Algorithm
Add `PolyLabel` static class in `OpenNest.Geometry` namespace (file: `OpenNest.Core/Geometry/PolyLabel.cs`).
**Public API:**
```csharp
public static class PolyLabel
{
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5);
}
```
**Algorithm:**
1. Compute bounding box of the outer polygon.
2. Divide into a grid of cells (cell size = shorter bbox dimension).
3. For each cell, compute signed distance from cell center to nearest edge on any ring (outer boundary + all holes). Use `Polygon.ContainsPoint` for sign (negative if outside outer polygon or inside a hole).
4. Track the best interior point found so far.
5. Use a priority queue (sorted list) ordered by maximum possible distance for each cell.
6. Subdivide promising cells that could beat the current best; discard the rest.
7. Stop when the best cell's potential improvement over the current best is less than the precision tolerance.
**Dependencies within codebase:**
- `Polygon.ContainsPoint(Vector)` — ray-casting point-in-polygon test (already exists).
- Point-to-segment distance — compute from `Line` or inline (distance from point to each polygon edge).
**Fallback:** If the polygon is degenerate (< 3 vertices) or the program has no geometry, fall back to the bounding box center.
**No external dependencies.**
### Part 2: Label Rendering in LayoutPart
Modify `LayoutPart` in `OpenNest/LayoutPart.cs`.
**Changes:**
1. Add a cached `Vector? _labelPoint` field in **program-local coordinates** (pre-transform). Invalidated when `IsDirty` is set.
2. When computing the label point (on first draw after invalidation):
- Convert the part's `Program` to geometry via `ConvertProgram.ToGeometry`.
- Build shapes via `ShapeBuilder.GetShapes`.
- Identify the outer contour using `ShapeProfile` (the `Perimeter` shape) and convert cutouts to hole polygons.
- Run `PolyLabel.Find(outer, holes)` on the result.
- Cache the `Vector` in program-local coordinates.
3. In `Draw(Graphics g, string id)`:
- Offset the cached label point by `BasePart.Location`.
- Transform through the current view matrix (handles zoom/pan without cache invalidation).
- Draw the ID string centered using `StringFormat` with `Alignment = Center` and `LineAlignment = Center`.
**Coordinate pipeline:** polylabel runs once in program-local coordinates (expensive, cached). Location offset + matrix transform happen every frame (cheap, no caching needed). This matches how the existing `GraphicsPath` pipeline works and avoids stale cache on zoom/pan.
## Scope
- **In scope:** polylabel algorithm, label positioning change in `LayoutPart.Draw`.
- **Out of scope:** changing part origins, modifying the nesting engine, any changes to `Part`, `Drawing`, or `Program` classes.
## Testing
- Unit tests for `PolyLabel.Find()` with known polygons:
- Square — label at center.
- L-shape — label in the larger lobe.
- C-shape — label inside the concavity, not at bounding box center.
- Triangle — label at incenter.
- Thin rectangle (10:1 aspect ratio) — label centered along the short axis.
- Square with large centered hole — label avoids the hole.
- Verify the returned point is inside the polygon and has the expected distance from edges.
@@ -1,143 +0,0 @@
# Remnant Finder Design
## Problem
Remnant detection is currently scattered across four places in the codebase, all using simple edge-strip heuristics that miss interior gaps and produce unreliable results:
- `Plate.GetRemnants()` — finds strips along plate edges from global min/max of part bounding boxes
- `DefaultNestEngine.TryRemainderImprovement()` / `TryStripRefill()` / `ClusterParts()` — clusters parts into rows/columns and refills the last incomplete cluster
- `FillScore.ComputeUsableRemnantArea()` — estimates remnant area from rightmost/topmost part edges for fill scoring
- `NestEngineBase.ComputeRemainderWithin()` — picks the larger of one horizontal or vertical strip
These approaches only find single edge strips and cannot discover multiple or interior empty regions.
## Solution
A standalone `RemnantFinder` class in `OpenNest.Engine` that uses edge projection to find all rectangular empty regions in a work area given a set of obstacle bounding boxes. This decouples remnant detection from the nesting engine and enables an iterative workflow:
1. Fill an area
2. Get all remnants
3. Pick a remnant, fill it
4. Get all remnants again (repeat)
## API
### `RemnantFinder` — `OpenNest.Engine`
```csharp
public class RemnantFinder
{
// Constructor
public RemnantFinder(Box workArea, List<Box> obstacles = null);
// Mutable obstacle management
public List<Box> Obstacles { get; }
public void AddObstacle(Box obstacle);
public void AddObstacles(IEnumerable<Box> obstacles);
public void ClearObstacles();
// Core method
public List<Box> FindRemnants(double minDimension = 0);
// Convenience factory
public static RemnantFinder FromPlate(Plate plate);
}
```
### `FindRemnants` Algorithm (Edge Projection)
1. Collect all unique X coordinates from obstacle left/right edges + work area left/right.
2. Collect all unique Y coordinates from obstacle bottom/top edges + work area bottom/top.
3. Form candidate rectangles from every adjacent `(x[i], x[i+1])` x `(y[j], y[j+1])` cell in the grid.
4. Filter out any candidate that overlaps any obstacle.
5. Merge adjacent empty cells into larger rectangles — greedy row-first merge: scan cells left-to-right within each row and merge horizontally where cells share the same Y span, then merge vertically where resulting rectangles share the same X span and are adjacent in Y. This produces "good enough" large rectangles without requiring maximal rectangle decomposition.
6. Filter by `minDimension` — both width and height must be >= the threshold.
7. Return sorted by area descending.
### `FromPlate` Factory
Extracts `plate.WorkArea()` as the work area and each part's bounding box offset by `plate.PartSpacing` as obstacles.
## Scoping
The `RemnantFinder` operates on whatever work area it's given. When used within the strip nester or sub-region fills, pass the sub-region's work area and only the parts placed within it — not the full plate. This prevents remnants from spanning into unrelated layout regions.
## Thread Safety
`RemnantFinder` is not thread-safe. Each thread/task should use its own instance. The `FromPlate` factory creates a snapshot of obstacles at construction time, so concurrent reads of the plate during construction should be avoided.
## Removals
### `DefaultNestEngine`
Remove the entire remainder phase:
- `TryRemainderImprovement()`
- `TryStripRefill()`
- `ClusterParts()`
- `NestPhase.Remainder` reporting in both `Fill()` overrides
The engine's `Fill()` becomes single-pass. Iterative remnant filling is the caller's responsibility.
### `NestPhase.Remainder`
Remove the `Remainder` value from the `NestPhase` enum. Clean up corresponding switch cases in:
- `NestEngineBase.FormatPhaseName()`
- `NestProgressForm.FormatPhase()`
### `Plate`
Remove `GetRemnants()` — fully replaced by `RemnantFinder.FromPlate(plate)`.
### `FillScore`
Remove remnant-related members:
- `MinRemnantDimension` constant
- `UsableRemnantArea` property
- `ComputeUsableRemnantArea()` method
- Remnant area from the `CompareTo` ordering
Constructor simplifies from `FillScore(int count, double usableRemnantArea, double density)` to `FillScore(int count, double density)`. The `Compute` factory method drops the `ComputeUsableRemnantArea` call accordingly.
### `NestProgress`
Remove `UsableRemnantArea` property. Update `NestEngineBase.ReportProgress()` to stop computing/setting it. Update `NestProgressForm` to stop displaying it.
### `NestEngineBase`
Replace `ComputeRemainderWithin()` with `RemnantFinder` in the `Nest()` method. The current `Nest()` fills an item, then calls `ComputeRemainderWithin` to get a single remainder box for the next item. Updated behavior: after filling, create a `RemnantFinder` with the current work area and all placed parts, call `FindRemnants()`, and use the largest remnant as the next work area. If no remnants exist, the fill loop stops.
### `StripNestResult`
Remove `RemnantBox` property. The `StripNestEngine.TryOrientation` assignment to `result.RemnantBox` is removed — the value was stored but never read externally. The `StripNestResult` class itself is retained (it still carries `Parts`, `StripBox`, `Score`, `Direction`).
## Caller Updates
### `NestingTools` (MCP)
`fill_remnants` switches from `plate.GetRemnants()` to:
```csharp
var finder = RemnantFinder.FromPlate(plate);
var remnants = finder.FindRemnants(minDimension);
```
### `InspectionTools` (MCP)
`get_plate_info` switches from `plate.GetRemnants()` to `RemnantFinder.FromPlate(plate).FindRemnants()`.
### Debug Logging
`DefaultNestEngine.FillWithPairs()` logs `bestScore.UsableRemnantArea` — update to log only count and density after the `FillScore` simplification.
### UI / Console callers
Any caller that previously relied on `TryRemainderImprovement` getting called automatically inside `Fill()` will need to implement the iterative loop: fill -> find remnants -> fill remnant -> repeat.
## PlateView Work Area Visualization
When an area is being filled (during the iterative workflow), the `PlateView` control displays the active work area's outline as a dashed orange rectangle. The outline persists while that area is being filled and disappears when the fill completes.
**Implementation:** Add a `Box ActiveWorkArea` property to `PlateView` (`Box` is a reference type, so `null` means no overlay). When set, the paint handler draws a dashed rectangle at that location. The `NestProgress` class gets a new `Box ActiveWorkArea` property so the progress pipeline carries the current work area from the engine to the UI. The existing progress callbacks in `PlateView.FillWithProgress` and `MainForm` set `PlateView.ActiveWorkArea` from the progress object, alongside the existing `SetTemporaryParts` calls. `NestEngineBase.ReportProgress` populates `ActiveWorkArea` from its `workArea` parameter.
## Future
The edge projection algorithm is embarrassingly parallel — each candidate rectangle's overlap check is independent. This makes it a natural fit for GPU acceleration via `OpenNest.Gpu` in the future.
@@ -1,111 +0,0 @@
# Shape Library Design Spec
## Overview
A parametric shape library for OpenNest that provides reusable, self-describing shape classes for generating `Drawing` objects. Each shape is its own class with typed parameters, inheriting from an abstract `ShapeDefinition` base class. Inspired by PEP's WINSHAPE library.
## Location
- Project: `OpenNest.Core`
- Folder: `Shapes/`
- Namespace: `OpenNest.Shapes`
## Architecture
### Base Class — `ShapeDefinition`
Abstract base class that all shapes inherit from. `Name` defaults to the shape type name (e.g. `"Rectangle"`) but can be overridden.
```csharp
public abstract class ShapeDefinition
{
public string Name { get; set; }
protected ShapeDefinition()
{
// Default name to the concrete class name, stripping "Shape" suffix
var typeName = GetType().Name;
Name = typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public abstract Drawing GetDrawing();
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
if (pgm == null)
throw new InvalidOperationException(
$"Failed to create program for shape '{Name}'. Check that parameters produce valid geometry.");
return new Drawing(Name, pgm);
}
}
```
- `Name`: The name assigned to the resulting `Drawing`. Defaults to the shape class name without the "Shape" suffix. Never null.
- `GetDrawing()`: Each shape implements this to build its geometry and return a `Drawing`.
- `CreateDrawing()`: Shared helper that converts a list of geometry entities into a `Drawing` via `ConvertGeometry.ToProgram()`. Throws `InvalidOperationException` if the geometry is degenerate (prevents null `Program` from reaching `Drawing.UpdateArea()`).
### Shape Classes
#### Tier 1 — Basics (extracted from MCP InputTools)
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RectangleShape` | `Width`, `Height` | Axis-aligned rectangle from origin |
| `CircleShape` | `Diameter` | Circle centered at origin. Implementation divides by 2 for the `Circle` entity's radius parameter. |
| `LShape` | `Width`, `Height`, `LegWidth`?, `LegHeight`? | L-shaped profile. `LegWidth` defaults to `Width/2`, `LegHeight` defaults to `Height/2`. |
| `TShape` | `Width`, `Height`, `StemWidth`?, `BarHeight`? | T-shaped profile. `StemWidth` defaults to `Width/3`, `BarHeight` defaults to `Height/3`. |
#### Tier 2 — Common CNC shapes
| Class | Parameters | Description |
|-------|-----------|-------------|
| `RingShape` | `OuterDiameter`, `InnerDiameter` | Annular ring (two concentric circles). Both converted to radius internally. |
| `RightTriangleShape` | `Width`, `Height` | Right triangle with the right angle at origin |
| `IsoscelesTriangleShape` | `Base`, `Height` | Isosceles triangle centered on base |
| `TrapezoidShape` | `TopWidth`, `BottomWidth`, `Height` | Trapezoid with bottom edge centered under top |
| `OctagonShape` | `Width` | Regular octagon where `Width` is the flat-to-flat distance |
| `RoundedRectangleShape` | `Width`, `Height`, `Radius` | Rectangle with 90-degree CCW arc corners |
### File Structure
```
OpenNest.Core/
Shapes/
ShapeDefinition.cs
CircleShape.cs
RectangleShape.cs
RingShape.cs
RightTriangleShape.cs
IsoscelesTriangleShape.cs
TrapezoidShape.cs
OctagonShape.cs
RoundedRectangleShape.cs
LShape.cs
TShape.cs
```
### Geometry Construction
Each shape builds a `List<Entity>` (using `Line`, `Arc`, `Circle` from `OpenNest.Geometry`) and passes it to the base `CreateDrawing()` helper. Shapes are constructed at the origin (0,0) with positive X/Y extents.
- **Lines** for straight edges — endpoints must chain end-to-end for `ShapeBuilder` to detect closed shapes.
- **Arcs** for rounded corners (`RoundedRectangleShape`). Arcs use CCW direction (not reversed) with angles in radians.
- **Circles** for `CircleShape` and `RingShape` outer/inner boundaries.
### MCP Integration
`InputTools.CreateDrawing` in `OpenNest.Mcp` will be refactored to instantiate the appropriate `ShapeDefinition` subclass and call `GetDrawing()`, replacing the existing private `CreateRectangle`, `CreateCircle`, `CreateLShape`, `CreateTShape` methods. The MCP tool's existing flat parameter names (`radius`, `width`, `height`) are mapped to the shape class properties at the MCP layer. The `gcode` case remains as-is.
New Tier 2 shapes can be exposed via the MCP tool by extending the `shape` parameter's accepted values and mapping to the new shape classes, with additional MCP parameters as needed.
## Future Expansion
- Additional shapes (Tier 3): Single-D, Parallelogram, House, Stair, Rectangle with chamfer(s), Ring segment, Slot rectangle
- UI shape picker with per-shape parameter editors
- Shape discovery via reflection or static registry
- `LShape`/`TShape` additional sub-dimension parameters for full parametric control
@@ -1,65 +0,0 @@
# Pattern Tile Layout Window
## Summary
A standalone tool window for designing two-part tile patterns and previewing how they fill a plate. The user selects two drawings, arranges them into a unit cell by dragging, and sees the pattern tiled across a configurable plate. The unit cell compacts on release using the existing angle-based `Compactor.Push`. The tiled result can be applied to the current plate or a new plate.
## Window Layout
`PatternTileForm` is a non-MDI dialog opened from the main menu/toolbar. It receives a reference to the active `Nest` (for drawing list and plate creation). Horizontal `SplitContainer`:
- **Left panel (Unit Cell Editor):** A `PlateView` with `Plate.Size = (0, 0)` — no plate outline drawn. `Plate.Quantity = 0` to prevent `Drawing.Quantity.Nested` side-effects when parts are added/removed. Shows only the two parts. The user drags parts freely to position them relative to each other. Standard `PlateView` interactions (shift+scroll rotation, middle-click 90-degree rotation) are available. On mouse up after a drag, gravity compaction fires toward the combined center of gravity. Part spacing from the preview plate is used as the minimum gap during compaction.
- **Right panel (Tile Preview):** A read-only `PlateView` (`AllowSelect = false`, `AllowDrop = false`) with `Plate.Quantity = 0` (same isolation from quantity tracking). Shows the unit cell pattern tiled across a plate with a visible plate outline. Plate size is user-configurable, defaulting to the current nest's `PlateDefaults` size. Rebuilds on mouse up in the unit cell editor (not during drag).
- **Top control strip:** Two `ComboBox` dropdowns ("Drawing A", "Drawing B") populated from the active nest's `DrawingCollection`. Both may select the same drawing. Plate size inputs (length, width). An "Auto-Arrange" button. An "Apply" button.
## Drawing Selection & Unit Cell
When both dropdowns have a selection, two parts are created and placed side by side horizontally in the left `PlateView`, centered in the view. Selecting the same drawing for both is allowed.
When only one dropdown has a selection, a single part is shown in the unit cell editor. The tile preview tiles that single part across the plate (simple grid fill). The compaction step is skipped since there is only one part.
When neither dropdown has a selection, both panels are empty.
## Compaction on Mouse Up
On mouse up after a drag, each part is pushed individually toward the combined centroid of both parts:
1. Compute the centroid of the two parts' combined bounding box.
2. For each part, compute the angle from that part's bounding box center to the centroid.
3. Call the existing `Compactor.Push(List<Part>, List<Part>, Box, double, double angle)` overload for each part individually, treating the other part as the sole obstacle. Use a large synthetic work area (e.g., `new Box(-10000, -10000, 20000, 20000)`) since the unit cell editor has no real plate boundary — the work area just needs to not constrain the push.
4. The push uses part spacing from the preview plate as the minimum gap.
This avoids the zero-size plate `WorkArea()` issue and uses the existing angle-based push that already exists in `Compactor`.
## Auto-Arrange
A button that tries rotation combinations (0, 90, 180, 270 for each part — 16 combinations) and picks the pair arrangement with the tightest bounding box after compaction. The user can fine-tune from there.
## Tiling Algorithm
1. Compute the unit cell bounding box from the two parts' combined bounds.
2. Add half the part spacing as a margin on all sides of the cell, so adjacent cells have the correct spacing between parts at cell boundaries.
3. Calculate grid dimensions: `cols = floor(plateWorkAreaWidth / cellWidth)`, `rows = floor(plateWorkAreaHeight / cellHeight)`.
4. For each grid position `(col, row)`, clone the two parts offset by `(col * cellWidth, row * cellHeight)`.
5. Place all cloned parts on the preview plate.
Tiling recalculates only on mouse up in the unit cell editor, or when drawing selection or plate size changes.
## Apply to Plate
The "Apply" button opens a dialog with two choices:
- **Apply to current plate** — clears the current plate, then places the tiled parts onto it in `EditNestForm`.
- **Apply to new plate** — creates a new plate in the nest with the preview plate's size, then places the parts.
`PatternTileForm` returns a result object containing the list of parts and the target choice. The caller (`EditNestForm`) handles actual placement and quantity updates.
## Components
| Component | Project | Purpose |
|-----------|---------|---------|
| `PatternTileForm` | OpenNest (WinForms) | The dialog window with split layout, controls, and apply logic |
| Menu/toolbar integration | OpenNest (WinForms) | Entry point from `EditNestForm` toolbar |
Note: The angle-based `Compactor.Push(movingParts, obstacleParts, workArea, partSpacing, angle)` overload already exists in `OpenNest.Engine/Compactor.cs` — no engine changes are needed.
@@ -1,295 +0,0 @@
# Pluggable Fill Strategies Design
## Problem
`DefaultNestEngine.FindBestFill` is a monolithic method that hard-wires four fill phases (Pairs, Linear, RectBestFit, Extents) in a fixed order. Adding a new fill strategy or changing the execution order requires modifying `DefaultNestEngine` directly. The Linear phase is expensive and rarely wins, but there's no way to skip or reorder it without editing the orchestration code.
## Goal
Extract fill strategies into pluggable components behind a common interface. Engines compose strategies in a pipeline where each strategy receives the current best result from prior strategies and can decide whether to run. New strategies can be added by implementing the interface — including from plugin DLLs discovered via reflection.
## Scope
This refactoring targets only the **single-item fill path** (`DefaultNestEngine.FindBestFill`, called from the `Fill(NestItem, ...)` overload). The following are explicitly **out of scope** and remain unchanged:
- `Fill(List<Part> groupParts, ...)` — group-fill overload, has its own inline orchestration with different conditions (multi-phase block only runs when `groupParts.Count == 1`). May be refactored to use strategies in a future pass once the single-item pipeline is proven.
- `PackArea` — packing is a different operation (bin-packing single-quantity items).
- `Nest` — multi-item orchestration on `NestEngineBase`, uses `Fill` and `PackArea` as building blocks.
## Design
### `IFillStrategy` Interface
```csharp
public interface IFillStrategy
{
string Name { get; }
NestPhase Phase { get; }
int Order { get; } // lower runs first; gaps of 100 for plugin insertion
List<Part> Fill(FillContext context);
}
```
Strategies must be **stateless**. All mutable state lives in `FillContext`. This avoids leaking state between calls when strategies are shared across invocations.
Strategies **may** call `NestEngineBase.ReportProgress` for intermediate progress updates (e.g., `LinearFillStrategy` reports per-angle progress). The `FillContext` carries `Progress` and `PlateNumber` for this purpose. The pipeline orchestrator reports progress only when the overall best improves; strategies report their own internal progress as they work.
For plugin strategies that don't map to a built-in `NestPhase`, use `NestPhase.Custom` (a new enum value added as part of this work). The `Name` property provides the human-readable label.
### `FillContext`
Carries inputs and pipeline state through the strategy chain:
```csharp
public class FillContext
{
// Inputs
public NestItem Item { get; init; }
public Box WorkArea { get; init; }
public Plate Plate { get; init; }
public int PlateNumber { get; init; }
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
// Pipeline state
public List<Part> CurrentBest { get; set; }
public FillScore CurrentBestScore { get; set; }
public NestPhase WinnerPhase { get; set; }
public List<PhaseResult> PhaseResults { get; } = new();
public List<AngleResult> AngleResults { get; } = new();
// Shared resources (populated by earlier strategies, available to later ones)
public Dictionary<string, object> SharedState { get; } = new();
}
```
`SharedState` enables cross-strategy data sharing without direct coupling. Well-known keys:
| Key | Type | Producer | Consumer |
|-----|------|----------|----------|
| `"BestFits"` | `List<BestFitResult>` | `PairsFillStrategy` | `ExtentsFillStrategy` |
| `"BestRotation"` | `double` | Pipeline setup | `ExtentsFillStrategy`, `LinearFillStrategy` |
| `"AngleCandidates"` | `List<double>` | Pipeline setup | `LinearFillStrategy` |
### Pipeline Setup
Before iterating strategies, `RunPipeline` performs shared pre-computation and stores results in `SharedState`:
```csharp
private void RunPipeline(FillContext context)
{
// Pre-pipeline setup: shared across strategies
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
context.SharedState["BestRotation"] = bestRotation;
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
context.SharedState["AngleCandidates"] = angles;
foreach (var strategy in FillStrategyRegistry.Strategies)
{
// ... strategy loop ...
}
// Post-pipeline: record productive angles for cross-run learning
angleBuilder.RecordProductive(context.AngleResults);
}
```
The `AngleCandidateBuilder` instance stays on `DefaultNestEngine` (not inside a strategy) because it accumulates cross-run learning state via `RecordProductive`. Strategies read the pre-built angle list from `SharedState["AngleCandidates"]`.
### `FillStrategyRegistry`
Discovers strategies via reflection, similar to `NestEngineRegistry.LoadPlugins`. Stores strategy **instances** (not factories) because strategies are stateless:
```csharp
public static class FillStrategyRegistry
{
private static readonly List<IFillStrategy> strategies = new();
static FillStrategyRegistry()
{
LoadFrom(typeof(FillStrategyRegistry).Assembly);
}
private static List<IFillStrategy> sorted;
public static IReadOnlyList<IFillStrategy> Strategies =>
sorted ??= strategies.OrderBy(s => s.Order).ToList();
public static void LoadFrom(Assembly assembly)
{
/* scan for IFillStrategy implementations */
sorted = null; // invalidate cache
}
public static void LoadPlugins(string directory)
{
/* load DLLs and scan each */
sorted = null; // invalidate cache
}
}
```
Strategy plugins use a `Strategies/` directory (separate from the `Engines/` directory used by `NestEngineRegistry`). Note: plugin strategies cannot use `internal` types like `BinConverter` from `OpenNest.Engine`. If a plugin needs rectangle packing, `BinConverter` would need to be made `public` — defer this until a plugin actually needs it.
### Built-in Strategy Order
| Strategy | Order | Notes |
|----------|-------|-------|
| `PairsFillStrategy` | 100 | Populates `SharedState["BestFits"]` for Extents |
| `RectBestFitStrategy` | 200 | |
| `ExtentsFillStrategy` | 300 | Reads `SharedState["BestFits"]` from Pairs |
| `LinearFillStrategy` | 400 | Expensive, rarely wins, runs last |
Gaps of 100 allow plugins to slot in between (e.g., Order 150 runs after Pairs, before RectBestFit).
### Strategy Implementations
Each strategy is a thin stateless adapter around the existing filler class. Strategies construct filler instances using `context.Plate` properties:
```csharp
public class PairsFillStrategy : IFillStrategy
{
public string Name => "Pairs";
public NestPhase Phase => NestPhase.Pairs;
public int Order => 100;
public List<Part> Fill(FillContext context)
{
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);
// Share the BestFitCache for Extents to use later.
// This is a cache hit (PairFiller already called GetOrCompute internally),
// so it's a dictionary lookup, not a recomputation.
var bestFits = BestFitCache.GetOrCompute(
context.Item.Drawing, context.Plate.Size.Length,
context.Plate.Size.Width, context.Plate.PartSpacing);
context.SharedState["BestFits"] = bestFits;
return result;
}
}
```
Summary of all four:
- **`PairsFillStrategy`** — constructs `PairFiller(context.Plate.Size, context.Plate.PartSpacing)`, stores `BestFitCache` in `SharedState`
- **`RectBestFitStrategy`** — uses `BinConverter.ToItem(item, partSpacing)` and `BinConverter.CreateBin(workArea, partSpacing)` to delegate to `FillBestFit`
- **`ExtentsFillStrategy`** — constructs `FillExtents(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["BestRotation"]` for angles, reads `SharedState["BestFits"]` from Pairs
- **`LinearFillStrategy`** — constructs `FillLinear(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["AngleCandidates"]` for angle list. Internally iterates all angle candidates, tracks its own best, writes per-angle `AngleResults` to context, and calls `ReportProgress` for per-angle updates (preserving the existing UX). Returns only its single best result.
The underlying classes (`PairFiller`, `FillLinear`, `FillExtents`, `FillBestFit`) are unchanged.
### Changes to `DefaultNestEngine`
`FindBestFill` is replaced by `RunPipeline`:
```csharp
private void RunPipeline(FillContext context)
{
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
context.SharedState["BestRotation"] = bestRotation;
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
context.SharedState["AngleCandidates"] = angles;
try
{
foreach (var strategy in FillStrategyRegistry.Strategies)
{
context.Token.ThrowIfCancellationRequested();
var sw = Stopwatch.StartNew();
var result = strategy.Fill(context);
sw.Stop();
context.PhaseResults.Add(new PhaseResult(
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds));
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
{
context.CurrentBest = result;
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
context.WinnerPhase = strategy.Phase;
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
result, context.WorkArea, BuildProgressSummary());
}
}
}
catch (OperationCanceledException)
{
// Graceful degradation: return whatever best has been accumulated so far.
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
}
angleBuilder.RecordProductive(context.AngleResults);
}
```
After `RunPipeline`, the engine copies `context.PhaseResults` and `context.AngleResults` back to the `NestEngineBase` properties so existing UI and test consumers continue to work:
```csharp
PhaseResults.AddRange(context.PhaseResults);
AngleResults.AddRange(context.AngleResults);
WinnerPhase = context.WinnerPhase;
```
**Removed from `DefaultNestEngine`:**
- `FindBestFill` method (replaced by `RunPipeline`)
- `FillRectangleBestFit` method (moves into `RectBestFitStrategy`)
- `QuickFillCount` method (dead code — has zero callers, delete it)
**Stays on `DefaultNestEngine`:**
- `AngleCandidateBuilder` field — owns cross-run learning state, used in pipeline setup/teardown
- `ForceFullAngleSweep` property — forwards to `angleBuilder.ForceFullSweep`, keeps existing public API for `BruteForceRunner` and tests
- `Fill(List<Part> groupParts, ...)` overload — out of scope (see Scope section)
- `PackArea` — out of scope
**Static helpers `BuildRotatedPattern` and `FillPattern`** move to `Strategies/FillHelpers.cs`.
### File Layout
```
OpenNest.Engine/
Strategies/
IFillStrategy.cs
FillContext.cs
FillStrategyRegistry.cs
FillHelpers.cs
PairsFillStrategy.cs
LinearFillStrategy.cs
RectBestFitStrategy.cs
ExtentsFillStrategy.cs
```
### What Doesn't Change
- `PairFiller.cs`, `FillLinear.cs`, `FillExtents.cs`, `RectanglePacking/FillBestFit.cs` — underlying implementations
- `FillScore.cs`, `NestProgress.cs`, `Compactor.cs` — shared infrastructure
- `NestEngineBase.cs` — base class
- `NestEngineRegistry.cs` — engine-level registry (separate concern)
- `StripNestEngine.cs` — delegates to `DefaultNestEngine` internally
### Minor Changes to `NestPhase`
Add `Custom` to the `NestPhase` enum for plugin strategies that don't map to a built-in phase:
```csharp
public enum NestPhase
{
Linear,
RectBestFit,
Pairs,
Nfp,
Extents,
Custom
}
```
### Testing
- Existing `EngineRefactorSmokeTests` serve as the regression gate — they must pass unchanged after refactoring.
- `BruteForceRunner` continues to access `ForceFullAngleSweep` via the forwarding property on `DefaultNestEngine`.
- Individual strategy adapters do not need their own unit tests initially — the existing smoke tests cover the end-to-end pipeline. Strategy-level tests can be added as the strategy count grows.
@@ -1,235 +0,0 @@
# NestProgressForm Redesign v2
## Problem
The current `NestProgressForm` is a flat `TableLayoutPanel` of label/value pairs with default WinForms styling, MS Sans Serif font, and no visual hierarchy. It's functional but looks plain and gives no sense of where the engine is in its process or whether results are improving.
## Solution
Four combined improvements:
1. A custom-drawn **phase stepper** control showing all 6 nesting phases with visited/active/pending states
2. **Grouped sections** separating Results from Status with white panels on a gray background
3. **Modern typography** — Segoe UI for labels, Consolas for values (monospaced so numbers don't shift width)
4. **Flash & fade with color-coded density** — values flash green on change and fade back; density flash color varies by quality (red < 50%, yellow 50-70%, green > 70%)
## Phase Stepper Control
**New file: `OpenNest/Controls/PhaseStepperControl.cs`**
A custom `UserControl` that draws 6 circles with labels beneath, connected by lines:
```
●━━━●━━━●━━━○━━━○━━━○
Linear BestFit Pairs NFP Extents Custom
```
### All 6 phases
The stepper displays all values from the `NestPhase` enum in enum order: `Linear`, `RectBestFit` (labeled "BestFit"), `Pairs`, `Nfp` (labeled "NFP"), `Extents`, `Custom`. This ensures the control stays accurate as new phases are added or existing ones start being used.
### Non-sequential design
The engine does not execute phases in a fixed order. `DefaultNestEngine` runs strategies in registration order (Linear → Pairs → RectBestFit → Extents by default), and custom engines may run any subset in any order. Some phases may never execute.
The stepper tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress. The connecting lines are purely decorative (always light gray).
### Visual states
- **Active:** Filled circle with accent color (`#0078D4`), slightly larger radius (11px vs 9px), subtle glow (`Color.FromArgb(60, 0, 120, 212)` drawn as a larger circle behind), bold label
- **Visited:** Filled circle with accent color, normal radius, bold label
- **Pending:** Hollow circle with gray border (`#C0C0C0`), dimmed label text (`#999999`)
- **All complete:** All 6 circles filled (set when `IsComplete = true`)
- **Initial state:** All 6 circles in Pending state
### Implementation
- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray (`#D0D0D0`).
- Colors and fonts defined as `static readonly` fields. Fonts cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates.
- State: `HashSet<NestPhase> VisitedPhases`, `NestPhase? ActivePhase` property. Setting `ActivePhase` adds to `VisitedPhases` and calls `Invalidate()`. `bool IsComplete` marks all phases done.
- `DoubleBuffered = true`.
- Fixed height: 60px. Docks to fill width.
- Namespace: `OpenNest.Controls`.
- Phase display order matches `NestPhase` enum order. Display names: `RectBestFit` → "BestFit", `Nfp` → "NFP", others use `ToString()`.
## Form Layout
Four vertical zones using `DockStyle.Top` stacking:
```
┌──────────────────────────────────────────────┐
│ ●━━━●━━━●━━━○━━━○━━━○ │ Phase stepper
│ Linear BestFit Pairs Extents NFP Custom │
├──────────────────────────────────────────────┤
│ RESULTS │ Results group
│ Parts: 156 │
│ Density: 68.3% ████████░░ │
│ Nested: 24.1 x 36.0 (867.6 sq in) │
├──────────────────────────────────────────────┤
│ STATUS │ Status group
│ Plate: 2 │
│ Elapsed: 1:24 │
│ Detail: Trying best fit... │
├──────────────────────────────────────────────┤
│ [ Stop ] │ Button bar
└──────────────────────────────────────────────┘
```
### Group panels
Each group is a `Panel` containing:
- A header label ("RESULTS" / "STATUS") — Segoe UI 9pt bold, uppercase, color `#555555`, with `0.5px` letter spacing effect (achieved by drawing or just using uppercase text)
- A `TableLayoutPanel` with label/value rows beneath
Group panels use `Color.White` `BackColor` against the form's `SystemColors.Control` (gray) background. Small padding (10px horizontal, 4px vertical gap between groups).
### Typography
- All fonts: Segoe UI
- Group headers: 9pt bold, uppercase, color `#555555`
- Row labels: 8.25pt bold, color `#333333`
- Row values: Consolas 8.25pt regular — monospaced so numeric values don't shift width as digits change
- Detail value: Segoe UI 8.25pt regular (not monospaced, since it's descriptive text)
### Sizing
- Width: ~450px
- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~115px) + status group (~95px) + button bar (~45px) + padding
- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false`
### Plate row visibility
The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior).
### Phase description text
The phase stepper replaces the old Phase row. The descriptive text ("Trying rotations...") moves to the Detail row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when set.
### Unused row removed
The current form has `remnantLabel`/`remnantValue` but `NestProgress` has no unused/remnant property — these labels are never updated and always show "—". The redesign drops this row entirely.
### FormatPhase updates
`FormatPhase` currently handles Linear, RectBestFit, and Pairs. Add entries for the three remaining phases:
- `Extents` → "Trying extents..."
- `Nfp` → "Trying NFP..."
- `Custom` → phase name via `ToString()`
## Density Sparkline Bar
A small inline visual next to the density percentage value:
- Size: 60px wide, 8px tall
- Background: `#E0E0E0` (light gray track)
- Fill: gradient from orange (`#F5A623`) on the left to green (`#4CAF50`) on the right, clipped to the density percentage width
- Border radius: 4px
- Position: inline, 8px margin-left from the density text
### Implementation
Owner-drawn directly in a custom `Label` subclass or a small `Panel` placed next to the density value in the table. The simplest approach: a small `Panel` with `OnPaint` override that draws the track and fill. Updated whenever density changes.
**New file: `OpenNest/Controls/DensityBar.cs`** — a lightweight `Control` subclass:
- `double Value` property (0.0 to 1.0), calls `Invalidate()` on set
- `OnPaint`: fills rounded rect background, then fills gradient portion proportional to `Value`
- Fixed size: 60 x 8px
- `DoubleBuffered = true`
## Flash & Fade
### Current implementation (keep, with modification)
Values flash green (`Color.FromArgb(0, 160, 0)`) when they change and fade back to `SystemColors.ControlText` over ~1 second (20 steps at 50ms). A `SetValueWithFlash` helper checks if text actually changed before triggering. A single `System.Windows.Forms.Timer` drives all active fades.
### Color-coded density flash
Extend the flash color for the density value based on quality:
- Below 50%: red (`Color.FromArgb(200, 40, 40)`)
- 50% to 70%: yellow/orange (`Color.FromArgb(200, 160, 0)`)
- Above 70%: green (`Color.FromArgb(0, 160, 0)`) — same as current
### Fade state changes
The `SetValueWithFlash` method gains an optional `Color? flashColor` parameter. The fade dictionary changes from `Dictionary<Label, int>` to `Dictionary<Label, (int remaining, Color flashColor)>` so that each label fades from its own flash color. `FadeTimer_Tick` reads the per-label `flashColor` from the tuple when interpolating back to `SystemColors.ControlText`, rather than using the static `FlashColor` constant. `FlashColor` becomes the default when `flashColor` is null.
`UpdateProgress` passes the density-appropriate color when updating `densityValue`. All other values continue using the default green.
## Accept & Stop Buttons
Currently the form has a single "Stop" button that cancels the `CancellationTokenSource`. Callers check `token.IsCancellationRequested` and discard results when true. This means there's no way to stop early and keep the current best result.
### New button layout
Two buttons in the button bar, right-aligned:
```
[ Accept ] [ Stop ]
```
- **Accept:** Stops the engine and keeps the current best result. Sets `Accepted = true`, then cancels the token.
- **Stop:** Stops the engine and discards results. Leaves `Accepted = false`, cancels the token.
Both buttons are disabled until the first progress update arrives (so there's something to accept). After `ShowCompleted()`, both are replaced by a single "Close" button (same as current behavior).
### Accepted property
`bool Accepted { get; private set; }` — defaults to `false`. Set to `true` only by the Accept button click handler.
### Caller changes
Four callsites create a `NestProgressForm`. Each needs to honor the `Accepted` property:
**`MainForm.cs``RunAutoNest_Click`** (line ~868):
```csharp
// Before:
if (nestParts.Count > 0 && !token.IsCancellationRequested)
// After:
if (nestParts.Count > 0 && (!token.IsCancellationRequested || progressForm.Accepted))
```
**`MainForm.cs``FillPlate_Click`** (line ~983): No change needed — this path already accepts regardless of cancellation state (`if (parts.Count > 0)`).
**`MainForm.cs``FillArea_Click`** (line ~1024): No change needed — this path delegates to `ActionFillArea` which handles its own completion via a callback.
**`PlateView.cs``FillWithProgress`** (line ~933):
```csharp
// Before:
if (parts.Count > 0 && !cts.IsCancellationRequested)
// After:
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
```
## Public API
### Constructor
`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged.
### Properties
- `bool Accepted { get; }`**new**. True if user clicked Accept, false if user clicked Stop or form was closed.
### UpdateProgress(NestProgress progress)
Same as today, plus:
- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper
- Updates `densityBar.Value = progress.BestDensity`
- Passes color-coded flash color for density value
- Writes `FormatPhase(progress.Phase)` to Detail row as fallback when `progress.Description` is empty
- Enables Accept/Stop buttons on first call (if not already enabled)
### ShowCompleted()
Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles.
## Files Touched
| File | Change |
|------|--------|
| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control |
| `OpenNest/Controls/DensityBar.cs` | New — small density sparkline bar control |
| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration, color-coded flash, Accept/Stop buttons |
| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout |
| `OpenNest/Forms/MainForm.cs` | Update `RunAutoNest_Click` to check `progressForm.Accepted` |
| `OpenNest/Controls/PlateView.cs` | Update `FillWithProgress` to check `progressForm.Accepted` |
@@ -1,138 +0,0 @@
# Two-Bucket Preview Parts
## Problem
During nesting, the PlateView preview shows whatever the latest progress report contains. When the engine runs multiple strategies sequentially (Pairs, Linear, RectBestFit, Extents), each strategy reports its own intermediate results. A later strategy starting fresh can report fewer parts than an earlier winner, causing the preview to visually regress. The user sees the part count drop and the layout change, even though the engine internally tracks the overall best.
A simple high-water-mark filter at the UI level prevents regression but freezes the preview and can diverge from the engine's actual result, causing the wrong layout to be accepted.
## Solution
Split the preview into two visual layers:
- **Stationary parts**: The overall best result found so far across all strategies. Never regresses. Drawn at full preview opacity.
- **Active parts**: The current strategy's work-in-progress. Updates freely as strategies iterate. Drawn at reduced opacity (~50% alpha).
The engine flags progress reports as `IsOverallBest` so the UI knows which bucket to update. On acceptance, the engine's returned result is used directly, not the preview state. This also fixes an existing bug where `AcceptTemporaryParts()` could accept stale preview parts instead of the engine's actual output.
## Changes
### NestProgress
Add one property:
```csharp
public bool IsOverallBest { get; set; }
```
Default `false`. Set to `true` by `RunPipeline` when reporting the overall winner, and by the final `ReportProgress` calls in `DefaultNestEngine.Fill`.
### NestEngineBase.ReportProgress
Add an optional `isOverallBest` parameter:
```csharp
internal static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description,
bool isOverallBest = false)
```
Pass through to the `NestProgress` object.
### DefaultNestEngine.RunPipeline
Remove the existing `ReportProgress` call from inside the `if (IsBetterFill(...))` block. Replace it with an unconditional report of the overall best after each strategy completes:
```csharp
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
{
context.CurrentBest = result;
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
context.WinnerPhase = strategy.Phase;
}
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
{
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
isOverallBest: true);
}
```
Strategy-internal progress reports (PairFiller, LinearFillStrategy, etc.) continue using the default `isOverallBest: false`.
### DefaultNestEngine.Fill — final reports
Both `Fill` overloads have a final `ReportProgress` call after the pipeline/fill completes. These must pass `isOverallBest: true` so the final preview goes to stationary parts at full opacity:
- `Fill(NestItem, Box, ...)` line 58 — reports the pipeline winner after quantity trimming
- `Fill(List<Part>, Box, ...)` line 85 — reports the single-strategy linear result
### ColorScheme
Add two members for the active (transparent) preview style, created alongside the existing preview resources in the `PreviewPartColor` setter with the same disposal pattern:
- `ActivePreviewPartBrush` — same color as `PreviewPartBrush` at ~50% alpha
- `ActivePreviewPartPen` — same color as `PreviewPartPen` at ~50% alpha
### PlateView
Rename `temporaryParts` to `activeParts`. Add `stationaryParts` (both `List<LayoutPart>`).
**New public API:**
- `SetStationaryParts(List<Part>)` — sets the overall-best preview, calls `Invalidate()`
- `SetActiveParts(List<Part>)` — sets the current-strategy preview, calls `Invalidate()`
- `ClearPreviewParts()` — clears both lists, calls `Invalidate()` (replaces `ClearTemporaryParts()`)
- `AcceptPreviewParts(List<Part> parts)` — adds the engine's returned `parts` directly to the plate, clears both lists. Decouples acceptance from preview state.
**Internal references:** `Refresh()`, `UpdateMatrix()`, and `SetPlate()` currently reference `temporaryParts`. All must be updated to handle both `stationaryParts` and `activeParts` (clear both in `SetPlate`, update both in `Refresh`/`UpdateMatrix`).
**Drawing order in `DrawParts`:**
1. Stationary parts: `PreviewPartBrush` / `PreviewPartPen` (full opacity)
2. Active parts: `ActivePreviewPartBrush` / `ActivePreviewPartPen` (~50% alpha)
**Remove:** `SetTemporaryParts`, `ClearTemporaryParts`, `AcceptTemporaryParts`.
### Progress callbacks
All four progress callback sites (PlateView.FillWithProgress, 3x MainForm) change from:
```csharp
SetTemporaryParts(p.BestParts);
```
to:
```csharp
if (p.IsOverallBest)
plateView.SetStationaryParts(p.BestParts);
else
plateView.SetActiveParts(p.BestParts);
```
### Acceptance points
All acceptance points change from `AcceptTemporaryParts()` to `AcceptPreviewParts(engineResult)` where `engineResult` is the `List<Part>` returned by the engine. The multi-plate nest path in MainForm already uses `plate.Parts.AddRange(nestParts)` directly and needs no change beyond clearing preview parts.
## Threading
All `SetStationaryParts`/`SetActiveParts` calls arrive on the UI thread via `Progress<T>` (which captures `SynchronizationContext` at construction). `DrawParts` also runs on the UI thread. No concurrent access to either list.
## Files Modified
| File | Change |
|------|--------|
| `OpenNest.Engine/NestProgress.cs` | Add `IsOverallBest` property |
| `OpenNest.Engine/NestEngineBase.cs` | Add `isOverallBest` parameter to `ReportProgress` |
| `OpenNest.Engine/DefaultNestEngine.cs` | Report overall best after each strategy; flag final reports |
| `OpenNest/Controls/PlateView.cs` | Two-bucket temp parts, new API, update internal references |
| `OpenNest/Controls/ColorScheme.cs` | Add active preview brush/pen with disposal |
| `OpenNest/Forms/MainForm.cs` | Update 3 progress callbacks and acceptance points |
| `OpenNest/Actions/ActionFillArea.cs` | No change needed (uses PlateView API) |
@@ -1,88 +0,0 @@
# Iterative Shrink-Fill Design
## Problem
`StripNestEngine` currently picks a single "strip" drawing (the highest-area item), shrink-fills it into the tightest sub-region, then fills remnants with remaining drawings. This wastes potential density — every drawing benefits from shrink-filling into its tightest sub-region, not just the first one.
## Design
### 1. IterativeShrinkFiller
New static class in `OpenNest.Engine/Fill/IterativeShrinkFiller.cs`.
**Responsibility:** Given an ordered list of multi-quantity `NestItem` and a work area, iteratively shrink-fill each item into the tightest sub-region using `RemnantFiller` + `ShrinkFiller`, returning placed parts and leftovers.
**Algorithm:**
1. Create a `RemnantFiller` with the work area and spacing.
2. Build a single fill function (closure) that wraps the caller-provided raw fill function with dual-direction shrink logic:
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Height` (bottom strip direction).
- Calls `ShrinkFiller.Shrink` with `ShrinkAxis.Width` (left strip direction).
- Compares results using `FillScore.Compute(parts, box)` where `box` is the remnant box passed by `RemnantFiller`. Since `FillScore` density is derived from placed parts' bounding box (not the work area parameter), the comparison is valid regardless of which box is used.
- Returns the parts from whichever direction scores better.
3. Pass this wrapper function and all items to `RemnantFiller.FillItems`, which drives the iteration — discovering free rectangles, iterating over items and boxes, and managing obstacle tracking.
4. After `RemnantFiller.FillItems` returns, collect any unfilled quantities (including `Quantity <= 0` items which mean "unlimited") into a leftovers list.
5. Return placed parts and leftovers. Remaining free space for the pack pass is reconstructed from placed parts by the caller (existing pattern), not by returning `RemnantFinder` state.
**Data flow:** Caller provides a raw single-item fill function (e.g., `DefaultNestEngine.Fill`) → `IterativeShrinkFiller` wraps it in a dual-direction shrink closure → passes the wrapper to `RemnantFiller.FillItems` which drives the loop.
**Note on quantities:** `Quantity <= 0` means "fill as many as possible" (unlimited). These items are included in the fill bucket (qty != 1), not the pack bucket.
**Interface:**
```csharp
public class IterativeShrinkResult
{
public List<Part> Parts { get; set; }
public List<NestItem> Leftovers { get; set; }
}
public static class IterativeShrinkFiller
{
public static IterativeShrinkResult Fill(
List<NestItem> items,
Box workArea,
Func<NestItem, Box, List<Part>> fillFunc,
double spacing,
CancellationToken token);
}
```
The class composes `RemnantFiller` and `ShrinkFiller` — it does not duplicate their logic.
### 2. Revised StripNestEngine.Nest
**Note:** The rotating calipers angle is already included via `RotationAnalysis.FindBestRotation`, which calls `RotatingCalipers.MinimumBoundingRectangle` and feeds the result as `bestRotation` into `AngleCandidateBuilder.Build`. No changes needed to the angle pipeline.
The `Nest` override becomes a thin orchestrator:
1. Separate items into multi-quantity (qty != 1) and singles (qty == 1).
2. Sort multi-quantity items by `Priority` ascending, then `Drawing.Area` descending.
3. Call `IterativeShrinkFiller.Fill` with the sorted multi-quantity items.
4. Collect leftovers: unfilled multi-quantity remainders + all singles.
5. If leftovers exist and free space remains, run `PackArea` into the remaining area.
6. Deduct placed quantities from the original items. Return all parts.
**Deleted code:**
- `SelectStripItemIndex` method
- `EstimateStripDimension` method
- `TryOrientation` method
- `ShrinkFill` method
**Deleted files:**
- `StripNestResult.cs`
- `StripDirection.cs`
## Files Changed
| File | Change |
|------|--------|
| `OpenNest.Engine/Fill/IterativeShrinkFiller.cs` | New — orchestrates RemnantFiller + ShrinkFiller with dual-direction selection |
| `OpenNest.Engine/StripNestEngine.cs` | Rewrite Nest to use IterativeShrinkFiller + pack leftovers |
| `OpenNest.Engine/StripNestResult.cs` | Delete |
| `OpenNest.Engine/StripDirection.cs` | Delete |
## Not In Scope
- Trying multiple item orderings and picking the best overall `FillScore` — future follow-up once we confirm the iterative approach is fast enough.
- Changes to `NestEngineBase`, `DefaultNestEngine`, `RemnantFiller`, `ShrinkFiller`, `RemnantFinder`, `AngleCandidateBuilder`, `NestItem`, or UI code.
@@ -1,51 +0,0 @@
# Lead Item Rotation for Strip Nesting
## Problem
`StripNestEngine.Nest()` sorts multi-quantity items by priority then area descending, always placing the largest-area drawing first. This fixed ordering can produce suboptimal layouts — a different starting drawing may create a tighter shrink region that leaves more usable remnant space for subsequent items.
## Solution
Try multiple candidate orderings by promoting each of the top N largest drawings to the front of the fill list. Run the full pipeline for each ordering, score the results, and keep the best.
## Candidate Generation
- Take the multi-quantity fill items (already filtered from singles)
- Identify the top `MaxLeadCandidates` (default 3) unique drawings by `Drawing.Area`, deduplicated by `Drawing` reference equality
- If there is only one unique drawing, skip the multi-ordering loop entirely (no-op — only one possible ordering)
- For each candidate drawing, create a reordered copy of the fill list where that drawing's items move to the front, preserving the original relative order for the remaining items
- The default ordering (largest area first) is always the first candidate, so the feature never regresses
- Lead promotion intentionally overrides the existing priority-then-area sort — the purpose is to explore whether a different lead item produces a better overall layout regardless of the default priority ordering
## Scoring
Use `FillScore` semantics for cross-ordering comparison: total placed part count as the primary metric, plate utilization (`sum(part.BaseDrawing.Area) / plate.WorkArea().Area()`) as tiebreaker. This is consistent with how `FillScore` works elsewhere in the codebase (count > density). Keep the first (default) result unless a later candidate is strictly better, so ties preserve the default ordering.
## Execution
- Run each candidate ordering sequentially through the existing pipeline: `IterativeShrinkFiller` → compaction → packing
- No added parallelism — each run already uses `Parallel.Invoke` internally for shrink axes
- `IterativeShrinkFiller.Fill` is a static method that creates fresh internal state (`RemnantFiller`, `placedSoFar` list) on each call, so the same input item list can be passed to multiple runs without interference. Neither `IterativeShrinkFiller` nor `RemnantFiller` mutate `NestItem.Quantity`. Each run also produces independent `Part` instances (created by `DefaultNestEngine.Fill`), so compaction mutations on one run's parts don't affect another.
- Only the winning result gets applied to the quantity deduction at the end of `Nest()`
## Progress Reporting
- Each candidate run reports progress normally (user sees live updates during shrink iterations)
- Between candidates, report a status message like "Lead item 2/3: [drawing name]"
- Only the final winning result is reported with `isOverallBest: true` to avoid the UI flashing between intermediate results
## Early Exit
- If a candidate meets all requested quantities **and** plate utilization exceeds 50%, skip remaining candidates
- Unlimited-quantity items (`Quantity <= 0`) never satisfy the quantity condition, so all candidates are always tried
- Cancellation token is respected — if cancelled mid-run, return the best result across all completed candidates
- The 50% threshold is a constant (`MinEarlyExitUtilization`) that can be tuned if typical nesting utilization proves higher or lower
## Scope
Changes are confined to `StripNestEngine.Nest()`. No modifications to `IterativeShrinkFiller`, `ShrinkFiller`, `DefaultNestEngine`, fill strategies, or the UI.
## Files
- Modify: `OpenNest.Engine/StripNestEngine.cs`
- Add test: `OpenNest.Tests/StripNestEngineTests.cs` (verify multiple orderings are tried, early exit works)
@@ -1,238 +0,0 @@
# Nest API Design
## Overview
A new `OpenNest.Api` project providing a clean programmatic facade for nesting operations. A single `NestRequest` goes in, a self-contained `NestResponse` comes out. Designed for external callers (MCP server, console app, LaserQuote, future web API) that don't want to manually wire engine + timing + IO.
## Motivation
Today, running a nest programmatically requires manually coordinating:
1. DXF import (IO layer)
2. Plate/NestItem setup (Core)
3. Engine selection and execution (Engine layer)
4. Timing calculation (Core's `Timing` class)
5. File persistence (IO layer)
This design wraps all five steps behind a single stateless call. The response captures the original request, making nests reproducible and re-priceable months later.
## New Project: `OpenNest.Api`
Class library targeting `net8.0-windows`. References Core, Engine, and IO.
```
OpenNest.Api/
CutParameters.cs
NestRequest.cs
NestRequestPart.cs
NestStrategy.cs
NestResponse.cs
NestRunner.cs
```
All types live in the `OpenNest.Api` namespace.
## Types
### CutParameters
Unified timing and quoting parameters. Replaces the existing `OpenNest.CutParameters` in Core.
```csharp
namespace OpenNest.Api;
public class CutParameters
{
public double Feedrate { get; init; } // in/min or mm/sec depending on Units
public double RapidTravelRate { get; init; } // in/min or mm/sec
public TimeSpan PierceTime { get; init; }
public double LeadInLength { get; init; } // forward-looking: unused until Timing rework
public string PostProcessor { get; init; } // forward-looking: unused until Timing rework
public Units Units { get; init; }
public static CutParameters Default => new()
{
Feedrate = 100,
RapidTravelRate = 300,
PierceTime = TimeSpan.FromSeconds(0.5),
Units = Units.Inches
};
}
```
`LeadInLength` and `PostProcessor` are included for forward compatibility but will not be wired into `Timing.CalculateTime` until the Timing rework. Implementers should not attempt to use them in the initial implementation.
### NestRequestPart
A part to nest, identified by DXF file path. No `Drawing` reference — keeps the request fully serializable for persistence.
```csharp
namespace OpenNest.Api;
public class NestRequestPart
{
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
public int Priority { get; init; } = 0;
}
```
### NestStrategy
```csharp
namespace OpenNest.Api;
public enum NestStrategy { Auto }
```
- `Auto` maps to `DefaultNestEngine` (multi-phase fill).
- Additional strategies (`Linear`, `BestFit`, `Pack`) will be added later, driven by ML-based auto-detection of part type during the training work. The intelligence for selecting the best strategy for a given part will live inside `DefaultNestEngine`.
### NestRequest
Immutable input capturing everything needed to run and reproduce a nest.
```csharp
namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
public Size SheetSize { get; init; } = new(60, 120); // OpenNest.Geometry.Size(width, length)
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1; // part-to-part spacing; edge spacing defaults to zero
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}
```
- `Parts` uses `IReadOnlyList<T>` to prevent mutation after construction, preserving reproducibility when the request is stored in the response.
- `Spacing` maps to `Plate.PartSpacing`. `Plate.EdgeSpacing` defaults to zero on all sides.
- `SheetSize` is `OpenNest.Geometry.Size` (not `System.Drawing.Size`).
### NestResponse
Immutable output containing computed metrics, the resulting `Nest`, and the original request for reproducibility.
```csharp
namespace OpenNest.Api;
public class NestResponse
{
public int SheetCount { get; init; }
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
public Task SaveAsync(string path) => ...;
public static Task<NestResponse> LoadAsync(string path) => ...;
}
```
`SaveAsync`/`LoadAsync` live on the data class for API simplicity — a pragmatic choice over a separate IO helper class.
### NestRunner
Stateless orchestrator. Single public method.
```csharp
namespace OpenNest.Api;
public static class NestRunner
{
public static async Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
// 1. Validate request (non-empty parts list, all DXF paths exist)
// 2. Import DXFs → Drawings via DxfImporter + ConvertGeometry.ToProgram
// 3. Create Plate from request.SheetSize / Thickness / Spacing
// 4. Convert NestRequestParts → NestItems
// 5. Multi-plate loop:
// a. Create engine via NestEngineRegistry
// b. Fill plate
// c. Deduct placed quantities
// d. If remaining quantities > 0, create next plate and repeat
// 6. Compute TimingInfo → CutTime using request.Cutting (placeholder for Timing rework)
// 7. Build and return NestResponse (with stopwatch for Elapsed)
}
}
```
- Static class, no state, no DI. If we need dependency injection later, we add an instance-based overload.
- `Timing.CalculateTime` is called with the new `CutParameters` (placeholder integration — `Timing` will be reworked later).
#### Multi-plate Loop
`NestRunner` handles multi-plate nesting: it fills a plate, deducts placed quantities from the remaining request, creates a new plate, and repeats until all quantities are met or no progress is made (a part doesn't fit on a fresh sheet). This is new logic — `AutoNester` and `NestEngineBase` are single-plate only.
#### Error Handling
- If any DXF file path does not exist or fails to import (empty geometry, conversion failure), `RunAsync` throws `FileNotFoundException` or `InvalidOperationException` with a message identifying the failing file. Fail-fast on first bad DXF — no partial results.
- If cancellation is requested, the method throws `OperationCanceledException` per standard .NET patterns.
## Renames
| Current | New | Reason |
|---------|-----|--------|
| `OpenNest.NestResult` (Engine) | `OpenNest.OptimizationResult` | Frees the "result" name for the public API; this type is engine-internal (sequence/score/iterations) |
| `OpenNest.CutParameters` (Core) | Deleted | Replaced by `OpenNest.Api.CutParameters` |
| `.opnest` file extension | `.nest` | Standardize file extensions |
All references to the renamed types and extensions must be updated across the solution: Engine, Core, IO, MCP, Console, Training, Tests, and WinForms.
The WinForms project gains a reference to `OpenNest.Api` to use the new `CutParameters` type (it already references Core and Engine, so no circular dependency).
## Persistence
### File Extensions
- **`.nest`** — nest files (renamed from `.opnest`)
- **`.nestquote`** — quote files (new)
### `.nestquote` Format
ZIP archive containing:
```
quote.nestquote (ZIP)
├── request.json ← serialized NestRequest
├── response.json ← computed metrics (SheetCount, Utilization, CutTime, Elapsed)
└── nest.nest ← embedded .nest file (existing format, produced by NestWriter)
```
- `NestResponse.SaveAsync(path)` writes this ZIP. The embedded `nest.nest` is written to a `MemoryStream` via `NestWriter`, then added as a ZIP entry alongside the JSON files.
- `NestResponse.LoadAsync(path)` reads it back using `NestReader` for the `.nest` payload and JSON deserialization for the metadata.
- Source DXF files are **not** embedded — they are referenced by path in `request.json`. The actual geometry is captured in the `.nest`. Paths exist so the request can be re-run with different parameters if the DXFs are still available.
## Consumer Integration
### MCP Server
The MCP server can expose a single `nest_and_quote` tool that takes request parameters and calls `NestRunner.RunAsync()` internally, replacing the current multi-tool orchestration for batch nesting workflows.
### Console App
The console app gains a one-liner for batch nesting:
```csharp
var response = await NestRunner.RunAsync(request, progress, token);
await response.SaveAsync(outputPath);
```
### WinForms
The WinForms app continues using the engine directly for its interactive workflow. It gains a reference to `OpenNest.Api` only for the shared `CutParameters` type used by `TimingForm` and `CutParametersForm`.
## Out of Scope
- ML-based auto-strategy detection in `DefaultNestEngine` (future, part of training work)
- `Timing` rework (will happen separately; placeholder integration for now)
- Embedding source DXFs in `.nestquote` files
- Builder pattern for `NestRequest` (C# `init` properties suffice)
- DI/instance-based `NestRunner` (add later if needed)
- Additional `NestStrategy` enum values beyond `Auto` (added with ML work)
@@ -1,66 +0,0 @@
# Trim-to-Count: Replace ShrinkFiller Loop with Edge-Sorted Trim
## Problem
When a fill produces more parts than needed, `ShrinkFiller` iteratively shrinks the work area and re-fills from scratch until the count drops below target. Each iteration runs the full fill pipeline (pairs, bestfit, linear), making this expensive. Meanwhile, `DefaultNestEngine.Fill` trims excess parts with a blind `Take(N)` that ignores spatial position.
## Solution
Add `ShrinkFiller.TrimToCount` — a static method that sorts parts by their trailing edge and removes from the far end until the target count is reached. Replace the shrink loop and the blind `Take(N)` with calls to this method.
## Design
### New method: `ShrinkFiller.TrimToCount`
```csharp
internal static List<Part> TrimToCount(List<Part> parts, int targetCount, ShrinkAxis axis)
```
- Returns input unchanged if `parts.Count <= targetCount`
- Sorts ascending by trailing edge, takes the first `targetCount` parts (keeps parts nearest to origin, discards farthest):
- `ShrinkAxis.Width` → sort ascending by `BoundingBox.Right`
- `ShrinkAxis.Height` → sort ascending by `BoundingBox.Top`
- Returns a new list (does not mutate input)
### Changes to `ShrinkFiller.Shrink`
Replace the iterative shrink loop:
1. Fill once using existing `EstimateStartBox` + fallback logic (unchanged)
2. If count exceeds `shrinkTarget`, call `TrimToCount(parts, shrinkTarget, axis)`
3. Measure dimension from trimmed result via existing `MeasureDimension`
4. Report progress once after trim
5. Return `ShrinkResult`
Parameters removed from `Shrink`: `maxIterations` (no loop). The `spacing` parameter is kept (used by `EstimateStartBox`). `CancellationToken` is kept in the signature for API consistency even though the loop no longer uses it.
### Changes to `DefaultNestEngine.Fill`
Replace line 55-56:
```csharp
// Before:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
// After:
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
```
Defaults to `ShrinkAxis.Width` (trim by right edge) since this is the natural "end of nest" direction outside of a shrink context.
## Design Decisions
- **Axis-aware trimming**: Height shrink trims by top edge, width shrink trims by right edge. This respects the strip direction.
- **No pair integrity**: Trimming may split interlocking pairs. This is acceptable because if the layout is suboptimal, a better candidate will replace it during evaluation.
- **No edge spacing concerns**: The new dimension is simply the max edge of remaining parts. No snapping to spacing increments.
- **`MeasureDimension` unchanged**: It measures the occupied extent of remaining parts relative to `box.X`/`box.Y` (the work area origin). This works correctly after trimming.
- **`EstimateStartBox` preserved**: It was designed to accelerate the iterative loop, which is now gone. It still helps by producing a smaller starting fill, but could be simplified in a future pass.
- **Behavioral trade-off**: The shrink loop found the smallest box fitting N parts; trim-to-count reports the actual extent of the N nearest parts, which may be slightly less tight if there are gaps. In practice this is negligible since fill algorithms pack densely.
## Files Changed
- `OpenNest.Engine/Fill/ShrinkFiller.cs` — add `TrimToCount`, replace shrink loop, remove `maxIterations`
- `OpenNest.Engine/DefaultNestEngine.cs` — replace `Take(N)` with `TrimToCount`
- `OpenNest.Tests/ShrinkFillerTests.cs` — delete `Shrink_RespectsMaxIterations` test (concept no longer exists), update remaining tests, add `TrimToCount` tests
@@ -1,129 +0,0 @@
# NFP-Based Best-Fit Strategy
## Problem
The current best-fit pair generation uses `RotationSlideStrategy`, which samples Part2 positions by sliding it toward Part1 from 4 directions at discrete step sizes. This is brute-force: more precision requires more samples, it can miss optimal interlocking positions between steps, and it generates hundreds of candidates per rotation angle.
## Solution
Replace the slide-based sampling with NFP (No-Fit Polygon) computation. The NFP of two polygons gives the exact mathematical boundary of all valid positions where Part2 can touch Part1 without overlapping. Every point on that boundary is a guaranteed-valid candidate offset.
## Approach
Implement `NfpSlideStrategy : IBestFitStrategy` that plugs into the existing `BestFitFinder` pipeline. No changes to `PairEvaluator`, `BestFitFilter`, `BestFitResult`, tiling, or caching.
## Design
### New class: `NfpSlideStrategy`
**Location:** `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`
**Implements:** `IBestFitStrategy`
**Constructor parameters:**
- `double part2Rotation` — rotation angle for Part2 (same as `RotationSlideStrategy`)
- `int type` — strategy type id (same as `RotationSlideStrategy`)
- `string description` — human-readable description
- `Polygon stationaryPoly` (optional) — pre-extracted stationary polygon to avoid redundant extraction across rotation angles
**`GenerateCandidates(Drawing drawing, double spacing, double stepSize)`:**
1. Extract perimeter polygon from the drawing inflated by `spacing / 2` using `PolygonHelper.ExtractPerimeterPolygon` (shared helper, extracted from `AutoNester`)
2. If polygon extraction fails (null), return empty list
3. Create a rotated copy of the polygon at `part2Rotation` using `PolygonHelper.RotatePolygon` (also extracted)
4. Compute `NoFitPolygon.Compute(stationaryPoly, orbitingPoly)` — single call
5. If the NFP is null or has fewer than 3 vertices, return empty list
6. Convert NFP vertices from polygon-space to Part-space (see Coordinate Correction below)
7. Walk the NFP boundary:
- Each vertex becomes a `PairCandidate` with that vertex as `Part2Offset`
- For edges longer than `stepSize`, add intermediate sample points starting at `stepSize` from the edge start, exclusive of endpoints (to avoid duplicates with vertex candidates)
- Skip the closing vertex if the polygon is closed (first == last)
8. Part1 is always at rotation 0, matching existing `RotationSlideStrategy` behavior
9. Return the candidates list
### Coordinate correction
`ExtractPerimeterPolygon` inflates by `halfSpacing` and re-normalizes to origin based on the inflated bounding box. `Part.CreateAtOrigin` normalizes using the raw program bounding box — a different reference point. NFP offsets are in polygon-space and must be mapped to Part-space.
**Correction:** Compute the offset between the two reference points:
```
programOrigin = (program.BoundingBox.Left, program.BoundingBox.Bottom)
polygonOrigin = (inflatedPerimeter.BoundingBox.Left, inflatedPerimeter.BoundingBox.Bottom) → (0, 0) after normalization
correction = programOrigin - polygonOrigin
```
Since both are normalized to (0,0), the actual correction is the difference between where the inflated perimeter's bottom-left sits relative to the program's bottom-left *before* normalization. In practice:
- The program bbox includes all entities (rapid moves, all layers)
- The perimeter polygon only uses non-rapid cut geometry, inflated outward
`PolygonHelper` will compute this correction vector once per drawing and return it alongside the polygon. `NfpSlideStrategy` applies it to each NFP vertex before creating `PairCandidate` offsets.
### Floating-point boundary tolerance
NFP boundary positions represent exact touching. Floating-point imprecision may cause `PairEvaluator`'s shape-intersection test to falsely detect overlap at valid boundary points. The `PairEvaluator` overlap check serves as a safety net — a few boundary positions may be filtered out, but the best results should remain valid since we sample many boundary points.
### Shared helper: `PolygonHelper`
**Location:** `OpenNest.Engine/BestFit/PolygonHelper.cs`
**Static methods extracted from `AutoNester`:**
- `ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)` — extracts and inflates the perimeter polygon
- `RotatePolygon(Polygon polygon, double angle)` — creates a rotated copy normalized to origin
After extraction, `AutoNester` delegates to these methods to avoid duplication.
### Changes to `BestFitFinder.BuildStrategies`
Replace `RotationSlideStrategy` instances with `NfpSlideStrategy` instances. Same rotation angles from `GetRotationAngles(drawing)`, different strategy class. No `ISlideComputer` dependency needed.
Extract the stationary polygon once and pass it to each strategy to avoid redundant computation (strategies run in `Parallel.ForEach`):
```csharp
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
// Extract stationary polygon once, shared across all rotation strategies.
var stationaryPoly = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
foreach (var angle in angles)
{
var desc = $"{Angle.ToDegrees(angle):F1} deg NFP";
strategies.Add(new NfpSlideStrategy(angle, type++, desc, stationaryPoly));
}
return strategies;
}
```
Note: spacing inflation is applied inside `GenerateCandidates` since it depends on the `spacing` parameter, not at strategy construction time.
### No changes required
- `PairEvaluator` — still evaluates candidates (overlap check becomes redundant but harmless and fast)
- `BestFitFilter` — still filters results by aspect ratio, plate fit, etc.
- `BestFitResult` — unchanged
- `BestFitCache` — unchanged
- Tiling pipeline — unchanged
- `PairsFillStrategy` — unchanged
## Edge Sampling
NFP vertices alone may miss optimal positions along long straight edges. For each edge of the NFP polygon where `edgeLength > stepSize`, interpolate additional points at `stepSize` intervals. This reuses the existing `stepSize` parameter meaningfully — it controls resolution along NFP edges rather than grid spacing.
## Files Changed
| File | Change |
|------|--------|
| `OpenNest.Engine/BestFit/NfpSlideStrategy.cs` | New — `IBestFitStrategy` implementation |
| `OpenNest.Engine/BestFit/PolygonHelper.cs` | New — shared polygon extraction/rotation |
| `OpenNest.Engine/Nfp/AutoNester.cs` | Delegate to `PolygonHelper` methods |
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | Swap `RotationSlideStrategy` for `NfpSlideStrategy` in `BuildStrategies` |
## What This Does NOT Change
- The `RotationSlideStrategy` class stays in the codebase (not deleted) in case GPU slide computation is still wanted
- The `ISlideComputer` / GPU pipeline remains available
- `BestFitFinder` constructor still accepts `ISlideComputer` but it won't be passed to NFP strategies (they don't need it)