diff --git a/CLAUDE.md b/CLAUDE.md
index 86751db..f6adb06 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,6 +100,8 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
+**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
+
## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
diff --git a/OpenNest.Engine/Fill/FillResultCache.cs b/OpenNest.Engine/Fill/FillResultCache.cs
new file mode 100644
index 0000000..0eb2f35
--- /dev/null
+++ b/OpenNest.Engine/Fill/FillResultCache.cs
@@ -0,0 +1,97 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Fill;
+
+///
+/// Caches fill results by drawing and box dimensions so repeated fills
+/// of the same size don't recompute. Parts are stored normalized to origin
+/// and offset to the actual location on retrieval.
+///
+public static class FillResultCache
+{
+ private static readonly ConcurrentDictionary> _cache = new();
+
+ ///
+ /// Returns a cached fill result for the given drawing and box dimensions,
+ /// offset to the target location. Returns null on cache miss.
+ ///
+ public static List Get(Drawing drawing, Box targetBox, double spacing)
+ {
+ var key = new CacheKey(drawing, targetBox.Width, targetBox.Length, spacing);
+
+ if (!_cache.TryGetValue(key, out var cached) || cached.Count == 0)
+ return null;
+
+ var offset = targetBox.Location;
+ var result = new List(cached.Count);
+
+ foreach (var part in cached)
+ result.Add(part.CloneAtOffset(offset));
+
+ return result;
+ }
+
+ ///
+ /// Stores a fill result normalized to origin (0,0).
+ ///
+ public static void Store(Drawing drawing, Box sourceBox, double spacing, List parts)
+ {
+ if (parts == null || parts.Count == 0)
+ return;
+
+ var key = new CacheKey(drawing, sourceBox.Width, sourceBox.Length, spacing);
+
+ if (_cache.ContainsKey(key))
+ return;
+
+ var offset = new Vector(-sourceBox.X, -sourceBox.Y);
+ var normalized = new List(parts.Count);
+
+ foreach (var part in parts)
+ normalized.Add(part.CloneAtOffset(offset));
+
+ _cache.TryAdd(key, normalized);
+ }
+
+ public static void Clear() => _cache.Clear();
+
+ public static int Count => _cache.Count;
+
+ private readonly struct CacheKey : System.IEquatable
+ {
+ public readonly Drawing Drawing;
+ public readonly double Width;
+ public readonly double Height;
+ public readonly double Spacing;
+
+ public CacheKey(Drawing drawing, double width, double height, double spacing)
+ {
+ Drawing = drawing;
+ Width = System.Math.Round(width, 2);
+ Height = System.Math.Round(height, 2);
+ Spacing = spacing;
+ }
+
+ public bool Equals(CacheKey other) =>
+ ReferenceEquals(Drawing, other.Drawing) &&
+ Width == other.Width && Height == other.Height &&
+ Spacing == other.Spacing;
+
+ public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hash = RuntimeHelpers.GetHashCode(Drawing);
+ hash = hash * 397 ^ Width.GetHashCode();
+ hash = hash * 397 ^ Height.GetHashCode();
+ hash = hash * 397 ^ Spacing.GetHashCode();
+ return hash;
+ }
+ }
+ }
+}
diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs
index f8e515c..a488623 100644
--- a/OpenNest.Engine/Fill/StripeFiller.cs
+++ b/OpenNest.Engine/Fill/StripeFiller.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -18,6 +19,18 @@ public class StripeFiller
private readonly FillContext _context;
private readonly NestDirection _primaryAxis;
+ ///
+ /// When true, only complete stripes are placed — no partial rows/columns.
+ ///
+ public bool CompleteStripesOnly { get; set; }
+
+ ///
+ /// Factory to create the engine used for filling the remnant strip.
+ /// Defaults to NestEngineRegistry.Create (uses the user's selected engine).
+ ///
+ public Func CreateRemnantEngine { get; set; }
+ = NestEngineRegistry.Create;
+
public StripeFiller(FillContext context, NestDirection primaryAxis)
{
_context = context;
@@ -33,10 +46,6 @@ public class StripeFiller
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var drawing = _context.Item.Drawing;
- var perpAxis = _primaryAxis == NestDirection.Horizontal
- ? NestDirection.Vertical
- : NestDirection.Horizontal;
- var sheetSpan = GetDimension(workArea, _primaryAxis);
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
List bestParts = null;
@@ -49,30 +58,40 @@ public class StripeFiller
var candidate = bestFits[i];
var pairParts = candidate.BuildParts(drawing);
- // Try both expand (N wider pairs) and shrink (N+1 narrower pairs)
- var expandResult = ConvergeStripeAngle(
- pairParts, sheetSpan, spacing, _primaryAxis, _context.Token);
- var shrinkResult = ConvergeStripeAngleShrink(
- pairParts, sheetSpan, spacing, _primaryAxis, _context.Token);
-
- // Evaluate both convergence results
- foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
+ // Try both directions for each candidate to find the shortest
+ // perpendicular dimension (more complete stripes → larger remnant)
+ foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
- if (count <= 0)
- continue;
+ var perpAxis = axis == NestDirection.Horizontal
+ ? NestDirection.Vertical : NestDirection.Horizontal;
+ var sheetSpan = GetDimension(workArea, axis);
+ var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
- var result = BuildGrid(pairParts, angle, workArea, spacing, perpAxis, drawing);
- if (result == null || result.Count == 0)
- continue;
+ var expandResult = ConvergeStripeAngle(
+ pairParts, sheetSpan, spacing, axis, _context.Token);
+ var shrinkResult = ConvergeStripeAngleShrink(
+ pairParts, sheetSpan, spacing, axis, _context.Token);
- var score = FillScore.Compute(result, workArea);
- Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i}: angle={Angle.ToDegrees(angle):F1}°, " +
- $"pairsAcross={count}, waste={waste:F2}, grid={result.Count} parts");
-
- if (bestParts == null || score > bestScore)
+ foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
{
- bestParts = result;
- bestScore = score;
+ if (count <= 0)
+ continue;
+
+ var result = BuildGrid(pairParts, angle, workArea, spacing, axis, perpAxis, drawing);
+
+ if (result == null || result.Count == 0)
+ continue;
+
+ var score = FillScore.Compute(result, workArea);
+ Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " +
+ $"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " +
+ $"grid={result.Count} parts");
+
+ if (bestParts == null || score > bestScore)
+ {
+ bestParts = result;
+ bestScore = score;
+ }
}
}
@@ -85,18 +104,21 @@ public class StripeFiller
}
private List BuildGrid(List pairParts, double angle,
- Box workArea, double spacing, NestDirection perpAxis, Drawing drawing)
+ Box workArea, double spacing, NestDirection primaryAxis,
+ NestDirection perpAxis, Drawing drawing)
{
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
- var stripeBox = MakeStripeBox(workArea, perpDim, _primaryAxis);
+ var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
var stripeEngine = new FillLinear(stripeBox, spacing);
- var stripeParts = stripeEngine.Fill(rotatedPattern, _primaryAxis);
+ var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
return null;
- Debug.WriteLine($"[StripeFiller] Stripe: {stripeParts.Count} parts, " +
+ var partsPerStripe = stripeParts.Count;
+
+ Debug.WriteLine($"[StripeFiller] Stripe: {partsPerStripe} parts, " +
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
var stripePattern = new Pattern();
@@ -109,10 +131,26 @@ public class StripeFiller
if (gridParts == null || gridParts.Count == 0)
return null;
+ if (CompleteStripesOnly)
+ {
+ // Keep only complete stripes — discard partial copies
+ var completeCount = gridParts.Count / partsPerStripe * partsPerStripe;
+ if (completeCount < gridParts.Count)
+ {
+ Debug.WriteLine($"[StripeFiller] CompleteOnly: {gridParts.Count} → {completeCount} " +
+ $"(dropped {gridParts.Count - completeCount} partial)");
+ gridParts = gridParts.GetRange(0, completeCount);
+ }
+ }
+
Debug.WriteLine($"[StripeFiller] Grid: {gridParts.Count} parts");
+ if (gridParts.Count == 0)
+ return null;
+
var allParts = new List(gridParts);
- var remnantParts = FillRemnant(gridParts, drawing, angle, workArea, spacing);
+
+ var remnantParts = FillRemnant(gridParts, drawing, angle, primaryAxis, workArea, spacing);
if (remnantParts != null)
{
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
@@ -150,7 +188,7 @@ public class StripeFiller
private List FillRemnant(
List gridParts, Drawing drawing, double angle,
- Box workArea, double spacing)
+ NestDirection primaryAxis, Box workArea, double spacing)
{
var gridBox = gridParts.GetBoundingBox();
var minDim = System.Math.Min(
@@ -159,7 +197,7 @@ public class StripeFiller
Box remnantBox;
- if (_primaryAxis == NestDirection.Horizontal)
+ if (primaryAxis == NestDirection.Horizontal)
{
var remnantY = gridBox.Top + spacing;
var remnantLength = workArea.Top - remnantY;
@@ -176,9 +214,39 @@ public class StripeFiller
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
}
- var engine = new FillLinear(remnantBox, spacing);
- var parts = engine.Fill(drawing, angle, _primaryAxis);
- return parts != null && parts.Count > 0 ? parts : null;
+ Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
+
+ // Check cache first
+ var cached = FillResultCache.Get(drawing, remnantBox, spacing);
+ if (cached != null)
+ {
+ Debug.WriteLine($"[StripeFiller] Remnant CACHE HIT: {cached.Count} parts");
+ return cached;
+ }
+
+ // Exclude Row/Column from remnant fill to prevent recursion
+ FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
+ try
+ {
+ var engine = CreateRemnantEngine(_context.Plate);
+ var item = new NestItem { Drawing = drawing };
+ var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
+
+ Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
+ $"winner={engine.WinnerPhase}");
+
+ if (parts != null && parts.Count > 0)
+ {
+ FillResultCache.Store(drawing, remnantBox, spacing, parts);
+ return parts;
+ }
+
+ return null;
+ }
+ finally
+ {
+ FillStrategyRegistry.SetEnabled(null);
+ }
}
public static double FindAngleForTargetSpan(
@@ -261,23 +329,39 @@ public class StripeFiller
var bestCount = 0;
var tolerance = sheetSpan * 0.001;
+ Debug.WriteLine($"[Converge] Start: orient={Angle.ToDegrees(currentAngle):F1}°, sheetSpan={sheetSpan:F2}, spacing={spacing}");
+
for (var iteration = 0; iteration < MaxConvergenceIterations; iteration++)
{
token.ThrowIfCancellationRequested();
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
var pairSpan = GetDimension(rotated.BoundingBox, axis);
+ var perpDim = axis == NestDirection.Horizontal
+ ? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
if (pairSpan + spacing <= 0)
break;
- var n = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
+ // Use FillLinear to get the ACTUAL part count with geometry-aware spacing
+ var stripeBox = axis == NestDirection.Horizontal
+ ? new Box(0, 0, sheetSpan, perpDim)
+ : new Box(0, 0, perpDim, sheetSpan);
+ var engine = new FillLinear(stripeBox, spacing);
+ var filled = engine.Fill(rotated, axis);
+ var n = filled?.Count ?? 0;
+
if (n <= 0)
break;
- var usedSpan = n * (pairSpan + spacing) - spacing;
+ // Measure actual waste from the placed parts
+ var filledBox = ((IEnumerable)filled).GetBoundingBox();
+ var usedSpan = GetDimension(filledBox, axis);
var remaining = sheetSpan - usedSpan;
+ Debug.WriteLine($"[Converge] iter={iteration}: angle={Angle.ToDegrees(currentAngle):F2}°, " +
+ $"pairSpan={pairSpan:F4}, perpDim={perpDim:F4}, N={n}, waste={remaining:F3}");
+
if (remaining < bestWaste)
{
bestWaste = remaining;
@@ -288,12 +372,27 @@ public class StripeFiller
if (remaining <= tolerance)
break;
- var delta = remaining / n;
+ // Estimate pairs from bounding box to compute delta
+ var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
+ if (bboxN <= 0) bboxN = 1;
+ var delta = remaining / bboxN;
var targetSpan = pairSpan + delta;
+ Debug.WriteLine($"[Converge] delta={delta:F4}, targetSpan={targetSpan:F4}");
+
+ var prevAngle = currentAngle;
currentAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
+
+ Debug.WriteLine($"[Converge] newAngle={Angle.ToDegrees(currentAngle):F2}° (was {Angle.ToDegrees(prevAngle):F2}°)");
+
+ if (System.Math.Abs(currentAngle - prevAngle) < Tolerance.Epsilon)
+ {
+ Debug.WriteLine("[Converge] STUCK — angle unchanged, breaking");
+ break;
+ }
}
+ Debug.WriteLine($"[Converge] Result: angle={Angle.ToDegrees(bestAngle):F2}°, N={bestCount}, waste={bestWaste:F3}");
return (bestAngle, bestWaste, bestCount);
}
@@ -336,7 +435,7 @@ public class StripeFiller
var usedSpan = actualN * (actualSpan + spacing) - spacing;
var waste = sheetSpan - usedSpan;
- // Now converge from this starting point
+ // Now converge from this starting point using geometry-aware fill
var bestWaste = waste;
var bestAngle = angle;
var bestCount = actualN;
@@ -352,13 +451,21 @@ public class StripeFiller
rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
var pairSpan = GetDimension(rotated.BoundingBox, axis);
+ var perpDim = axis == NestDirection.Horizontal
+ ? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
+
+ var stripeBox = axis == NestDirection.Horizontal
+ ? new Box(0, 0, sheetSpan, perpDim)
+ : new Box(0, 0, perpDim, sheetSpan);
+ var engine = new FillLinear(stripeBox, spacing);
+ var filled = engine.Fill(rotated, axis);
+ var n = filled?.Count ?? 0;
- var n = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
if (n <= 0)
break;
- usedSpan = n * (pairSpan + spacing) - spacing;
- var remaining = sheetSpan - usedSpan;
+ var filledBox = ((IEnumerable)filled).GetBoundingBox();
+ var remaining = sheetSpan - GetDimension(filledBox, axis);
if (remaining < bestWaste)
{
@@ -370,7 +477,9 @@ public class StripeFiller
if (remaining <= tolerance)
break;
- var delta = remaining / n;
+ var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
+ if (bboxN <= 0) bboxN = 1;
+ var delta = remaining / bboxN;
var newTarget = pairSpan + delta;
currentAngle = FindAngleForTargetSpan(patternParts, newTarget, axis);
diff --git a/OpenNest.Engine/Strategies/ColumnFillStrategy.cs b/OpenNest.Engine/Strategies/ColumnFillStrategy.cs
index 7dd0224..a4ac975 100644
--- a/OpenNest.Engine/Strategies/ColumnFillStrategy.cs
+++ b/OpenNest.Engine/Strategies/ColumnFillStrategy.cs
@@ -11,7 +11,7 @@ public class ColumnFillStrategy : IFillStrategy
public List Fill(FillContext context)
{
- var filler = new StripeFiller(context, NestDirection.Vertical);
+ var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
return filler.Fill();
}
}
diff --git a/OpenNest.Engine/Strategies/RowFillStrategy.cs b/OpenNest.Engine/Strategies/RowFillStrategy.cs
index 984cbb5..0570e97 100644
--- a/OpenNest.Engine/Strategies/RowFillStrategy.cs
+++ b/OpenNest.Engine/Strategies/RowFillStrategy.cs
@@ -11,7 +11,7 @@ public class RowFillStrategy : IFillStrategy
public List Fill(FillContext context)
{
- var filler = new StripeFiller(context, NestDirection.Horizontal);
+ var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
return filler.Fill();
}
}