Compare commits

...

16 Commits

Author SHA1 Message Date
560105f952 refactor: extract shared convergence loop and reduce parameter counts in StripeFiller
Extract ConvergeFromAngle to deduplicate ~40 lines shared between
ConvergeStripeAngle and ConvergeStripeAngleShrink. Reduce BuildGrid
from 7 to 4 params and FillRemnant from 6 to 2 by reading context
fields directly. Remove unused angle parameter from FillRemnant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:22:29 -04:00
266f8a83e6 docs: update CLAUDE.md with fill goal engines architecture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:58:35 -04:00
0b7697e9c0 feat: add VerticalRemnantEngine and HorizontalRemnantEngine
Two new engine classes subclassing DefaultNestEngine that override
CreateComparer, PreferredDirection, and BuildAngles to optimize for
preserving side remnants. Both registered in NestEngineRegistry and
covered by 6 integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:57:33 -04:00
83124eb38d feat: wire IFillComparer through PairFiller and StripeFiller
PairFiller now accepts an optional IFillComparer (defaulting to
DefaultFillComparer) and uses it in EvaluateCandidates and
EvaluateCandidate/FillPattern instead of raw FillScore comparisons.
PairsFillStrategy passes context.Policy?.Comparer through.
StripeFiller derives _comparer from FillContext.Policy in its
constructor and uses it in Fill() instead of FillScore comparisons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:53:28 -04:00
24beb8ada1 feat: wire IFillComparer through FillHelpers, Linear, and Extents strategies
- FillHelpers.FillPattern gains optional IFillComparer parameter; falls back to FillScore when null
- LinearFillStrategy.Fill replaced with FillWithDirectionPreference + comparer from context.Policy
- ExtentsFillStrategy.Fill replaced with comparer.IsBetter, removing FillScore comparison
- DefaultNestEngine group-fill path resolves Task 6 TODO, passing Comparer to FillPattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:49:59 -04:00
ee83f17afe feat: wire FillPolicy into DefaultNestEngine and FillContext
- FillContext gains a Policy property (init-only) carrying the IFillComparer
- DefaultNestEngine.Fill sets Policy = BuildPolicy() on every context
- RunPipeline now uses context.Policy.Comparer.IsBetter instead of IsBetterFill
- RunPipeline promoted to protected virtual so subclasses can override
- BuildAngles/RecordProductiveAngles overrides delegate to angleBuilder
- RunPipeline calls virtual BuildAngles/RecordProductiveAngles instead of angleBuilder directly
- TODO comment added in group-fill overload for Task 6 Comparer pass-through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:46:30 -04:00
99546e7eef feat: add IFillComparer hooks to NestEngineBase
Add virtual comparer, direction, and angle-building hooks to NestEngineBase
so subclasses can override scoring and direction policy. Rewire IsBetterFill
to delegate to the comparer instead of calling FillScore directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:43:45 -04:00
4586a53590 feat: add FillPolicy record and FillHelpers.FillWithDirectionPreference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:41:07 -04:00
1a41eeb81d feat: add VerticalRemnantComparer and HorizontalRemnantComparer
Implements two IFillComparer strategies that preserve axis-aligned remnants:
VerticalRemnantComparer minimizes X-extent, HorizontalRemnantComparer minimizes
Y-extent, both using a count > extent > density tiebreak chain. Includes 12
unit tests covering all tiebreak levels and null-guard cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:38:23 -04:00
f894ffd27c feat: add IFillComparer interface and DefaultFillComparer
Extracts the fill result scoring contract into IFillComparer with a DefaultFillComparer implementation that preserves the existing count-then-density lexicographic ranking via FillScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:36:04 -04:00
0ec22f2207 feat: geometry-aware convergence, both-axis search, remnant engine, fill cache
- Convergence loop now uses FillLinear internally to measure actual
  waste with geometry-aware spacing instead of bounding-box arithmetic
- Each candidate pair is tried in both Row and Column orientations to
  find the shortest perpendicular dimension (more complete stripes)
- CompleteStripesOnly flag drops partial stripes; remnant strip is
  filled by a full engine run (injected via CreateRemnantEngine)
- ConvergeStripeAngleShrink tries N+1 narrower pairs as alternative
- FillResultCache avoids redundant engine runs on same-sized remnants
- CLAUDE.md: note to not commit specs/plans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:12:31 -04:00
3f3d95a5e4 fix: orient pair short side along primary axis before convergence
The convergence loop now ensures the pair starts with its short side
parallel to the primary axis, maximizing the number of pairs that fit.
Also adds ConvergeStripeAngleShrink to try N+1 narrower pairs, and
evaluates both expand and shrink results to pick the better grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:22:13 -04:00
811d23510e feat: add RowFillStrategy and ColumnFillStrategy with registry integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:48:00 -04:00
0597a11a23 feat: implement StripeFiller.Fill with pair iteration, stripe tiling, and remnant fill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:44:59 -04:00
2ae1d513cf feat: add StripeFiller.ConvergeStripeAngle iterative convergence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:37:59 -04:00
904d30d05d feat: add StripeFiller.FindAngleForTargetSpan with scan-then-bisect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 07:36:10 -04:00
27 changed files with 1500 additions and 67 deletions

