Files
OpenNest/docs/superpowers/specs/2026-03-10-fill-score-design.md
2026-03-10 23:03:21 -04:00

4.3 KiB
Raw Blame History

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:

// 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