docs: update NFP autonest spec with review fixes
Address spec review findings: add Clipper2 dependency, fix BLF point selection priority (Y-first), add Drawing.Id, clarify spacing semantics, add CancellationToken support, specify convex decomposition approach, add error handling and polygon vertex budget sections. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,10 @@ Build an NFP-based mixed-part autonester that:
|
||||
- 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:
|
||||
@@ -40,11 +44,18 @@ The No-Fit Polygon between a stationary polygon A and an orbiting polygon B defi
|
||||
- **On the boundary** → touching
|
||||
- **Outside** → no overlap
|
||||
|
||||
### Computation Method
|
||||
### 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. For concave polygons, decompose into convex pieces first, compute partial NFPs, then take the union.
|
||||
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
|
||||
|
||||
@@ -56,31 +67,34 @@ Drawing.Program
|
||||
→ 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 polygon by half-spacing before NFP computation (consistent with existing `PartBoundary` approach).
|
||||
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.
|
||||
|
||||
### Concave Polygon Handling
|
||||
### Polygon Vertex Budget
|
||||
|
||||
Many real parts are concave. The approach:
|
||||
1. Decompose concave polygon into convex sub-polygons (ear-clipping or Hertel-Mehlhorn)
|
||||
2. Compute NFP for each convex pair combination
|
||||
3. Union the partial NFPs into the final NFP
|
||||
`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.
|
||||
|
||||
**Type:** `NfpCache` — dictionary-based lookup with lazy computation.
|
||||
`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 | Namespace | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `NoFitPolygon` | `OpenNest.Geometry` | Static methods for NFP computation between two polygons |
|
||||
| `InnerFitPolygon` | `OpenNest.Geometry` | Static methods for IFP (part inside plate boundary) |
|
||||
| `NfpCache` | `OpenNest.Engine` | Caches computed NFPs keyed by drawing/rotation pairs |
|
||||
| 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)
|
||||
|
||||
@@ -93,9 +107,11 @@ For a rectangular plate and a convex part, the IFP is a smaller rectangle (plate
|
||||
For placing part P(i) given already-placed parts P(1)...P(i-1):
|
||||
|
||||
```
|
||||
FeasibleRegion = IFP(plate, P(i)) ∩ complement(NFP(P(1), P(i))) ∩ ... ∩ complement(NFP(P(i-1), P(i)))
|
||||
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)
|
||||
@@ -107,19 +123,21 @@ BLF(sequence, plate, nfpCache):
|
||||
placedParts = []
|
||||
for each (drawing, rotation) in sequence:
|
||||
polygon = getPerimeterPolygon(drawing, rotation)
|
||||
ifp = InnerFitPolygon.Compute(plate, polygon)
|
||||
ifp = InnerFitPolygon.Compute(plate.WorkArea, polygon)
|
||||
nfps = [nfpCache.Get(placed, polygon) for placed in placedParts]
|
||||
feasible = ifp minus union(nfps)
|
||||
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)
|
||||
return FillScore.Compute(placedParts, plate.WorkArea)
|
||||
```
|
||||
|
||||
### Bottom-Left Point Selection
|
||||
|
||||
"Bottom-left" means: minimize X first, then minimize Y (leftmost, then lowest). This matches the existing `PackBottomLeft.FindPointVertical` priority but operates on polygon feasible regions instead of discrete candidate points.
|
||||
"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
|
||||
|
||||
@@ -132,11 +150,12 @@ This replaces `PackBottomLeft` for mixed-part scenarios. The existing rectangle-
|
||||
```csharp
|
||||
interface INestOptimizer
|
||||
{
|
||||
NestResult Optimize(List<NestItem> items, Plate plate, NfpCache cache);
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -156,13 +175,13 @@ Each iteration, one mutation is selected randomly:
|
||||
|
||||
- **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
|
||||
- **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()`
|
||||
- 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
|
||||
|
||||
@@ -175,18 +194,26 @@ Generate the initial sequence by sorting parts largest-area-first (matches exist
|
||||
New public entry point:
|
||||
|
||||
```csharp
|
||||
public List<Part> AutoNest(List<NestItem> items, Plate plate)
|
||||
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
|
||||
3. Compute candidate rotations per drawing
|
||||
4. Pre-compute `NfpCache` for all (drawing, rotation) pair combinations
|
||||
5. Run `INestOptimizer.Optimize()` → best sequence
|
||||
6. Final BLF placement with best solution → placed `Part` instances
|
||||
7. Return parts
|
||||
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
|
||||
|
||||
@@ -218,13 +245,19 @@ The `INestOptimizer` interface naturally supports parallelism:
|
||||
- **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). No changes needed.
|
||||
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` → `OpenNest.Core/Geometry/`
|
||||
- `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`
|
||||
|
||||
Reference in New Issue
Block a user