View File

@@ -35,7 +35,8 @@ Domain model, geometry, and CNC primitives organized into namespaces:
### OpenNest.Engine (class library, depends on Core)
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry).
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases)`VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
@@ -100,6 +101,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`.

View File

@@ -26,6 +26,16 @@ namespace OpenNest
set => angleBuilder.ForceFullSweep = value;
}
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
return angleBuilder.Build(item, bestRotation, workArea);
}
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
{
angleBuilder.RecordProductive(angleResults);
}
// --- Public Fill API ---
public override List<Part> Fill(NestItem item, Box workArea,
@@ -42,6 +52,7 @@ namespace OpenNest
PlateNumber = PlateNumber,
Token = token,
Progress = progress,
Policy = BuildPolicy(),
};
RunPipeline(context);
@@ -78,7 +89,7 @@ namespace OpenNest
PhaseResults.Clear();
var engine = new FillLinear(workArea, Plate.PartSpacing);
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea);
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
@@ -105,12 +116,12 @@ namespace OpenNest
// --- RunPipeline: strategy-based orchestration ---
private void RunPipeline(FillContext context)
protected virtual void RunPipeline(FillContext context)
{
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
context.SharedState["BestRotation"] = bestRotation;
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
context.SharedState["AngleCandidates"] = angles;
try
@@ -131,7 +142,7 @@ namespace OpenNest
// during progress reporting.
PhaseResults.Add(phaseResult);
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
{
context.CurrentBest = result;
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
@@ -151,7 +162,7 @@ namespace OpenNest
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
}
angleBuilder.RecordProductive(context.AngleResults);
RecordProductiveAngles(context.AngleResults);
}
}

View File

