diff --git a/docs/superpowers/specs/2026-03-11-nfp-autonest-design.md b/docs/superpowers/specs/2026-03-11-nfp-autonest-design.md index 4f7bc10..7e156cb 100644 --- a/docs/superpowers/specs/2026-03-11-nfp-autonest-design.md +++ b/docs/superpowers/specs/2026-03-11-nfp-autonest-design.md @@ -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 items, Plate plate, NfpCache cache); + NestResult Optimize(List 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`) - 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 AutoNest(List items, Plate plate) +public List AutoNest(List 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, 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`