From 384d53da476d3bbfe6f86a6e8bfbf6eff31482d7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 12:19:16 -0400 Subject: [PATCH] docs: add remnant finder design spec Extracts remnant detection from the nesting engine into a standalone RemnantFinder class using edge projection algorithm, enabling an iterative nest-area -> get-remnants workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-16-remnant-finder-design.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-remnant-finder-design.md diff --git a/docs/superpowers/specs/2026-03-16-remnant-finder-design.md b/docs/superpowers/specs/2026-03-16-remnant-finder-design.md new file mode 100644 index 0000000..23df6fd --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-remnant-finder-design.md @@ -0,0 +1,137 @@ +# 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 obstacles = null); + + // Mutable obstacle management + public List Obstacles { get; } + public void AddObstacle(Box obstacle); + public void AddObstacles(IEnumerable obstacles); + public void ClearObstacles(); + + // Core method + public List 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. + +## 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.