@@ -0,0 +1,23 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results by count first, then density.
/// This is the original scoring logic used by DefaultNestEngine.
/// </summary>
public class DefaultFillComparer : IFillComparer
{
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
}
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using OpenNest.Geometry;
namespace OpenNest.Engine.Fill;
/// <summary>
/// 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.
/// </summary>
public static class FillResultCache
{
private static readonly ConcurrentDictionary<CacheKey, List<Part>> _cache = new();
/// <summary>
/// Returns a cached fill result for the given drawing and box dimensions,
/// offset to the target location. Returns null on cache miss.
/// </summary>
public static List<Part> 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<Part>(cached.Count);
foreach (var part in cached)
result.Add(part.CloneAtOffset(offset));
return result;
}
/// <summary>
/// Stores a fill result normalized to origin (0,0).
/// </summary>
public static void Store(Drawing drawing, Box sourceBox, double spacing, List<Part> 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<Part>(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<CacheKey>
{
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;
}
}
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results to minimize Y-extent (preserve top-side horizontal remnant).
/// Tiebreak chain: count > smallest Y-extent > highest density.
/// </summary>
public class HorizontalRemnantComparer : IFillComparer
{
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
if (candidate.Count != current.Count)
return candidate.Count > current.Count;
var candExtent = YExtent(candidate);
var currExtent = YExtent(current);
if (!candExtent.IsEqualTo(currExtent))
return candExtent < currExtent;
return FillScore.Compute(candidate, workArea).Density
> FillScore.Compute(current, workArea).Density;
}
private static double YExtent(List<Part> parts)
{
var minY = double.MaxValue;
var maxY = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Bottom < minY) minY = bb.Bottom;
if (bb.Top > maxY) maxY = bb.Top;
}
return maxY - minY;
}
}
}

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine;
namespace OpenNest.Engine.Fill
{
@@ -29,11 +30,13 @@ namespace OpenNest.Engine.Fill
private readonly Size plateSize;
private readonly double partSpacing;
private readonly IFillComparer comparer;
public PairFiller(Size plateSize, double partSpacing)
public PairFiller(Size plateSize, double partSpacing, IFillComparer comparer = null)
{
this.plateSize = plateSize;
this.partSpacing = partSpacing;
this.comparer = comparer ?? new DefaultFillComparer();
}
public PairFillResult Fill(NestItem item, Box workArea,
@@ -61,7 +64,6 @@ namespace OpenNest.Engine.Fill
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
{
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
var effectiveWorkArea = workArea;
@@ -72,12 +74,10 @@ namespace OpenNest.Engine.Fill
token.ThrowIfCancellationRequested();
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
var score = FillScore.Compute(filled, effectiveWorkArea);
if (score > bestScore)
if (comparer.IsBetter(filled, best, effectiveWorkArea))
{
best = filled;
bestScore = score;
sinceImproved = 0;
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
}
@@ -87,7 +87,7 @@ namespace OpenNest.Engine.Fill
}
NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts");
if (i + 1 >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
{
@@ -101,7 +101,7 @@ namespace OpenNest.Engine.Fill
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
}
Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts");
return best ?? new List<Part>();
}
@@ -147,7 +147,7 @@ namespace OpenNest.Engine.Fill
var pairParts = candidate.BuildParts(drawing);
var engine = new FillLinear(workArea, partSpacing);
var angles = BuildTilingAngles(candidate);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
return FillHelpers.FillPattern(engine, pairParts, angles, workArea, comparer);
}
private static List<double> BuildTilingAngles(BestFitResult candidate)

View File

@@ -0,0 +1,462 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Diagnostics;
namespace OpenNest.Engine.Fill;
public class StripeFiller
{
private const int MaxPairCandidates = 5;
private const int MaxConvergenceIterations = 20;
private const int AngleSamples = 36;
private readonly FillContext _context;
private readonly NestDirection _primaryAxis;
private readonly IFillComparer _comparer;
/// <summary>
/// When true, only complete stripes are placed — no partial rows/columns.
/// </summary>
public bool CompleteStripesOnly { get; set; }
/// <summary>
/// Factory to create the engine used for filling the remnant strip.
/// Defaults to NestEngineRegistry.Create (uses the user's selected engine).
/// </summary>
public Func<Plate, NestEngineBase> CreateRemnantEngine { get; set; }
= NestEngineRegistry.Create;
public StripeFiller(FillContext context, NestDirection primaryAxis)
{
_context = context;
_primaryAxis = primaryAxis;
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
}
public List<Part> Fill()
{
var bestFits = GetPairCandidates();
if (bestFits.Count == 0)
return new List<Part>();
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var drawing = _context.Item.Drawing;
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
List<Part> bestParts = null;
for (var i = 0; i < bestFits.Count; i++)
{
_context.Token.ThrowIfCancellationRequested();
var candidate = bestFits[i];
var pairParts = candidate.BuildParts(drawing);
foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical })
{
var perpAxis = axis == NestDirection.Horizontal
? NestDirection.Vertical : NestDirection.Horizontal;
var sheetSpan = GetDimension(workArea, axis);
var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
var expandResult = ConvergeStripeAngle(
pairParts, sheetSpan, spacing, axis, _context.Token);
var shrinkResult = ConvergeStripeAngleShrink(
pairParts, sheetSpan, spacing, axis, _context.Token);
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
{
if (count <= 0)
continue;
var result = BuildGrid(pairParts, angle, axis, perpAxis);
if (result == null || result.Count == 0)
continue;
Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " +
$"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " +
$"grid={result.Count} parts");
if (_comparer.IsBetter(result, bestParts, workArea))
{
bestParts = result;
}
}
}
NestEngineBase.ReportProgress(_context.Progress, NestPhase.Custom,
_context.PlateNumber, bestParts, workArea,
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts");
}
return bestParts ?? new List<Part>();
}
private List<Part> BuildGrid(List<Part> pairParts, double angle,
NestDirection primaryAxis, NestDirection perpAxis)
{
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
var stripeEngine = new FillLinear(stripeBox, spacing);
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
return null;
var partsPerStripe = stripeParts.Count;
Debug.WriteLine($"[StripeFiller] Stripe: {partsPerStripe} parts, " +
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
var stripePattern = new Pattern();
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing);
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
return null;
if (CompleteStripesOnly)
{
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<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null)
{
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
allParts.AddRange(remnantParts);
}
return allParts;
}
private List<BestFitResult> GetPairCandidates()
{
List<BestFitResult> bestFits;
if (_context.SharedState.TryGetValue("BestFits", out var cached))
bestFits = (List<BestFitResult>)cached;
else
bestFits = BestFitCache.GetOrCompute(
_context.Item.Drawing,
_context.Plate.Size.Length,
_context.Plate.Size.Width,
_context.Plate.PartSpacing);
return bestFits
.Where(r => r.Keep)
.Take(MaxPairCandidates)
.ToList();
}
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
{
return primaryAxis == NestDirection.Horizontal
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
}
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
{
var workArea = _context.WorkArea;
var spacing = _context.Plate.PartSpacing;
var drawing = _context.Item.Drawing;
var gridBox = gridParts.GetBoundingBox();
var minDim = System.Math.Min(
drawing.Program.BoundingBox().Width,
drawing.Program.BoundingBox().Length);
Box remnantBox;
if (primaryAxis == NestDirection.Horizontal)
{
var remnantY = gridBox.Top + spacing;
var remnantLength = workArea.Top - remnantY;
if (remnantLength < minDim)
return null;
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
}
else
{
var remnantX = gridBox.Right + spacing;
var remnantWidth = workArea.Right - remnantX;
if (remnantWidth < minDim)
return null;
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
}
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
var cachedResult = FillResultCache.Get(drawing, remnantBox, spacing);
if (cachedResult != null)
{
Debug.WriteLine($"[StripeFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
return cachedResult;
}
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(
List<Part> patternParts, double targetSpan, NestDirection axis)
{
var bestAngle = 0.0;
var bestDiff = double.MaxValue;
var samples = new (double angle, double span)[AngleSamples + 1];
for (var i = 0; i <= AngleSamples; i++)
{
var angle = i * Angle.HalfPI / AngleSamples;
var span = GetRotatedSpan(patternParts, angle, axis);
samples[i] = (angle, span);
var diff = System.Math.Abs(span - targetSpan);
if (diff < bestDiff)
{
bestDiff = diff;
bestAngle = angle;
}
}
if (bestDiff < Tolerance.Epsilon)
return bestAngle;
for (var i = 0; i < samples.Length - 1; i++)
{
var (a1, s1) = samples[i];
var (a2, s2) = samples[i + 1];
if ((s1 <= targetSpan && targetSpan <= s2) ||
(s2 <= targetSpan && targetSpan <= s1))
{
var result = BisectForTarget(patternParts, a1, a2, targetSpan, axis);
var resultSpan = GetRotatedSpan(patternParts, result, axis);
var resultDiff = System.Math.Abs(resultSpan - targetSpan);
if (resultDiff < bestDiff)
{
bestDiff = resultDiff;
bestAngle = result;
}
}
}
return bestAngle;
}
/// <summary>
/// Returns the rotation angle that orients the pair with its short side
/// along the given axis. Returns 0 if already oriented, PI/2 if rotated.
/// </summary>
private static double OrientShortSideAlong(List<Part> patternParts, NestDirection axis)
{
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
var span0 = GetDimension(box, axis);
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
if (span0 <= perpSpan0)
return 0;
return Angle.HalfPI;
}
/// <summary>
/// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste by expanding pair width.
/// Returns (angle, waste, pairCount).
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
var startAngle = OrientShortSideAlong(patternParts, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
}
/// <summary>
/// Tries fitting N+1 narrower pairs by shrinking the pair width.
/// Complements ConvergeStripeAngle which only expands.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngleShrink(
List<Part> patternParts, double sheetSpan, double spacing,
NestDirection axis, CancellationToken token = default)
{
var baseAngle = OrientShortSideAlong(patternParts, axis);
var naturalPattern = FillHelpers.BuildRotatedPattern(patternParts, baseAngle);
var naturalSpan = GetDimension(naturalPattern.BoundingBox, axis);
if (naturalSpan + spacing <= 0)
return (0, double.MaxValue, 0);
var naturalN = (int)System.Math.Floor((sheetSpan + spacing) / (naturalSpan + spacing));
var targetN = naturalN + 1;
var targetSpan = (sheetSpan + spacing) / targetN - spacing;
if (targetSpan <= 0)
return (0, double.MaxValue, 0);
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
}
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
List<Part> patternParts, double startAngle, double sheetSpan,
double spacing, NestDirection axis, CancellationToken token)
{
var bestWaste = double.MaxValue;
var bestAngle = startAngle;
var bestCount = 0;
var tolerance = sheetSpan * 0.001;
var currentAngle = startAngle;
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 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 filledBox = ((IEnumerable<IBoundable>)filled).GetBoundingBox();
var remaining = sheetSpan - GetDimension(filledBox, axis);
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;
bestAngle = currentAngle;
bestCount = n;
}
if (remaining <= tolerance)
break;
var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
if (bboxN <= 0) bboxN = 1;
var delta = remaining / bboxN;
var targetSpan = pairSpan + delta;
var prevAngle = currentAngle;
currentAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
if (System.Math.Abs(currentAngle - prevAngle) < Tolerance.Epsilon)
break;
}
return (bestAngle, bestWaste, bestCount);
}
private static double BisectForTarget(
List<Part> patternParts, double lo, double hi,
double targetSpan, NestDirection axis)
{
var bestAngle = lo;
var bestDiff = double.MaxValue;
for (var i = 0; i < 30; i++)
{
var mid = (lo + hi) / 2;
var span = GetRotatedSpan(patternParts, mid, axis);
var diff = System.Math.Abs(span - targetSpan);
if (diff < bestDiff)
{
bestDiff = diff;
bestAngle = mid;
}
if (diff < Tolerance.Epsilon)
break;
var loSpan = GetRotatedSpan(patternParts, lo, axis);
if ((loSpan < targetSpan && span < targetSpan) ||
(loSpan > targetSpan && span > targetSpan))
lo = mid;
else
hi = mid;
}
return bestAngle;
}
private static double GetRotatedSpan(
List<Part> patternParts, double angle, NestDirection axis)
{
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
return axis == NestDirection.Horizontal
? rotated.BoundingBox.Width
: rotated.BoundingBox.Length;
}
private static double GetDimension(Box box, NestDirection axis)
{
return axis == NestDirection.Horizontal ? box.Width : box.Length;
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
/// <summary>
/// Ranks fill results to minimize X-extent (preserve right-side vertical remnant).
/// Tiebreak chain: count > smallest X-extent > highest density.
/// </summary>
public class VerticalRemnantComparer : IFillComparer
{
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
if (candidate.Count != current.Count)
return candidate.Count > current.Count;
var candExtent = XExtent(candidate);
var currExtent = XExtent(current);
if (!candExtent.IsEqualTo(currExtent))
return candExtent < currExtent;
return FillScore.Compute(candidate, workArea).Density
> FillScore.Compute(current, workArea).Density;
}
private static double XExtent(List<Part> parts)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Left < minX) minX = bb.Left;
if (bb.Right > maxX) maxX = bb.Right;
}
return maxX - minX;
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Optimizes for the largest top-side horizontal drop.
/// Scores by count first, then minimizes Y-extent.
/// Prefers vertical nest direction and angles that keep parts narrow in Y.
/// </summary>
public class HorizontalRemnantEngine : DefaultNestEngine
{
public HorizontalRemnantEngine(Plate plate) : base(plate) { }
public override string Name => "Horizontal Remnant";
public override string Description => "Optimizes for largest top-side horizontal drop";
protected override IFillComparer CreateComparer() => new HorizontalRemnantComparer();
public override NestDirection? PreferredDirection => NestDirection.Vertical;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
return baseAngles;
}
private static double RotatedHeight(NestItem item, double angle)
{
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Length * cos + bb.Width * sin;
}
}
}

