Compare commits
16 Commits
e9678c73b2
...
560105f952
| Author | SHA1 | Date | |
|---|---|---|---|
| 560105f952 | |||
| 266f8a83e6 | |||
| 0b7697e9c0 | |||
| 83124eb38d | |||
| 24beb8ada1 | |||
| ee83f17afe | |||
| 99546e7eef | |||
| 4586a53590 | |||
| 1a41eeb81d | |||
| f894ffd27c | |||
| 0ec22f2207 | |||
| 3f3d95a5e4 | |||
| 811d23510e | |||
| 0597a11a23 | |||
| 2ae1d513cf | |||
| 904d30d05d |
@@ -35,7 +35,8 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
|||||||
### OpenNest.Engine (class library, depends on Core)
|
### 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).
|
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.
|
- **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`.
|
- **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`.
|
- **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.
|
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
|
## Key Patterns
|
||||||
|
|
||||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ namespace OpenNest
|
|||||||
set => angleBuilder.ForceFullSweep = value;
|
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 Fill API ---
|
||||||
|
|
||||||
public override List<Part> Fill(NestItem item, Box workArea,
|
public override List<Part> Fill(NestItem item, Box workArea,
|
||||||
@@ -42,6 +52,7 @@ namespace OpenNest
|
|||||||
PlateNumber = PlateNumber,
|
PlateNumber = PlateNumber,
|
||||||
Token = token,
|
Token = token,
|
||||||
Progress = progress,
|
Progress = progress,
|
||||||
|
Policy = BuildPolicy(),
|
||||||
};
|
};
|
||||||
|
|
||||||
RunPipeline(context);
|
RunPipeline(context);
|
||||||
@@ -78,7 +89,7 @@ namespace OpenNest
|
|||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||||
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
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));
|
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}");
|
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 ---
|
// --- RunPipeline: strategy-based orchestration ---
|
||||||
|
|
||||||
private void RunPipeline(FillContext context)
|
protected virtual void RunPipeline(FillContext context)
|
||||||
{
|
{
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
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;
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -131,7 +142,7 @@ namespace OpenNest
|
|||||||
// during progress reporting.
|
// during progress reporting.
|
||||||
PhaseResults.Add(phaseResult);
|
PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
|
||||||
{
|
{
|
||||||
context.CurrentBest = result;
|
context.CurrentBest = result;
|
||||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||||
@@ -151,7 +162,7 @@ namespace OpenNest
|
|||||||
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||||
}
|
}
|
||||||
|
|
||||||
angleBuilder.RecordProductive(context.AngleResults);
|
RecordProductiveAngles(context.AngleResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
23
OpenNest.Engine/Fill/DefaultFillComparer.cs
Normal file
23
OpenNest.Engine/Fill/DefaultFillComparer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
OpenNest.Engine/Fill/FillResultCache.cs
Normal file
97
OpenNest.Engine/Fill/FillResultCache.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
OpenNest.Engine/Fill/HorizontalRemnantComparer.cs
Normal file
49
OpenNest.Engine/Fill/HorizontalRemnantComparer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
@@ -29,11 +30,13 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
private readonly Size plateSize;
|
private readonly Size plateSize;
|
||||||
private readonly double partSpacing;
|
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.plateSize = plateSize;
|
||||||
this.partSpacing = partSpacing;
|
this.partSpacing = partSpacing;
|
||||||
|
this.comparer = comparer ?? new DefaultFillComparer();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PairFillResult Fill(NestItem item, Box workArea,
|
public PairFillResult Fill(NestItem item, Box workArea,
|
||||||
@@ -61,7 +64,6 @@ namespace OpenNest.Engine.Fill
|
|||||||
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
|
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
|
||||||
{
|
{
|
||||||
List<Part> best = null;
|
List<Part> best = null;
|
||||||
var bestScore = default(FillScore);
|
|
||||||
var sinceImproved = 0;
|
var sinceImproved = 0;
|
||||||
var effectiveWorkArea = workArea;
|
var effectiveWorkArea = workArea;
|
||||||
|
|
||||||
@@ -72,12 +74,10 @@ namespace OpenNest.Engine.Fill
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
|
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
|
||||||
var score = FillScore.Compute(filled, effectiveWorkArea);
|
|
||||||
|
|
||||||
if (score > bestScore)
|
if (comparer.IsBetter(filled, best, effectiveWorkArea))
|
||||||
{
|
{
|
||||||
best = filled;
|
best = filled;
|
||||||
bestScore = score;
|
|
||||||
sinceImproved = 0;
|
sinceImproved = 0;
|
||||||
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
}
|
}
|
||||||
|
|
||||||
NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
|
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)
|
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] 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>();
|
return best ?? new List<Part>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
var pairParts = candidate.BuildParts(drawing);
|
var pairParts = candidate.BuildParts(drawing);
|
||||||
var engine = new FillLinear(workArea, partSpacing);
|
var engine = new FillLinear(workArea, partSpacing);
|
||||||
var angles = BuildTilingAngles(candidate);
|
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)
|
private static List<double> BuildTilingAngles(BestFitResult candidate)
|
||||||
|
|||||||
462
OpenNest.Engine/Fill/StripeFiller.cs
Normal file
462
OpenNest.Engine/Fill/StripeFiller.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
OpenNest.Engine/Fill/VerticalRemnantComparer.cs
Normal file
49
OpenNest.Engine/Fill/VerticalRemnantComparer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
OpenNest.Engine/HorizontalRemnantEngine.cs
Normal file
42
OpenNest.Engine/HorizontalRemnantEngine.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
OpenNest.Engine/IFillComparer.cs
Normal file
14
OpenNest.Engine/IFillComparer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -31,6 +33,25 @@ namespace OpenNest
|
|||||||
|
|
||||||
public abstract string Description { get; }
|
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) ---
|
// --- Virtual methods (side-effect-free, return parts) ---
|
||||||
|
|
||||||
public virtual List<Part> Fill(NestItem item, Box workArea,
|
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)
|
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||||
{
|
=> Comparer.IsBetter(candidate, current, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ namespace OpenNest
|
|||||||
Register("NFP",
|
Register("NFP",
|
||||||
"NFP-based mixed-part nesting with simulated annealing",
|
"NFP-based mixed-part nesting with simulated annealing",
|
||||||
plate => new NfpNestEngine(plate));
|
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;
|
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
|
||||||
|
|||||||
17
OpenNest.Engine/Strategies/ColumnFillStrategy.cs
Normal file
17
OpenNest.Engine/Strategies/ColumnFillStrategy.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace OpenNest.Engine.Strategies
|
|||||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||||
|
|
||||||
List<Part> best = null;
|
List<Part> best = null;
|
||||||
var bestScore = default(FillScore);
|
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||||
|
|
||||||
foreach (var angle in angles)
|
foreach (var angle in angles)
|
||||||
{
|
{
|
||||||
@@ -30,12 +30,8 @@ namespace OpenNest.Engine.Strategies
|
|||||||
context.PlateNumber, context.Token, context.Progress);
|
context.PlateNumber, context.Token, context.Progress);
|
||||||
if (result != null && result.Count > 0)
|
if (result != null && result.Count > 0)
|
||||||
{
|
{
|
||||||
var score = FillScore.Compute(result, context.WorkArea);
|
if (best == null || comparer.IsBetter(result, best, context.WorkArea))
|
||||||
if (best == null || score > bestScore)
|
|
||||||
{
|
|
||||||
best = result;
|
best = result;
|
||||||
bestScore = score;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ namespace OpenNest.Engine.Strategies
|
|||||||
public int PlateNumber { get; init; }
|
public int PlateNumber { get; init; }
|
||||||
public CancellationToken Token { get; init; }
|
public CancellationToken Token { get; init; }
|
||||||
public IProgress<NestProgress> Progress { get; init; }
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
|
public FillPolicy Policy { get; init; }
|
||||||
|
|
||||||
public List<Part> CurrentBest { get; set; }
|
public List<Part> CurrentBest { get; set; }
|
||||||
|
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||||
public FillScore CurrentBestScore { get; set; }
|
public FillScore CurrentBestScore { get; set; }
|
||||||
public NestPhase WinnerPhase { get; set; }
|
public NestPhase WinnerPhase { get; set; }
|
||||||
public List<PhaseResult> PhaseResults { get; } = new();
|
public List<PhaseResult> PhaseResults { get; } = new();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -29,7 +30,7 @@ namespace OpenNest.Engine.Strategies
|
|||||||
return pattern;
|
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)>();
|
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||||
|
|
||||||
@@ -54,14 +55,59 @@ namespace OpenNest.Engine.Strategies
|
|||||||
|
|
||||||
foreach (var res in results)
|
foreach (var res in results)
|
||||||
{
|
{
|
||||||
if (best == null || res.Score > bestScore)
|
if (comparer != null)
|
||||||
{
|
{
|
||||||
best = res.Parts;
|
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
|
||||||
bestScore = res.Score;
|
best = res.Parts;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (best == null || res.Score > bestScore)
|
||||||
|
{
|
||||||
|
best = res.Parts;
|
||||||
|
bestScore = res.Score;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best;
|
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>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
OpenNest.Engine/Strategies/FillPolicy.cs
Normal file
8
OpenNest.Engine/Strategies/FillPolicy.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ namespace OpenNest.Engine.Strategies
|
|||||||
: new List<double> { 0, Angle.HalfPI };
|
: new List<double> { 0, Angle.HalfPI };
|
||||||
|
|
||||||
var workArea = context.WorkArea;
|
var workArea = context.WorkArea;
|
||||||
|
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||||
|
var preferred = context.Policy?.PreferredDirection;
|
||||||
List<Part> best = null;
|
List<Part> best = null;
|
||||||
var bestScore = default(FillScore);
|
|
||||||
|
|
||||||
for (var ai = 0; ai < angles.Count; ai++)
|
for (var ai = 0; ai < angles.Count; ai++)
|
||||||
{
|
{
|
||||||
@@ -26,48 +27,29 @@ namespace OpenNest.Engine.Strategies
|
|||||||
|
|
||||||
var angle = angles[ai];
|
var angle = angles[ai];
|
||||||
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
|
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);
|
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
|
context.AngleResults.Add(new AngleResult
|
||||||
{
|
{
|
||||||
AngleDeg = angleDeg,
|
AngleDeg = angleDeg,
|
||||||
Direction = NestDirection.Horizontal,
|
Direction = preferred ?? NestDirection.Horizontal,
|
||||||
PartCount = h.Count
|
PartCount = result.Count
|
||||||
});
|
});
|
||||||
|
|
||||||
if (best == null || scoreH > bestScore)
|
if (best == null || comparer.IsBetter(result, best, workArea))
|
||||||
{
|
best = result;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||||
context.PlateNumber, best, workArea,
|
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>();
|
return best ?? new List<Part>();
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace OpenNest.Engine.Strategies
|
|||||||
|
|
||||||
public List<Part> Fill(FillContext context)
|
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,
|
var result = filler.Fill(context.Item, context.WorkArea,
|
||||||
context.PlateNumber, context.Token, context.Progress);
|
context.PlateNumber, context.Token, context.Progress);
|
||||||
|
|
||||||
|
|||||||
17
OpenNest.Engine/Strategies/RowFillStrategy.cs
Normal file
17
OpenNest.Engine/Strategies/RowFillStrategy.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
OpenNest.Engine/VerticalRemnantEngine.cs
Normal file
42
OpenNest.Engine/VerticalRemnantEngine.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
OpenNest.Tests/FillComparerTests.cs
Normal file
173
OpenNest.Tests/FillComparerTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
OpenNest.Tests/FillPolicyTests.cs
Normal file
50
OpenNest.Tests/FillPolicyTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
OpenNest.Tests/RemnantEngineTests.cs
Normal file
92
OpenNest.Tests/RemnantEngineTests.cs
Normal 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})");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ public class FillPipelineTests
|
|||||||
|
|
||||||
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
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}");
|
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,8 @@ public class FillPipelineTests
|
|||||||
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||||
engine.WinnerPhase == NestPhase.Linear ||
|
engine.WinnerPhase == NestPhase.Linear ||
|
||||||
engine.WinnerPhase == NestPhase.RectBestFit ||
|
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||||
engine.WinnerPhase == NestPhase.Extents);
|
engine.WinnerPhase == NestPhase.Extents ||
|
||||||
|
engine.WinnerPhase == NestPhase.Custom);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
|
|
||||||
namespace OpenNest.Tests.Strategies;
|
namespace OpenNest.Tests.Strategies;
|
||||||
@@ -9,11 +10,13 @@ public class FillStrategyRegistryTests
|
|||||||
{
|
{
|
||||||
var strategies = FillStrategyRegistry.Strategies;
|
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 == "Pairs");
|
||||||
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
||||||
Assert.Contains(strategies, s => s.Name == "Extents");
|
Assert.Contains(strategies, s => s.Name == "Extents");
|
||||||
Assert.Contains(strategies, s => s.Name == "Linear");
|
Assert.Contains(strategies, s => s.Name == "Linear");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Row");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Column");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -34,4 +37,19 @@ public class FillStrategyRegistryTests
|
|||||||
|
|
||||||
Assert.Equal("Linear", last.Name);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
217
OpenNest.Tests/Strategies/StripeFillerTests.cs
Normal file
217
OpenNest.Tests/Strategies/StripeFillerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user