Files
OpenNest/docs/superpowers/specs/2026-03-16-remnant-finder-design.md
AJ Isaacs 5873bff48b docs: add remnant finder implementation plan
16-task plan covering RemnantFinder class, FillScore simplification,
remainder phase removal, caller updates, and PlateView ActiveWorkArea
visualization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:38:44 -04:00

6.9 KiB

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

RemnantFinderOpenNest.Engine

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:

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.