View File

@@ -0,0 +1,14 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Determines whether a candidate fill result is better than the current best.
/// Implementations must be stateless and thread-safe.
/// </summary>
public interface IFillComparer
{
bool IsBetter(List<Part> candidate, List<Part> current, Box workArea);
}
}

View File

@@ -1,4 +1,6 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -31,6 +33,25 @@ namespace OpenNest
public abstract string Description { get; }
// --- Engine policy ---
private IFillComparer _comparer;
protected IFillComparer Comparer => _comparer ??= CreateComparer();
protected virtual IFillComparer CreateComparer() => new DefaultFillComparer();
public virtual NestDirection? PreferredDirection => null;
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
}
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
@@ -255,15 +276,7 @@ namespace OpenNest
}
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
=> Comparer.IsBetter(candidate, current, workArea);
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{

View File

@@ -24,6 +24,14 @@ namespace OpenNest
Register("NFP",
"NFP-based mixed-part nesting with simulated annealing",
plate => new NfpNestEngine(plate));
Register("Vertical Remnant",
"Optimizes for largest right-side vertical drop",
plate => new VerticalRemnantEngine(plate));
Register("Horizontal Remnant",
"Optimizes for largest top-side horizontal drop",
plate => new HorizontalRemnantEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Engine.Fill;
namespace OpenNest.Engine.Strategies;
public class ColumnFillStrategy : IFillStrategy
{
public string Name => "Column";
public NestPhase Phase => NestPhase.Custom;
public int Order => 160;
public List<Part> Fill(FillContext context)
{
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
return filler.Fill();
}
}

View File

@@ -21,7 +21,7 @@ namespace OpenNest.Engine.Strategies
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
List<Part> best = null;
var bestScore = default(FillScore);
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
foreach (var angle in angles)
{
@@ -30,12 +30,8 @@ namespace OpenNest.Engine.Strategies
context.PlateNumber, context.Token, context.Progress);
if (result != null && result.Count > 0)
{
var score = FillScore.Compute(result, context.WorkArea);
if (best == null || score > bestScore)
{
if (best == null || comparer.IsBetter(result, best, context.WorkArea))
best = result;
bestScore = score;
}
}
}

View File

@@ -14,8 +14,10 @@ namespace OpenNest.Engine.Strategies
public int PlateNumber { get; init; }
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; }
public List<Part> CurrentBest { get; set; }
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
public FillScore CurrentBestScore { get; set; }
public NestPhase WinnerPhase { get; set; }
public List<PhaseResult> PhaseResults { get; } = new();

View File

@@ -1,6 +1,7 @@
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -29,7 +30,7 @@ namespace OpenNest.Engine.Strategies
return pattern;
}
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea, IFillComparer comparer = null)
{
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
@@ -54,14 +55,59 @@ namespace OpenNest.Engine.Strategies
foreach (var res in results)
{
if (best == null || res.Score > bestScore)
if (comparer != null)
{
best = res.Parts;
bestScore = res.Score;
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
best = res.Parts;
}
else
{
if (best == null || res.Score > bestScore)
{
best = res.Parts;
bestScore = res.Score;
}
}
}
return best;
}
/// <summary>
/// Runs a fill function with direction preference logic.
/// If preferred is null, tries both directions and returns the better result.
/// If preferred is set, tries preferred first; only tries other if preferred yields zero.
/// </summary>
public static List<Part> FillWithDirectionPreference(
Func<NestDirection, List<Part>> fillFunc,
NestDirection? preferred,
IFillComparer comparer,
Box workArea)
{
if (preferred == null)
{
var h = fillFunc(NestDirection.Horizontal);
var v = fillFunc(NestDirection.Vertical);
if ((h == null || h.Count == 0) && (v == null || v.Count == 0))
return new List<Part>();
if (h == null || h.Count == 0) return v;
if (v == null || v.Count == 0) return h;
return comparer.IsBetter(h, v, workArea) ? h : v;
}
var other = preferred == NestDirection.Horizontal
? NestDirection.Vertical
: NestDirection.Horizontal;
var pref = fillFunc(preferred.Value);
if (pref != null && pref.Count > 0)
return pref;
var fallback = fillFunc(other);
return fallback ?? new List<Part>();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace OpenNest.Engine.Strategies
{
/// <summary>
/// Groups engine scoring and direction policy into a single object.
/// Set by the engine, consumed by strategies via FillContext.Policy.
/// </summary>
public record FillPolicy(IFillComparer Comparer, NestDirection? PreferredDirection = null);
}

View File

@@ -17,8 +17,9 @@ namespace OpenNest.Engine.Strategies
: new List<double> { 0, Angle.HalfPI };
var workArea = context.WorkArea;
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
var preferred = context.Policy?.PreferredDirection;
List<Part> best = null;
var bestScore = default(FillScore);
for (var ai = 0; ai < angles.Count; ai++)
{
@@ -26,48 +27,29 @@ namespace OpenNest.Engine.Strategies
var angle = angles[ai];
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
var result = FillHelpers.FillWithDirectionPreference(
dir => engine.Fill(context.Item.Drawing, angle, dir),
preferred, comparer, workArea);
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
if (result != null && result.Count > 0)
{
var scoreH = FillScore.Compute(h, workArea);
context.AngleResults.Add(new AngleResult
{
AngleDeg = angleDeg,
Direction = NestDirection.Horizontal,
PartCount = h.Count
Direction = preferred ?? NestDirection.Horizontal,
PartCount = result.Count
});
if (best == null || scoreH > bestScore)
{
best = h;
bestScore = scoreH;
}
}
if (v != null && v.Count > 0)
{
var scoreV = FillScore.Compute(v, workArea);
context.AngleResults.Add(new AngleResult
{
AngleDeg = angleDeg,
Direction = NestDirection.Vertical,
PartCount = v.Count
});
if (best == null || scoreV > bestScore)
{
best = v;
bestScore = scoreV;
}
if (best == null || comparer.IsBetter(result, best, workArea))
best = result;
}
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
context.PlateNumber, best, workArea,
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts");
}
return best ?? new List<Part>();

View File

@@ -11,7 +11,8 @@ namespace OpenNest.Engine.Strategies
public List<Part> Fill(FillContext context)
{
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
var comparer = context.Policy?.Comparer;
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing, comparer);
var result = filler.Fill(context.Item, context.WorkArea,
context.PlateNumber, context.Token, context.Progress);

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Engine.Fill;
namespace OpenNest.Engine.Strategies;
public class RowFillStrategy : IFillStrategy
{
public string Name => "Row";
public NestPhase Phase => NestPhase.Custom;
public int Order => 150;
public List<Part> Fill(FillContext context)
{
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
return filler.Fill();
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Optimizes for the largest right-side vertical drop.
/// Scores by count first, then minimizes X-extent.
/// Prefers horizontal nest direction and angles that keep parts narrow in X.
/// </summary>
public class VerticalRemnantEngine : DefaultNestEngine
{
public VerticalRemnantEngine(Plate plate) : base(plate) { }
public override string Name => "Vertical Remnant";
public override string Description => "Optimizes for largest right-side vertical drop";
protected override IFillComparer CreateComparer() => new VerticalRemnantComparer();
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
{
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
return baseAngles;
}
private static double RotatedWidth(NestItem item, double angle)
{
var bb = item.Drawing.Program.BoundingBox();
var cos = System.Math.Abs(System.Math.Cos(angle));
var sin = System.Math.Abs(System.Math.Sin(angle));
return bb.Width * cos + bb.Length * sin;
}
}
}

View File

@@ -0,0 +1,173 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class DefaultFillComparerTests
{
private readonly IFillComparer comparer = new DefaultFillComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void NullCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(null, current, workArea));
}
[Fact]
public void EmptyCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(new List<Part>(), current, workArea));
}
[Fact]
public void NullCurrent_ReturnsTrue()
{
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.True(comparer.IsBetter(candidate, null, workArea));
}
[Fact]
public void HigherCount_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(20, 0, 10),
TestHelpers.MakePartAt(40, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(20, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_HigherDensityWins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(50, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
}
public class VerticalRemnantComparerTests
{
private readonly IFillComparer comparer = new VerticalRemnantComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void HigherCount_WinsRegardlessOfExtent()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 0, 10),
TestHelpers.MakePartAt(80, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_SmallerXExtent_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(12, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(50, 0, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void SameCount_SameExtent_HigherDensityWins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 0, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(40, 40, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void NullCandidate_ReturnsFalse()
{
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.False(comparer.IsBetter(null, current, workArea));
}
[Fact]
public void NullCurrent_ReturnsTrue()
{
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
Assert.True(comparer.IsBetter(candidate, null, workArea));
}
}
public class HorizontalRemnantComparerTests
{
private readonly IFillComparer comparer = new HorizontalRemnantComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void SameCount_SmallerYExtent_Wins()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 12, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 50, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
[Fact]
public void HigherCount_WinsRegardlessOfExtent()
{
var candidate = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 40, 10),
TestHelpers.MakePartAt(0, 80, 10)
};
var current = new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(0, 12, 10)
};
Assert.True(comparer.IsBetter(candidate, current, workArea));
}
}

View File

@@ -0,0 +1,50 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class FillWithDirectionPreferenceTests
{
private readonly IFillComparer comparer = new DefaultFillComparer();
private readonly Box workArea = new(0, 0, 100, 100);
[Fact]
public void NullPreference_TriesBothDirections_ReturnsBetter()
{
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? hParts : vParts,
null, comparer, workArea);
Assert.Equal(2, result.Count);
}
[Fact]
public void PreferredDirection_UsedFirst_WhenProducesResults()
{
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(0, 12, 10), TestHelpers.MakePartAt(0, 24, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? hParts : vParts,
NestDirection.Horizontal, comparer, workArea);
Assert.Equal(2, result.Count); // H has results, so H is returned (preferred)
}
[Fact]
public void PreferredDirection_FallsBack_WhenPreferredReturnsEmpty()
{
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
var result = FillHelpers.FillWithDirectionPreference(
dir => dir == NestDirection.Horizontal ? new List<Part>() : vParts,
NestDirection.Horizontal, comparer, workArea);
Assert.Equal(1, result.Count); // Falls back to V
}
}

View File

@@ -0,0 +1,92 @@
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class RemnantEngineTests
{
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
[Fact]
public void VerticalRemnantEngine_UsesVerticalRemnantComparer()
{
var plate = new Plate(60, 120);
var engine = new VerticalRemnantEngine(plate);
Assert.Equal("Vertical Remnant", engine.Name);
Assert.Equal(NestDirection.Horizontal, engine.PreferredDirection);
}
[Fact]
public void HorizontalRemnantEngine_UsesHorizontalRemnantComparer()
{
var plate = new Plate(60, 120);
var engine = new HorizontalRemnantEngine(plate);
Assert.Equal("Horizontal Remnant", engine.Name);
Assert.Equal(NestDirection.Vertical, engine.PreferredDirection);
}
[Fact]
public void VerticalRemnantEngine_Fill_ProducesResults()
{
var plate = new Plate(60, 120);
var engine = new VerticalRemnantEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(parts.Count > 0, "VerticalRemnantEngine should fill parts");
}
[Fact]
public void HorizontalRemnantEngine_Fill_ProducesResults()
{
var plate = new Plate(60, 120);
var engine = new HorizontalRemnantEngine(plate);
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(parts.Count > 0, "HorizontalRemnantEngine should fill parts");
}
[Fact]
public void Registry_ContainsBothRemnantEngines()
{
var names = NestEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
Assert.Contains("Vertical Remnant", names);
Assert.Contains("Horizontal Remnant", names);
}
[Fact]
public void VerticalRemnantEngine_ProducesTighterXExtent_ThanDefault()
{
var plate = new Plate(60, 120);
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var defaultEngine = new DefaultNestEngine(plate);
var remnantEngine = new VerticalRemnantEngine(plate);
var defaultParts = defaultEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
var remnantParts = remnantEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(defaultParts.Count > 0);
Assert.True(remnantParts.Count > 0);
var defaultXExtent = defaultParts.Max(p => p.BoundingBox.Right) - defaultParts.Min(p => p.BoundingBox.Left);
var remnantXExtent = remnantParts.Max(p => p.BoundingBox.Right) - remnantParts.Min(p => p.BoundingBox.Left);
Assert.True(remnantXExtent <= defaultXExtent + 0.01,
$"Remnant X-extent ({remnantXExtent:F1}) should be <= default ({defaultXExtent:F1})");
}
}

View File

@@ -24,7 +24,7 @@ public class FillPipelineTests
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.True(engine.PhaseResults.Count >= 4,
Assert.True(engine.PhaseResults.Count >= 6,
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
}
@@ -41,7 +41,8 @@ public class FillPipelineTests
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
engine.WinnerPhase == NestPhase.Linear ||
engine.WinnerPhase == NestPhase.RectBestFit ||
engine.WinnerPhase == NestPhase.Extents);
engine.WinnerPhase == NestPhase.Extents ||
engine.WinnerPhase == NestPhase.Custom);
}
[Fact]

View File

@@ -1,3 +1,4 @@
using System.Linq;
using OpenNest.Engine.Strategies;
namespace OpenNest.Tests.Strategies;
@@ -9,11 +10,13 @@ public class FillStrategyRegistryTests
{
var strategies = FillStrategyRegistry.Strategies;
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
Assert.True(strategies.Count >= 6, $"Expected at least 6 built-in strategies, got {strategies.Count}");
Assert.Contains(strategies, s => s.Name == "Pairs");
Assert.Contains(strategies, s => s.Name == "RectBestFit");
Assert.Contains(strategies, s => s.Name == "Extents");
Assert.Contains(strategies, s => s.Name == "Linear");
Assert.Contains(strategies, s => s.Name == "Row");
Assert.Contains(strategies, s => s.Name == "Column");
}
[Fact]
@@ -34,4 +37,19 @@ public class FillStrategyRegistryTests
Assert.Equal("Linear", last.Name);
}
[Fact]
public void Registry_RowAndColumnOrderedBetweenPairsAndRectBestFit()
{
var strategies = FillStrategyRegistry.Strategies;
var pairsOrder = strategies.First(s => s.Name == "Pairs").Order;
var rectOrder = strategies.First(s => s.Name == "RectBestFit").Order;
var rowOrder = strategies.First(s => s.Name == "Row").Order;
var colOrder = strategies.First(s => s.Name == "Column").Order;
Assert.True(rowOrder > pairsOrder, "Row should run after Pairs");
Assert.True(colOrder > pairsOrder, "Column should run after Pairs");
Assert.True(rowOrder < rectOrder, "Row should run before RectBestFit");
Assert.True(colOrder < rectOrder, "Column should run before RectBestFit");
}
}

View File

@@ -0,0 +1,217 @@
using System.Collections.Generic;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
namespace OpenNest.Tests.Strategies;
public class StripeFillerTests
{
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing(name, pgm);
}
private static Pattern MakeRectPattern(double w, double h)
{
var drawing = MakeRectDrawing(w, h);
var part = Part.CreateAtOrigin(drawing);
var pattern = new Pattern();
pattern.Parts.Add(part);
pattern.UpdateBounds();
return pattern;
}
/// <summary>
/// Builds a simple side-by-side pair BestFitResult for a rectangular drawing.
/// Places two copies next to each other along the X axis with the given spacing.
/// </summary>
private static List<BestFitResult> MakeSideBySideBestFits(
Drawing drawing, double spacing)
{
var bb = drawing.Program.BoundingBox();
var w = bb.Width;
var h = bb.Length;
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = 0,
Part2Offset = new Vector(w + spacing, 0),
Spacing = spacing,
};
var pairWidth = 2 * w + spacing;
var result = new BestFitResult
{
Candidate = candidate,
BoundingWidth = pairWidth,
BoundingHeight = h,
RotatedArea = pairWidth * h,
TrueArea = 2 * w * h,
OptimalRotation = 0,
Keep = true,
Reason = "Valid",
HullAngles = new List<double>(),
};
return new List<BestFitResult> { result };
}
[Fact]
public void FindAngleForTargetSpan_ZeroAngle_WhenAlreadyMatches()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 20.0, NestDirection.Horizontal);
Assert.True(System.Math.Abs(angle) < 0.05,
$"Expected angle near 0, got {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
}
[Fact]
public void FindAngleForTargetSpan_FindsLargerSpan()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 22.0, NestDirection.Horizontal);
var rotated = FillHelpers.BuildRotatedPattern(pattern.Parts, angle);
var span = rotated.BoundingBox.Width;
Assert.True(System.Math.Abs(span - 22.0) < 0.5,
$"Expected span ~22, got {span:F2} at {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
}
[Fact]
public void FindAngleForTargetSpan_ReturnsClosest_WhenUnreachable()
{
var pattern = MakeRectPattern(20, 10);
var angle = StripeFiller.FindAngleForTargetSpan(
pattern.Parts, 30.0, NestDirection.Horizontal);
Assert.True(angle >= 0 && angle <= System.Math.PI / 2);
}
[Fact]
public void ConvergeStripeAngle_ReducesWaste()
{
var pattern = MakeRectPattern(20, 10);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 120.0, 0.5, NestDirection.Horizontal);
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
Assert.True(waste < 18.0, $"Expected waste < 18, got {waste:F2}");
}
[Fact]
public void ConvergeStripeAngle_HandlesExactFit()
{
// 10x5 pattern: short side (5) oriented along axis, so more pairs fit
var pattern = MakeRectPattern(10, 5);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 100.0, 0.0, NestDirection.Horizontal);
Assert.True(count >= 10, $"Expected at least 10 pairs, got {count}");
Assert.True(waste < 1.0, $"Expected low waste, got {waste:F2}");
}
[Fact]
public void ConvergeStripeAngle_Vertical()
{
var pattern = MakeRectPattern(10, 20);
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
pattern.Parts, 120.0, 0.5, NestDirection.Vertical);
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
}
[Fact]
public void Fill_ProducesPartsForSimpleDrawing()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = bestFits;
var filler = new StripeFiller(context, NestDirection.Horizontal);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
}
[Fact]
public void Fill_VerticalProducesParts()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = bestFits;
var filler = new StripeFiller(context, NestDirection.Vertical);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from column fill");
}
[Fact]
public void Fill_ReturnsEmpty_WhenNoBestFits()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var item = new NestItem { Drawing = drawing };
var workArea = new Box(0, 0, 120, 60);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = item,
WorkArea = workArea,
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Progress = null,
};
context.SharedState["BestFits"] = new List<OpenNest.Engine.BestFit.BestFitResult>();
var filler = new StripeFiller(context, NestDirection.Horizontal);
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.Empty(parts);
}
}