Compare commits
46 Commits
2d1f2217e5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a548d5329a | |||
| 07012033c7 | |||
| 92b17b2963 | |||
| b6ee04f038 | |||
| 8ffdacd6c0 | |||
| ccd402c50f | |||
| b1e872577c | |||
| 9903478d3e | |||
| 93a8981d0a | |||
| 00e7866506 | |||
| 560105f952 | |||
| 266f8a83e6 | |||
| 0b7697e9c0 | |||
| 83124eb38d | |||
| 24beb8ada1 | |||
| ee83f17afe | |||
| 99546e7eef | |||
| 4586a53590 | |||
| 1a41eeb81d | |||
| f894ffd27c | |||
| 0ec22f2207 | |||
| 3f3d95a5e4 | |||
| 811d23510e | |||
| 0597a11a23 | |||
| 2ae1d513cf | |||
| 904d30d05d | |||
| e9678c73b2 | |||
| 4060430757 | |||
| de527cd668 | |||
| 9887cb1aa3 | |||
| cdf8e4e40e | |||
| 4f21fb91a1 | |||
| 7f96d632f3 | |||
| 38dcaf16d3 | |||
| 8c57e43221 | |||
| bc78ddc49c | |||
| c88cec2beb | |||
| b7c7cecd75 | |||
| 4d0d8c453b | |||
| 5f4288a786 | |||
| 707ddb80d9 | |||
| 71f28600d1 | |||
| d39b0ae540 | |||
| ee5c77c645 | |||
| 4615bcb40d | |||
| 7843de145b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -208,3 +208,7 @@ FakesAssemblies/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.superpowers/
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
|
||||
return MinkowskiSum(stationary, reflected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimized version of Compute for polygons known to be convex.
|
||||
/// Bypasses expensive triangulation and Clipper unions.
|
||||
/// </summary>
|
||||
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
|
||||
{
|
||||
var reflected = Reflect(orbiting);
|
||||
return ConvexMinkowskiSum(stationary, reflected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflects a polygon through the origin (negates all vertex coordinates).
|
||||
/// Point reflection (negating both axes) is equivalent to 180° rotation,
|
||||
/// which preserves winding order. No reversal needed.
|
||||
/// </summary>
|
||||
private static Polygon Reflect(Polygon polygon)
|
||||
{
|
||||
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
|
||||
foreach (var v in polygon.Vertices)
|
||||
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
||||
|
||||
// Reflecting reverses winding order — reverse to maintain CCW.
|
||||
result.Vertices.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -78,19 +89,24 @@ namespace OpenNest.Geometry
|
||||
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
||||
/// Both polygons must have CCW winding.
|
||||
/// </summary>
|
||||
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||
{
|
||||
var edgesA = GetEdgeVectors(a);
|
||||
var edgesB = GetEdgeVectors(b);
|
||||
|
||||
// Find bottom-most (then left-most) vertex for each polygon as starting point.
|
||||
// Find indices of bottom-left vertices for both.
|
||||
var startA = FindBottomLeft(a);
|
||||
var startB = FindBottomLeft(b);
|
||||
|
||||
var result = new Polygon();
|
||||
|
||||
// The starting point of the Minkowski sum A + B is the sum of the
|
||||
// starting points of A and B. For NFP = A + (-B), this is
|
||||
// startA + startReflectedB.
|
||||
var current = new Vector(
|
||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||
|
||||
result.Vertices.Add(current);
|
||||
|
||||
var ia = 0;
|
||||
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
|
||||
var na = edgesA.Count;
|
||||
var nb = edgesB.Count;
|
||||
|
||||
// Reorder edges to start from the bottom-left vertex.
|
||||
var orderedA = ReorderEdges(edgesA, startA);
|
||||
var orderedB = ReorderEdges(edgesB, startB);
|
||||
|
||||
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
|
||||
else
|
||||
{
|
||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
||||
if (angleA < 0) angleA += Angle.TwoPI;
|
||||
|
||||
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||
if (angleB < 0) angleB += Angle.TwoPI;
|
||||
|
||||
if (angleA < angleB)
|
||||
{
|
||||
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same angle — merge both edges.
|
||||
edge = new Vector(
|
||||
orderedA[ia].X + orderedB[ib].X,
|
||||
orderedA[ia].Y + orderedB[ib].Y);
|
||||
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
private static double RayEdgeDistance(
|
||||
public static double RayEdgeDistance(
|
||||
double vx, double vy,
|
||||
double p1x, double p1y, double p2x, double p2y,
|
||||
double dirX, double dirY)
|
||||
|
||||
@@ -12,14 +12,16 @@ namespace OpenNest.Engine.BestFit
|
||||
public class BestFitFinder
|
||||
{
|
||||
private readonly IPairEvaluator _evaluator;
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
private readonly IDistanceComputer _distanceComputer;
|
||||
private readonly BestFitFilter _filter;
|
||||
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
||||
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
||||
{
|
||||
_evaluator = evaluator ?? new PairEvaluator();
|
||||
_slideComputer = slideComputer;
|
||||
_distanceComputer = slideComputer != null
|
||||
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
||||
: new CpuDistanceComputer();
|
||||
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
||||
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||
_filter = new BestFitFilter
|
||||
@@ -36,7 +38,7 @@ namespace OpenNest.Engine.BestFit
|
||||
double stepSize = 0.25,
|
||||
BestFitSortField sortBy = BestFitSortField.Area)
|
||||
{
|
||||
var strategies = BuildStrategies(drawing);
|
||||
var strategies = BuildStrategies(drawing, spacing);
|
||||
|
||||
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
||||
|
||||
@@ -75,16 +77,16 @@ namespace OpenNest.Engine.BestFit
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
|
||||
private List<IBestFitStrategy> BuildStrategies(Drawing drawing, double spacing)
|
||||
{
|
||||
var angles = GetRotationAngles(drawing);
|
||||
var strategies = new List<IBestFitStrategy>();
|
||||
var type = 1;
|
||||
var index = 1;
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
|
||||
strategies.Add(new RotationSlideStrategy(angle, index++, desc, _distanceComputer));
|
||||
}
|
||||
|
||||
return strategies;
|
||||
@@ -226,7 +228,7 @@ namespace OpenNest.Engine.BestFit
|
||||
case BestFitSortField.ShortestSide:
|
||||
return results.OrderBy(r => r.ShortestSide).ToList();
|
||||
case BestFitSortField.Type:
|
||||
return results.OrderBy(r => r.Candidate.StrategyType)
|
||||
return results.OrderBy(r => r.Candidate.StrategyIndex)
|
||||
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
||||
case BestFitSortField.OriginalSequence:
|
||||
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
|
||||
|
||||
152
OpenNest.Engine/BestFit/CpuDistanceComputer.cs
Normal file
152
OpenNest.Engine/BestFit/CpuDistanceComputer.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class CpuDistanceComputer : IDistanceComputer
|
||||
{
|
||||
public double[] ComputeDistances(
|
||||
List<Line> stationaryLines,
|
||||
List<Line> movingTemplateLines,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
|
||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||
|
||||
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
|
||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
|
||||
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary edges
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryLines.Count; j++)
|
||||
{
|
||||
var e = stationaryLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
vx, vy,
|
||||
e.StartPoint.X, e.StartPoint.Y,
|
||||
e.EndPoint.X, e.EndPoint.Y,
|
||||
dirX, dirY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving edges (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingTemplateLines.Count; j++)
|
||||
{
|
||||
var e = movingTemplateLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
svx, svy,
|
||||
e.StartPoint.X + offset.Dx, e.StartPoint.Y + offset.Dy,
|
||||
e.EndPoint.X + offset.Dx, e.EndPoint.Y + offset.Dy,
|
||||
oppX, oppY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
vertices.Add(lines[i].StartPoint);
|
||||
vertices.Add(lines[i].EndPoint);
|
||||
}
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters vertices by their projection onto the push direction.
|
||||
/// keepHigh=true returns the leading half (front face, closest to target).
|
||||
/// keepHigh=false returns the facing half (side facing the approaching part).
|
||||
/// </summary>
|
||||
private static Vector[] FilterVerticesByProjection(
|
||||
Vector[] vertices, double dirX, double dirY, bool keepHigh)
|
||||
{
|
||||
if (vertices.Length == 0)
|
||||
return vertices;
|
||||
|
||||
var projections = new double[vertices.Length];
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
|
||||
if (projections[i] < min) min = projections[i];
|
||||
if (projections[i] > max) max = projections[i];
|
||||
}
|
||||
|
||||
var midpoint = (min + max) / 2;
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
count++;
|
||||
}
|
||||
|
||||
var result = new Vector[count];
|
||||
var idx = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
result[idx++] = vertices[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
OpenNest.Engine/BestFit/GpuDistanceComputer.cs
Normal file
51
OpenNest.Engine/BestFit/GpuDistanceComputer.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class GpuDistanceComputer : IDistanceComputer
|
||||
{
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
|
||||
public GpuDistanceComputer(ISlideComputer slideComputer)
|
||||
{
|
||||
_slideComputer = slideComputer;
|
||||
}
|
||||
|
||||
public double[] ComputeDistances(
|
||||
List<Line> stationaryLines,
|
||||
List<Line> movingTemplateLines,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
|
||||
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
|
||||
var count = offsets.Length;
|
||||
var flatOffsets = new double[count * 2];
|
||||
var directions = new int[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
flatOffsets[i * 2] = offsets[i].Dx;
|
||||
flatOffsets[i * 2 + 1] = offsets[i].Dy;
|
||||
directions[i] = DirectionVectorToInt(offsets[i].DirX, offsets[i].DirY);
|
||||
}
|
||||
|
||||
return _slideComputer.ComputeBatchMultiDir(
|
||||
stationarySegments, stationaryLines.Count,
|
||||
movingSegments, movingTemplateLines.Count,
|
||||
flatOffsets, count, directions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||
/// Left=0, Down=1, Right=2, Up=3.
|
||||
/// </summary>
|
||||
private static int DirectionVectorToInt(double dirX, double dirY)
|
||||
{
|
||||
if (dirX < -0.5) return (int)PushDirection.Left;
|
||||
if (dirX > 0.5) return (int)PushDirection.Right;
|
||||
if (dirY < -0.5) return (int)PushDirection.Down;
|
||||
return (int)PushDirection.Up;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public interface IBestFitStrategy
|
||||
{
|
||||
int Type { get; }
|
||||
int StrategyIndex { get; }
|
||||
string Description { get; }
|
||||
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
|
||||
}
|
||||
|
||||
13
OpenNest.Engine/BestFit/IDistanceComputer.cs
Normal file
13
OpenNest.Engine/BestFit/IDistanceComputer.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public interface IDistanceComputer
|
||||
{
|
||||
double[] ComputeDistances(
|
||||
List<Line> stationaryLines,
|
||||
List<Line> movingTemplateLines,
|
||||
SlideOffset[] offsets);
|
||||
}
|
||||
}
|
||||
179
OpenNest.Engine/BestFit/NfpSlideStrategy.cs
Normal file
179
OpenNest.Engine/BestFit/NfpSlideStrategy.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class NfpSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
|
||||
"nfp-slide-debug.log");
|
||||
|
||||
private static readonly object LogLock = new object();
|
||||
|
||||
private readonly double _part2Rotation;
|
||||
private readonly Polygon _stationaryPerimeter;
|
||||
private readonly Polygon _stationaryHull;
|
||||
private readonly Vector _correction;
|
||||
|
||||
public NfpSlideStrategy(double part2Rotation, int type, string description,
|
||||
Polygon stationaryPerimeter, Polygon stationaryHull, Vector correction)
|
||||
{
|
||||
_part2Rotation = part2Rotation;
|
||||
StrategyIndex = type;
|
||||
Description = description;
|
||||
_stationaryPerimeter = stationaryPerimeter;
|
||||
_stationaryHull = stationaryHull;
|
||||
_correction = correction;
|
||||
}
|
||||
|
||||
public int StrategyIndex { get; }
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an NfpSlideStrategy by extracting polygon data from a drawing.
|
||||
/// Returns null if the drawing has no valid perimeter.
|
||||
/// </summary>
|
||||
public static NfpSlideStrategy Create(Drawing drawing, double part2Rotation,
|
||||
int type, string description, double spacing)
|
||||
{
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, spacing / 2);
|
||||
|
||||
if (result.Polygon == null)
|
||||
return null;
|
||||
|
||||
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
||||
|
||||
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
|
||||
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
|
||||
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
|
||||
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
|
||||
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
|
||||
|
||||
return new NfpSlideStrategy(part2Rotation, type, description,
|
||||
result.Polygon, hull, result.Correction);
|
||||
}
|
||||
|
||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||
{
|
||||
var candidates = new List<PairCandidate>();
|
||||
|
||||
if (stepSize <= 0)
|
||||
return candidates;
|
||||
|
||||
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
|
||||
|
||||
// Orbiting polygon: same shape rotated to Part2's angle.
|
||||
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
||||
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
||||
|
||||
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
|
||||
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
|
||||
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
|
||||
|
||||
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
||||
|
||||
if (nfp == null || nfp.Vertices.Count < 3)
|
||||
{
|
||||
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var verts = nfp.Vertices;
|
||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
|
||||
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
|
||||
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
|
||||
|
||||
// Log NFP vertices
|
||||
for (var v = 0; v < vertCount; v++)
|
||||
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
|
||||
|
||||
// Compare with what RotationSlideStrategy would produce
|
||||
var part1 = Part.CreateAtOrigin(drawing);
|
||||
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
|
||||
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
|
||||
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
|
||||
|
||||
var testNumber = 0;
|
||||
|
||||
for (var i = 0; i < vertCount; i++)
|
||||
{
|
||||
var offset = ApplyCorrection(verts[i], _correction);
|
||||
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
|
||||
|
||||
// Add edge samples for long edges.
|
||||
var next = (i + 1) % vertCount;
|
||||
var dx = verts[next].X - verts[i].X;
|
||||
var dy = verts[next].Y - verts[i].Y;
|
||||
var edgeLength = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (edgeLength > stepSize)
|
||||
{
|
||||
var steps = (int)(edgeLength / stepSize);
|
||||
for (var s = 1; s < steps; s++)
|
||||
{
|
||||
var t = (double)s / steps;
|
||||
var sample = new Vector(
|
||||
verts[i].X + dx * t,
|
||||
verts[i].Y + dy * t);
|
||||
var sampleOffset = ApplyCorrection(sample, _correction);
|
||||
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log overlap check for vertex candidates (first few)
|
||||
var checkCount = System.Math.Min(vertCount, 8);
|
||||
for (var c = 0; c < checkCount; c++)
|
||||
{
|
||||
var cand = candidates[c];
|
||||
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
|
||||
p2.Location = cand.Part2Offset;
|
||||
var overlaps = part1.Intersects(p2, out _);
|
||||
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
|
||||
}
|
||||
|
||||
Log($" Total candidates: {candidates.Count}");
|
||||
Log("");
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
|
||||
{
|
||||
return new Vector(nfpVertex.X - correction.X, nfpVertex.Y - correction.Y);
|
||||
}
|
||||
|
||||
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
|
||||
{
|
||||
return new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = _part2Rotation,
|
||||
Part2Offset = offset,
|
||||
StrategyIndex = StrategyIndex,
|
||||
TestNumber = testNumber,
|
||||
Spacing = spacing
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatBounds(Polygon polygon)
|
||||
{
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
|
||||
}
|
||||
|
||||
private static void Log(string message)
|
||||
{
|
||||
lock (LogLock)
|
||||
{
|
||||
File.AppendAllText(LogPath, message + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace OpenNest.Engine.BestFit
|
||||
public double Part1Rotation { get; set; }
|
||||
public double Part2Rotation { get; set; }
|
||||
public Vector Part2Offset { get; set; }
|
||||
public int StrategyType { get; set; }
|
||||
public int StrategyIndex { get; set; }
|
||||
public int TestNumber { get; set; }
|
||||
public double Spacing { get; set; }
|
||||
}
|
||||
|
||||
77
OpenNest.Engine/BestFit/PolygonHelper.cs
Normal file
77
OpenNest.Engine/BestFit/PolygonHelper.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public static class PolygonHelper
|
||||
{
|
||||
public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
|
||||
var inflated = halfSpacing > 0
|
||||
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
|
||||
: perimeter;
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Normalize: move polygon to origin.
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
// No correction needed: BestFitFinder always pairs the same drawing with
|
||||
// itself, so the polygon-to-part offset is identical for both parts and
|
||||
// cancels out in the NFP displacement.
|
||||
return new PolygonExtractionResult(polygon, Vector.Zero);
|
||||
}
|
||||
|
||||
public static Polygon RotatePolygon(Polygon polygon, double angle, bool reNormalize = true)
|
||||
{
|
||||
if (angle.IsEqualTo(0))
|
||||
return polygon;
|
||||
|
||||
var result = new Polygon();
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
result.Vertices.Add(new Vector(
|
||||
v.X * cos - v.Y * sin,
|
||||
v.X * sin + v.Y * cos));
|
||||
}
|
||||
|
||||
if (reNormalize)
|
||||
{
|
||||
// Re-normalize to origin.
|
||||
result.UpdateBounds();
|
||||
var bb = result.BoundingBox;
|
||||
result.Offset(-bb.Left, -bb.Bottom);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record PolygonExtractionResult(Polygon Polygon, Vector Correction);
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class RotationSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
private readonly IDistanceComputer _distanceComputer;
|
||||
|
||||
private static readonly PushDirection[] AllDirections =
|
||||
private static readonly (double DirX, double DirY)[] PushDirections =
|
||||
{
|
||||
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
|
||||
(-1, 0), // Left
|
||||
(0, -1), // Down
|
||||
(1, 0), // Right
|
||||
(0, 1) // Up
|
||||
};
|
||||
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description,
|
||||
ISlideComputer slideComputer = null)
|
||||
public RotationSlideStrategy(double part2Rotation, int strategyIndex, string description,
|
||||
IDistanceComputer distanceComputer)
|
||||
{
|
||||
Part2Rotation = part2Rotation;
|
||||
Type = type;
|
||||
StrategyIndex = strategyIndex;
|
||||
Description = description;
|
||||
_slideComputer = slideComputer;
|
||||
_distanceComputer = distanceComputer;
|
||||
}
|
||||
|
||||
public double Part2Rotation { get; }
|
||||
public int Type { get; }
|
||||
public int StrategyIndex { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||
@@ -40,36 +42,25 @@ namespace OpenNest.Engine.BestFit
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
|
||||
// Collect offsets and directions across all 4 axes
|
||||
var allDx = new List<double>();
|
||||
var allDy = new List<double>();
|
||||
var allDirs = new List<PushDirection>();
|
||||
var offsets = BuildOffsets(bbox1, bbox2, spacing, stepSize);
|
||||
|
||||
foreach (var pushDir in AllDirections)
|
||||
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
|
||||
|
||||
if (allDx.Count == 0)
|
||||
if (offsets.Length == 0)
|
||||
return candidates;
|
||||
|
||||
// Compute all distances — single GPU dispatch or CPU loop
|
||||
var distances = ComputeAllDistances(
|
||||
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
|
||||
var distances = _distanceComputer.ComputeDistances(
|
||||
part1Lines, part2TemplateLines, offsets);
|
||||
|
||||
// Create candidates from valid results
|
||||
var testNumber = 0;
|
||||
|
||||
for (var i = 0; i < allDx.Count; i++)
|
||||
for (var i = 0; i < offsets.Length; i++)
|
||||
{
|
||||
var slideDist = distances[i];
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
var dx = allDx[i];
|
||||
var dy = allDy[i];
|
||||
var pushVector = GetPushVector(allDirs[i], slideDist);
|
||||
var finalPosition = new Vector(
|
||||
part2Template.Location.X + dx + pushVector.X,
|
||||
part2Template.Location.Y + dy + pushVector.Y);
|
||||
part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
|
||||
part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist);
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
@@ -77,7 +68,7 @@ namespace OpenNest.Engine.BestFit
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
StrategyIndex = StrategyIndex,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
@@ -86,158 +77,44 @@ namespace OpenNest.Engine.BestFit
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static void BuildOffsets(
|
||||
Box bbox1, Box bbox2, double spacing, double stepSize,
|
||||
PushDirection pushDir, List<double> allDx, List<double> allDy,
|
||||
List<PushDirection> allDirs)
|
||||
private static SlideOffset[] BuildOffsets(Box bbox1, Box bbox2, double spacing, double stepSize)
|
||||
{
|
||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
||||
var offsets = new List<SlideOffset>();
|
||||
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
foreach (var (dirX, dirY) in PushDirections)
|
||||
{
|
||||
perpMin = -(bbox2.Length + spacing);
|
||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpMin = -(bbox2.Width + spacing);
|
||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
var isHorizontalPush = System.Math.Abs(dirX) > System.Math.Abs(dirY);
|
||||
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
|
||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||
{
|
||||
allDx.Add(isHorizontalPush ? startPos : offset);
|
||||
allDy.Add(isHorizontalPush ? offset : startPos);
|
||||
allDirs.Add(pushDir);
|
||||
}
|
||||
}
|
||||
|
||||
private double[] ComputeAllDistances(
|
||||
List<Line> part1Lines, List<Line> part2TemplateLines,
|
||||
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
|
||||
{
|
||||
var count = allDx.Count;
|
||||
|
||||
if (_slideComputer != null)
|
||||
{
|
||||
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
|
||||
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
|
||||
var offsets = new double[count * 2];
|
||||
var directions = new int[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
if (isHorizontalPush)
|
||||
{
|
||||
offsets[i * 2] = allDx[i];
|
||||
offsets[i * 2 + 1] = allDy[i];
|
||||
directions[i] = (int)allDirs[i];
|
||||
perpMin = -(bbox2.Length + spacing);
|
||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
}
|
||||
|
||||
return _slideComputer.ComputeBatchMultiDir(
|
||||
stationarySegments, part1Lines.Count,
|
||||
movingSegments, part2TemplateLines.Count,
|
||||
offsets, count, directions);
|
||||
}
|
||||
|
||||
var results = new double[count];
|
||||
|
||||
// Pre-calculate moving vertices in local space.
|
||||
var movingVerticesLocal = new HashSet<Vector>();
|
||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
||||
{
|
||||
movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
|
||||
movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
|
||||
}
|
||||
var movingVerticesArray = movingVerticesLocal.ToArray();
|
||||
|
||||
// Pre-calculate stationary vertices in local space.
|
||||
var stationaryVerticesLocal = new HashSet<Vector>();
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
{
|
||||
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
|
||||
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
|
||||
}
|
||||
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
|
||||
|
||||
// Pre-sort stationary and moving edges for all 4 directions.
|
||||
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
|
||||
foreach (var dir in AllDirections)
|
||||
{
|
||||
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
|
||||
|
||||
if (dir == PushDirection.Left || dir == PushDirection.Right)
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
stationaryEdgesByDir[dir] = sEdges;
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
||||
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
|
||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
||||
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
movingEdgesByDir[dir] = mEdges;
|
||||
}
|
||||
|
||||
// Use Parallel.For for the heavy lifting.
|
||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||
{
|
||||
var dx = allDx[i];
|
||||
var dy = allDy[i];
|
||||
var dir = allDirs[i];
|
||||
var movingOffset = new Vector(dx, dy);
|
||||
|
||||
var sEdges = stationaryEdgesByDir[dir];
|
||||
var mEdges = movingEdgesByDir[dir];
|
||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Moving vertices -> Stationary edges
|
||||
foreach (var mv in movingVerticesArray)
|
||||
{
|
||||
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
|
||||
if (d < minDist) minDist = d;
|
||||
perpMin = -(bbox2.Width + spacing);
|
||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
|
||||
// Case 2: Stationary vertices -> Moving edges (translated)
|
||||
foreach (var sv in stationaryVerticesArray)
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
|
||||
// Start on the opposite side of the push direction.
|
||||
var pushComponent = isHorizontalPush ? dirX : dirY;
|
||||
var startPos = pushComponent < 0 ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||
{
|
||||
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
var dx = isHorizontalPush ? startPos : offset;
|
||||
var dy = isHorizontalPush ? offset : startPos;
|
||||
offsets.Add(new SlideOffset(dx, dy, dirX, dirY));
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector GetPushVector(PushDirection direction, double distance)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return new Vector(-distance, 0);
|
||||
case PushDirection.Right: return new Vector(distance, 0);
|
||||
case PushDirection.Down: return new Vector(0, -distance);
|
||||
case PushDirection.Up: return new Vector(0, distance);
|
||||
default: return Vector.Zero;
|
||||
}
|
||||
|
||||
return offsets.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
OpenNest.Engine/BestFit/SlideOffset.cs
Normal file
18
OpenNest.Engine/BestFit/SlideOffset.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public readonly struct SlideOffset
|
||||
{
|
||||
public double Dx { get; }
|
||||
public double Dy { get; }
|
||||
public double DirX { get; }
|
||||
public double DirY { get; }
|
||||
|
||||
public SlideOffset(double dx, double dy, double dirX, double dirY)
|
||||
{
|
||||
Dx = dx;
|
||||
Dy = dy;
|
||||
DirX = dirX;
|
||||
DirY = dirY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -55,8 +66,15 @@ namespace OpenNest
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
|
||||
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = WinnerPhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return best;
|
||||
}
|
||||
@@ -78,13 +96,20 @@ 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}");
|
||||
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Linear,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
@@ -105,12 +130,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 +156,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);
|
||||
@@ -140,9 +165,15 @@ namespace OpenNest
|
||||
|
||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
{
|
||||
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
|
||||
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
Phase = context.WinnerPhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = context.CurrentBest,
|
||||
WorkArea = context.WorkArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +182,7 @@ namespace OpenNest
|
||||
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||
}
|
||||
|
||||
angleBuilder.RecordProductive(context.AngleResults);
|
||||
RecordProductiveAngles(context.AngleResults);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace OpenNest.Engine.Fill
|
||||
combined.AddRange(previousParts);
|
||||
combined.AddRange(value.BestParts);
|
||||
value.BestParts = combined;
|
||||
value.BestPartCount = combined.Count;
|
||||
}
|
||||
|
||||
inner.Report(value);
|
||||
|
||||
@@ -7,74 +7,30 @@ namespace OpenNest
|
||||
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
|
||||
{
|
||||
overallLength += Tolerance.Epsilon;
|
||||
|
||||
if (length1 > overallLength)
|
||||
{
|
||||
if (length2 > overallLength)
|
||||
{
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
count1 = 0;
|
||||
count2 = (int)System.Math.Floor(overallLength / length2);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (length2 > overallLength)
|
||||
{
|
||||
count1 = (int)System.Math.Floor(overallLength / length1);
|
||||
count2 = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
var maxCountLength1 = (int)System.Math.Floor(overallLength / length1);
|
||||
|
||||
count1 = maxCountLength1;
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
|
||||
var remnant = overallLength - maxCountLength1 * length1;
|
||||
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
||||
var bestRemnant = overallLength + 1;
|
||||
|
||||
if (remnant.IsEqualTo(0))
|
||||
return true;
|
||||
|
||||
for (int countLength1 = 0; countLength1 <= maxCountLength1; ++countLength1)
|
||||
for (var c1 = 0; c1 <= maxCount1; c1++)
|
||||
{
|
||||
var remnant1 = overallLength - countLength1 * length1;
|
||||
var remaining = overallLength - c1 * length1;
|
||||
var c2 = (int)System.Math.Floor(remaining / length2);
|
||||
var remnant = remaining - c2 * length2;
|
||||
|
||||
if (remnant1 >= length2)
|
||||
{
|
||||
var countLength2 = (int)System.Math.Floor(remnant1 / length2);
|
||||
var remnant2 = remnant1 - length2 * countLength2;
|
||||
if (!(remnant < bestRemnant))
|
||||
continue;
|
||||
|
||||
if (!(remnant2 < remnant))
|
||||
continue;
|
||||
count1 = c1;
|
||||
count2 = c2;
|
||||
bestRemnant = remnant;
|
||||
|
||||
count1 = countLength1;
|
||||
count2 = countLength2;
|
||||
|
||||
if (remnant2.IsEqualTo(0))
|
||||
break;
|
||||
|
||||
remnant = remnant2;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!(remnant1 < remnant))
|
||||
continue;
|
||||
|
||||
count1 = countLength1;
|
||||
count2 = 0;
|
||||
|
||||
if (remnant1.IsEqualTo(0))
|
||||
break;
|
||||
|
||||
remnant = remnant1;
|
||||
}
|
||||
if (remnant.IsEqualTo(0))
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
return count1 > 0 || count2 > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,18 +36,36 @@ namespace OpenNest.Engine.Fill
|
||||
if (column.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
column, workArea, $"Extents: initial column {column.Count} parts");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = column,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: initial column {column.Count} parts",
|
||||
});
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
adjusted, workArea, $"Extents: adjusted column {adjusted.Count} parts");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
||||
});
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
result, workArea, $"Extents: {result.Count} parts total");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = result,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: {result.Count} parts total",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
OpenNest.Engine/Fill/GridDedup.cs
Normal file
75
OpenNest.Engine/Fill/GridDedup.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks evaluated grid configurations so duplicate pattern/direction/workArea
|
||||
/// combinations can be skipped across fill strategies.
|
||||
/// </summary>
|
||||
public class GridDedup
|
||||
{
|
||||
public const string SharedStateKey = "GridDedup";
|
||||
|
||||
private readonly ConcurrentDictionary<GridKey, byte> _seen = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this configuration has NOT been seen before (i.e., should be evaluated).
|
||||
/// Returns false if it's a duplicate.
|
||||
/// </summary>
|
||||
public bool TryAdd(Box patternBox, Box workArea, NestDirection dir)
|
||||
{
|
||||
var key = new GridKey(patternBox, workArea, dir);
|
||||
return _seen.TryAdd(key, 0);
|
||||
}
|
||||
|
||||
public int Count => _seen.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a GridDedup from FillContext.SharedState.
|
||||
/// </summary>
|
||||
public static GridDedup GetOrCreate(System.Collections.Generic.Dictionary<string, object> sharedState)
|
||||
{
|
||||
if (sharedState.TryGetValue(SharedStateKey, out var existing))
|
||||
return (GridDedup)existing;
|
||||
|
||||
var dedup = new GridDedup();
|
||||
sharedState[SharedStateKey] = dedup;
|
||||
return dedup;
|
||||
}
|
||||
|
||||
private readonly struct GridKey : IEquatable<GridKey>
|
||||
{
|
||||
private readonly int _patternW, _patternL, _workW, _workL, _dir;
|
||||
|
||||
public GridKey(Box patternBox, Box workArea, NestDirection dir)
|
||||
{
|
||||
_patternW = (int)System.Math.Round(patternBox.Width * 10);
|
||||
_patternL = (int)System.Math.Round(patternBox.Length * 10);
|
||||
_workW = (int)System.Math.Round(workArea.Width * 10);
|
||||
_workL = (int)System.Math.Round(workArea.Length * 10);
|
||||
_dir = (int)dir;
|
||||
}
|
||||
|
||||
public bool Equals(GridKey other) =>
|
||||
_patternW == other._patternW && _patternL == other._patternL &&
|
||||
_workW == other._workW && _workL == other._workL &&
|
||||
_dir == other._dir;
|
||||
|
||||
public override bool Equals(object obj) => obj is GridKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = _patternW;
|
||||
hash = hash * 397 ^ _patternL;
|
||||
hash = hash * 397 ^ _workW;
|
||||
hash = hash * 397 ^ _workL;
|
||||
hash = hash * 397 ^ _dir;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ namespace OpenNest.Engine.Fill
|
||||
double spacing,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null,
|
||||
int plateNumber = 0)
|
||||
int plateNumber = 0,
|
||||
Func<NestItem, Box, List<Part>> widthFillFunc = null)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new IterativeShrinkResult();
|
||||
@@ -72,6 +73,8 @@ namespace OpenNest.Engine.Fill
|
||||
// include them in progress reports.
|
||||
var placedSoFar = new List<Part>();
|
||||
|
||||
var wFillFunc = widthFillFunc ?? fillFunc;
|
||||
|
||||
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
|
||||
{
|
||||
var target = ni.Quantity > 0 ? ni.Quantity : 0;
|
||||
@@ -84,7 +87,7 @@ namespace OpenNest.Engine.Fill
|
||||
Parallel.Invoke(
|
||||
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
|
||||
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
|
||||
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
|
||||
() => widthResult = ShrinkFiller.Shrink(wFillFunc, ni, box, spacing, ShrinkAxis.Width, token,
|
||||
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
|
||||
);
|
||||
|
||||
@@ -108,8 +111,15 @@ namespace OpenNest.Engine.Fill
|
||||
var allParts = new List<Part>(placedSoFar.Count + best.Count);
|
||||
allParts.AddRange(placedSoFar);
|
||||
allParts.AddRange(best);
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, box, $"Shrink: {best.Count} parts placed", isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = allParts,
|
||||
WorkArea = box,
|
||||
Description = $"Shrink: {best.Count} parts placed",
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate for the next item's progress reports.
|
||||
|
||||
@@ -7,6 +7,8 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
@@ -27,13 +29,19 @@ namespace OpenNest.Engine.Fill
|
||||
private const int EarlyExitMinTried = 10;
|
||||
private const int EarlyExitStaleLimit = 10;
|
||||
|
||||
private readonly Plate plate;
|
||||
private readonly Size plateSize;
|
||||
private readonly double partSpacing;
|
||||
private readonly IFillComparer comparer;
|
||||
private readonly GridDedup dedup;
|
||||
|
||||
public PairFiller(Size plateSize, double partSpacing)
|
||||
public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null)
|
||||
{
|
||||
this.plateSize = plateSize;
|
||||
this.partSpacing = partSpacing;
|
||||
this.plate = plate;
|
||||
this.plateSize = plate.Size;
|
||||
this.partSpacing = plate.PartSpacing;
|
||||
this.comparer = comparer ?? new DefaultFillComparer();
|
||||
this.dedup = dedup ?? new GridDedup();
|
||||
}
|
||||
|
||||
public PairFillResult Fill(NestItem item, Box workArea,
|
||||
@@ -61,37 +69,62 @@ 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;
|
||||
var batchSize = System.Math.Max(2, Environment.ProcessorCount);
|
||||
|
||||
var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0;
|
||||
var partBox = drawing.Program.BoundingBox();
|
||||
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
|
||||
|
||||
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
for (var batchStart = 0; batchStart < candidates.Count; batchStart += batchSize)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
|
||||
var score = FillScore.Compute(filled, effectiveWorkArea);
|
||||
var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count);
|
||||
var batchCount = batchEnd - batchStart;
|
||||
var batchWorkArea = effectiveWorkArea;
|
||||
var minCountToBeat = best?.Count ?? 0;
|
||||
|
||||
if (score > bestScore)
|
||||
var results = new List<Part>[batchCount];
|
||||
Parallel.For(0, batchCount,
|
||||
new ParallelOptions { CancellationToken = token },
|
||||
j =>
|
||||
{
|
||||
results[j] = EvaluateCandidate(
|
||||
candidates[batchStart + j], drawing, batchWorkArea,
|
||||
minCountToBeat, maxUtilization, partArea, token);
|
||||
});
|
||||
|
||||
for (var j = 0; j < batchCount; j++)
|
||||
{
|
||||
best = filled;
|
||||
bestScore = score;
|
||||
sinceImproved = 0;
|
||||
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
if (comparer.IsBetter(results[j], best, effectiveWorkArea))
|
||||
{
|
||||
best = results[j];
|
||||
sinceImproved = 0;
|
||||
effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Pairs,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
|
||||
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
|
||||
|
||||
if (i + 1 >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
||||
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
||||
Debug.WriteLine($"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -100,8 +133,12 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
|
||||
}
|
||||
finally
|
||||
{
|
||||
FillStrategyRegistry.SetEnabled(null);
|
||||
}
|
||||
|
||||
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>();
|
||||
}
|
||||
|
||||
@@ -142,12 +179,162 @@ namespace OpenNest.Engine.Fill
|
||||
System.Math.Min(newTop - workArea.Y, workArea.Length));
|
||||
}
|
||||
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
|
||||
Box workArea, int minCountToBeat, double maxUtilization, double partArea,
|
||||
CancellationToken token)
|
||||
{
|
||||
var pairParts = candidate.BuildParts(drawing);
|
||||
var engine = new FillLinear(workArea, partSpacing);
|
||||
var angles = BuildTilingAngles(candidate);
|
||||
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
// Phase 1: evaluate all grids (fast)
|
||||
var grids = new List<(List<Part> Parts, NestDirection Dir)>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
|
||||
if (pattern.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var engine = new FillLinear(workArea, partSpacing);
|
||||
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
||||
{
|
||||
if (!dedup.TryAdd(pattern.BoundingBox, workArea, dir))
|
||||
continue;
|
||||
|
||||
var gridParts = engine.Fill(pattern, dir);
|
||||
if (gridParts != null && gridParts.Count > 0)
|
||||
grids.Add((gridParts, dir));
|
||||
}
|
||||
}
|
||||
|
||||
if (grids.Count == 0)
|
||||
return null;
|
||||
|
||||
// Sort by count descending so we try the best grids first
|
||||
grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count));
|
||||
|
||||
// Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2
|
||||
if (minCountToBeat > 0)
|
||||
{
|
||||
var topCount = grids[0].Parts.Count;
|
||||
var optimisticRemnant = EstimateRemnantUpperBound(
|
||||
grids[0].Parts, workArea, maxUtilization, partArea);
|
||||
if (topCount + optimisticRemnant <= minCountToBeat)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: try remnant for each grid, skip if grid is too far behind
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var (gridParts, dir) in grids)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// If this grid + max possible remnant can't beat current best, skip
|
||||
if (best != null)
|
||||
{
|
||||
var remnantBound = EstimateRemnantUpperBound(
|
||||
gridParts, workArea, maxUtilization, partArea);
|
||||
if (gridParts.Count + remnantBound <= best.Count)
|
||||
break; // sorted descending, so remaining are even smaller
|
||||
}
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
|
||||
List<Part> total;
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
total = new List<Part>(gridParts.Count + remnantParts.Count);
|
||||
total.AddRange(gridParts);
|
||||
total.AddRange(remnantParts);
|
||||
}
|
||||
else
|
||||
{
|
||||
total = gridParts;
|
||||
}
|
||||
|
||||
if (comparer.IsBetter(total, best, workArea))
|
||||
best = total;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private int EstimateRemnantUpperBound(List<Part> gridParts, Box workArea,
|
||||
double maxUtilization, double partArea)
|
||||
{
|
||||
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
|
||||
|
||||
// L-shaped remnant: top strip (full width) + right strip (grid height only)
|
||||
var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
|
||||
var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
|
||||
|
||||
var topArea = workArea.Width * topHeight;
|
||||
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length);
|
||||
var remnantArea = topArea + rightArea;
|
||||
|
||||
return (int)(remnantArea * maxUtilization / partArea) + 1;
|
||||
}
|
||||
|
||||
private List<Part> FillRemnant(List<Part> gridParts, Drawing drawing,
|
||||
Box workArea, CancellationToken token)
|
||||
{
|
||||
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
|
||||
var partBox = drawing.Program.BoundingBox();
|
||||
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
|
||||
|
||||
List<Part> bestRemnant = null;
|
||||
|
||||
// Try top remnant (full width, above grid)
|
||||
var topY = gridBox.Top + partSpacing;
|
||||
var topLength = workArea.Top - topY;
|
||||
if (topLength >= minDim)
|
||||
{
|
||||
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
|
||||
var parts = FillRemnantBox(drawing, topBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
}
|
||||
|
||||
// Try right remnant (full height, right of grid)
|
||||
var rightX = gridBox.Right + partSpacing;
|
||||
var rightWidth = workArea.Right - rightX;
|
||||
if (rightWidth >= minDim)
|
||||
{
|
||||
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
|
||||
var parts = FillRemnantBox(drawing, rightBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
}
|
||||
|
||||
return bestRemnant;
|
||||
}
|
||||
|
||||
private List<Part> FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
|
||||
{
|
||||
var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
|
||||
if (cachedResult != null)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
var remnantEngine = NestEngineRegistry.Create(plate);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var parts = remnantEngine.Fill(item, remnantBox, null, token);
|
||||
|
||||
Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " +
|
||||
$"{remnantBox.Width:F2}x{remnantBox.Length:F2}");
|
||||
|
||||
if (parts != null && parts.Count > 0)
|
||||
{
|
||||
FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<double> BuildTilingAngles(BestFitResult candidate)
|
||||
|
||||
@@ -79,8 +79,14 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, workArea, desc);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = allParts,
|
||||
WorkArea = workArea,
|
||||
Description = desc,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
473
OpenNest.Engine/Fill/StripeFiller.cs
Normal file
473
OpenNest.Engine/Fill/StripeFiller.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
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;
|
||||
private readonly GridDedup _dedup;
|
||||
|
||||
/// <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();
|
||||
_dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
}
|
||||
|
||||
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, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = _context.PlateNumber,
|
||||
Parts = bestParts,
|
||||
WorkArea = workArea,
|
||||
Description = $"{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);
|
||||
|
||||
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
|
||||
return null;
|
||||
|
||||
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,5 +1,6 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Nfp;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -32,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,
|
||||
@@ -130,9 +150,6 @@ namespace OpenNest
|
||||
// Compact placed parts toward the origin to close gaps.
|
||||
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
|
||||
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
allParts = AutoNester.Optimize(allParts, Plate);
|
||||
|
||||
return allParts;
|
||||
}
|
||||
|
||||
@@ -193,55 +210,26 @@ namespace OpenNest
|
||||
// --- Protected utilities ---
|
||||
|
||||
internal static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description,
|
||||
bool isOverallBest = false)
|
||||
IProgress<NestProgress> progress, ProgressReport report)
|
||||
{
|
||||
if (progress == null || best == null || best.Count == 0)
|
||||
if (progress == null || report.Parts == null || report.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
var clonedParts = new List<Part>(report.Parts.Count);
|
||||
foreach (var part in report.Parts)
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
var bounds = best.GetBoundingBox();
|
||||
|
||||
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
|
||||
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
|
||||
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
|
||||
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
|
||||
Debug.WriteLine(msg);
|
||||
try
|
||||
{
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n");
|
||||
}
|
||||
catch { }
|
||||
Debug.WriteLine($"[Progress] Phase={report.Phase}, Plate={report.PlateNumber}, " +
|
||||
$"Parts={clonedParts.Count} | {report.Description}");
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
Phase = report.Phase,
|
||||
PlateNumber = report.PlateNumber,
|
||||
BestParts = clonedParts,
|
||||
Description = description,
|
||||
ActiveWorkArea = workArea,
|
||||
IsOverallBest = isOverallBest,
|
||||
Description = report.Description,
|
||||
ActiveWorkArea = report.WorkArea,
|
||||
IsOverallBest = report.IsOverallBest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,21 +241,13 @@ namespace OpenNest
|
||||
var parts = new List<string>(PhaseResults.Count);
|
||||
|
||||
foreach (var r in PhaseResults)
|
||||
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
|
||||
parts.Add($"{r.Phase.ShortName()}: {r.PartCount}");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -314,17 +294,5 @@ namespace OpenNest
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string FormatPhaseName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Extents: return "Extents";
|
||||
case NestPhase.Custom: return "Custom";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
internal class ShortNameAttribute(string name) : Attribute
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
}
|
||||
|
||||
public enum NestPhase
|
||||
{
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Extents,
|
||||
Custom
|
||||
[Description("Trying rotations..."), ShortName("Linear")] Linear,
|
||||
[Description("Trying best fit..."), ShortName("BestFit")] RectBestFit,
|
||||
[Description("Trying pairs..."), ShortName("Pairs")] Pairs,
|
||||
[Description("Trying NFP..."), ShortName("NFP")] Nfp,
|
||||
[Description("Trying extents..."), ShortName("Extents")] Extents,
|
||||
[Description("Custom"), ShortName("Custom")] Custom
|
||||
}
|
||||
|
||||
public static class NestPhaseExtensions
|
||||
{
|
||||
private static readonly ConcurrentDictionary<NestPhase, string> DisplayNames = new();
|
||||
private static readonly ConcurrentDictionary<NestPhase, string> ShortNames = new();
|
||||
|
||||
public static string DisplayName(this NestPhase phase)
|
||||
{
|
||||
return DisplayNames.GetOrAdd(phase, p =>
|
||||
{
|
||||
var field = typeof(NestPhase).GetField(p.ToString());
|
||||
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
|
||||
return attr?.Description ?? p.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
public static string ShortName(this NestPhase phase)
|
||||
{
|
||||
return ShortNames.GetOrAdd(phase, p =>
|
||||
{
|
||||
var field = typeof(NestPhase).GetField(p.ToString());
|
||||
var attr = field?.GetCustomAttribute<ShortNameAttribute>();
|
||||
return attr?.Name ?? p.ToString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class PhaseResult
|
||||
@@ -34,18 +70,93 @@ namespace OpenNest
|
||||
public int PartCount { get; set; }
|
||||
}
|
||||
|
||||
internal readonly struct ProgressReport
|
||||
{
|
||||
public NestPhase Phase { get; init; }
|
||||
public int PlateNumber { get; init; }
|
||||
public List<Part> Parts { get; init; }
|
||||
public Box WorkArea { get; init; }
|
||||
public string Description { get; init; }
|
||||
public bool IsOverallBest { get; init; }
|
||||
}
|
||||
|
||||
public class NestProgress
|
||||
{
|
||||
public NestPhase Phase { get; set; }
|
||||
public int PlateNumber { get; set; }
|
||||
public int BestPartCount { get; set; }
|
||||
public double BestDensity { get; set; }
|
||||
public double NestedWidth { get; set; }
|
||||
public double NestedLength { get; set; }
|
||||
public double NestedArea { get; set; }
|
||||
public List<Part> BestParts { get; set; }
|
||||
|
||||
private List<Part> bestParts;
|
||||
public List<Part> BestParts
|
||||
{
|
||||
get => bestParts;
|
||||
set { bestParts = value; cachedParts = null; }
|
||||
}
|
||||
|
||||
public string Description { get; set; }
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
public bool IsOverallBest { get; set; }
|
||||
|
||||
public int BestPartCount => BestParts?.Count ?? 0;
|
||||
|
||||
private List<Part> cachedParts;
|
||||
private Box cachedBounds;
|
||||
private double cachedPartArea;
|
||||
|
||||
private void EnsureCache()
|
||||
{
|
||||
if (cachedParts == bestParts) return;
|
||||
cachedParts = bestParts;
|
||||
if (bestParts == null || bestParts.Count == 0)
|
||||
{
|
||||
cachedBounds = default;
|
||||
cachedPartArea = 0;
|
||||
return;
|
||||
}
|
||||
cachedBounds = bestParts.GetBoundingBox();
|
||||
cachedPartArea = 0;
|
||||
foreach (var p in bestParts)
|
||||
cachedPartArea += p.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
public double BestDensity
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
var bboxArea = cachedBounds.Width * cachedBounds.Length;
|
||||
return bboxArea > 0 ? cachedPartArea / bboxArea : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedWidth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedBounds.Width;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedLength
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedBounds.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedArea
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedPartArea;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
@@ -75,8 +74,15 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
|
||||
$"NFP: {parts.Count} parts, {result.Iterations} iterations", isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Nfp,
|
||||
PlateNumber = 0,
|
||||
Parts = parts,
|
||||
WorkArea = workArea,
|
||||
Description = $"NFP: {parts.Count} parts, {result.Iterations} iterations",
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -203,44 +209,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// </summary>
|
||||
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
Shape inflated;
|
||||
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left);
|
||||
inflated = offsetEntity as Shape ?? perimeter;
|
||||
}
|
||||
else
|
||||
{
|
||||
inflated = perimeter;
|
||||
}
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
// Normalize: move reference point to origin.
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return polygon;
|
||||
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -320,26 +289,7 @@ namespace OpenNest.Engine.Nfp
|
||||
/// </summary>
|
||||
private static Polygon RotatePolygon(Polygon polygon, double angle)
|
||||
{
|
||||
if (angle.IsEqualTo(0))
|
||||
return polygon;
|
||||
|
||||
var result = new Polygon();
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
result.Vertices.Add(new Vector(
|
||||
v.X * cos - v.Y * sin,
|
||||
v.X * sin + v.Y * cos));
|
||||
}
|
||||
|
||||
// Re-normalize to origin.
|
||||
result.UpdateBounds();
|
||||
var bb = result.BoundingBox;
|
||||
result.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return result;
|
||||
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +277,15 @@ namespace OpenNest.Engine.Nfp
|
||||
private static void ReportBest(IProgress<NestProgress> progress, List<Part> parts,
|
||||
Box workArea, string description)
|
||||
{
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
|
||||
description, isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Nfp,
|
||||
PlateNumber = 0,
|
||||
Parts = parts,
|
||||
WorkArea = workArea,
|
||||
Description = description,
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,6 @@ namespace OpenNest
|
||||
// Compact placed parts toward the origin to close gaps.
|
||||
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
|
||||
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
parts = AutoNester.Optimize(parts, Plate);
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
|
||||
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 };
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace OpenNest.Engine.Strategies
|
||||
private static readonly List<IFillStrategy> strategies = new();
|
||||
private static List<IFillStrategy> sorted;
|
||||
private static HashSet<string> enabledFilter;
|
||||
private static readonly HashSet<string> disabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static FillStrategyRegistry()
|
||||
{
|
||||
@@ -19,9 +20,36 @@ namespace OpenNest.Engine.Strategies
|
||||
}
|
||||
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= (enabledFilter != null
|
||||
? strategies.Where(s => enabledFilter.Contains(s.Name)).OrderBy(s => s.Order).ToList()
|
||||
: strategies.OrderBy(s => s.Order).ToList());
|
||||
sorted ??= FilterStrategies();
|
||||
|
||||
private static List<IFillStrategy> FilterStrategies()
|
||||
{
|
||||
var source = enabledFilter != null
|
||||
? strategies.Where(s => enabledFilter.Contains(s.Name))
|
||||
: strategies.Where(s => !disabled.Contains(s.Name));
|
||||
return source.OrderBy(s => s.Order).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently disables strategies by name. They remain registered
|
||||
/// but are excluded from the default pipeline.
|
||||
/// </summary>
|
||||
public static void Disable(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
disabled.Add(name);
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-enables a previously disabled strategy.
|
||||
/// </summary>
|
||||
public static void Enable(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
disabled.Remove(name);
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts the active strategies to only those whose names are listed.
|
||||
|
||||
@@ -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,34 @@ 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 (best == null || comparer.IsBetter(result, best, workArea))
|
||||
best = result;
|
||||
}
|
||||
|
||||
if (v != null && v.Count > 0)
|
||||
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
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,
|
||||
context.PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||
Phase = NestPhase.Linear,
|
||||
PlateNumber = context.PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
|
||||
@@ -11,7 +11,9 @@ 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 dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
var filler = new PairFiller(context.Plate, comparer, dedup);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Nfp;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -78,17 +77,23 @@ namespace OpenNest
|
||||
// Phase 1: Iterative shrink-fill for multi-quantity items.
|
||||
if (fillItems.Count > 0)
|
||||
{
|
||||
// Pass progress through so the UI shows intermediate results
|
||||
// during the initial BestFitCache computation and fill phases.
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
// Use direction-specific engines: height shrink benefits from
|
||||
// minimizing Y-extent, width shrink from minimizing X-extent.
|
||||
Func<NestItem, Box, List<Part>> heightFillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var inner = new HorizontalRemnantEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> widthFillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new VerticalRemnantEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = IterativeShrinkFiller.Fill(
|
||||
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
|
||||
progress, PlateNumber);
|
||||
fillItems, workArea, heightFillFunc, Plate.PartSpacing, token,
|
||||
progress, PlateNumber, widthFillFunc);
|
||||
|
||||
allParts.AddRange(shrinkResult.Parts);
|
||||
|
||||
@@ -123,9 +128,6 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
||||
allParts = AutoNester.Optimize(allParts, Plate);
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ namespace OpenNest.IO
|
||||
Part1Rotation = r.Part1Rotation,
|
||||
Part2Rotation = r.Part2Rotation,
|
||||
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
||||
StrategyType = r.StrategyType,
|
||||
StrategyIndex = r.StrategyType,
|
||||
TestNumber = r.TestNumber,
|
||||
Spacing = r.CandidateSpacing
|
||||
},
|
||||
|
||||
@@ -214,7 +214,7 @@ namespace OpenNest.IO
|
||||
Part2Rotation = r.Candidate.Part2Rotation,
|
||||
Part2OffsetX = r.Candidate.Part2Offset.X,
|
||||
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
||||
StrategyType = r.Candidate.StrategyType,
|
||||
StrategyType = r.Candidate.StrategyIndex,
|
||||
TestNumber = r.Candidate.TestNumber,
|
||||
CandidateSpacing = r.Candidate.Spacing,
|
||||
RotatedArea = r.RotatedArea,
|
||||
|
||||
@@ -18,7 +18,7 @@ public class AccumulatingProgressTests
|
||||
var accumulating = new AccumulatingProgress(inner, previous);
|
||||
|
||||
var newParts = new List<Part> { TestHelpers.MakePartAt(20, 0, 10) };
|
||||
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
|
||||
accumulating.Report(new NestProgress { BestParts = newParts });
|
||||
|
||||
Assert.NotNull(inner.Last);
|
||||
Assert.Equal(2, inner.Last.BestParts.Count);
|
||||
@@ -32,7 +32,7 @@ public class AccumulatingProgressTests
|
||||
var accumulating = new AccumulatingProgress(inner, new List<Part>());
|
||||
|
||||
var newParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
|
||||
accumulating.Report(new NestProgress { BestParts = newParts });
|
||||
|
||||
Assert.NotNull(inner.Last);
|
||||
Assert.Single(inner.Last.BestParts);
|
||||
|
||||
150
OpenNest.Tests/BestCombinationTests.cs
Normal file
150
OpenNest.Tests/BestCombinationTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class BestCombinationTests
|
||||
{
|
||||
[Fact]
|
||||
public void BothFit_FindsZeroRemnant()
|
||||
{
|
||||
// 100 = 0*30 + 5*20 (algorithm iterates from countLength1=0, finds zero remnant first)
|
||||
var result = BestCombination.FindFrom2(30, 20, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 30.0 + c2 * 20.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength1Fits_ReturnsMaxCount1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 200, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength2Fits_ReturnsMaxCount2()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(200, 10, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(5, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeitherFits_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(100, 200, 50, out var c1, out var c2);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Length1FillsExactly_ZeroRemnant()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(25, 10, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 25.0 + c2 * 10.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixMinimizesRemnant()
|
||||
{
|
||||
// 7 and 3 into 20: best is 2*7 + 2*3 = 20 (zero remnant)
|
||||
var result = BestCombination.FindFrom2(7, 3, 20, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, c1);
|
||||
Assert.Equal(2, c2);
|
||||
Assert.True(c1 * 7 + c2 * 3 <= 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefersLessRemnant_OverMoreOfLength1()
|
||||
{
|
||||
// 6 and 5 into 17:
|
||||
// all length1: 2*6=12, remnant=5 -> actually 2*6+1*5=17 perfect
|
||||
var result = BestCombination.FindFrom2(6, 5, 17, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 17.0 - (c1 * 6.0 + c2 * 5.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualLengths_FillsWithLength1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 10, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1 + c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallLengths_LargeOverall()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(3, 7, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 3.0 + c2 * 7.0;
|
||||
Assert.True(used <= 100);
|
||||
Assert.True(100 - used < 3); // remnant less than smallest piece
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Length2IsBetter_SoleCandidate()
|
||||
{
|
||||
// length1=9, length2=5, overall=10:
|
||||
// length1 alone: 1*9=9 remnant=1
|
||||
// length2 alone: 2*5=10 remnant=0
|
||||
var result = BestCombination.FindFrom2(9, 5, 10, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(2, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FractionalLengths_WorkCorrectly()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(2.5, 3.5, 12, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 2.5 + c2 * 3.5;
|
||||
Assert.True(used <= 12.0 + 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallExactlyOneOfEach()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(40, 60, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, c1);
|
||||
Assert.Equal(1, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallSmallerThanEither_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 20, 5, out var c1, out var c2);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRemnant_StopsEarly()
|
||||
{
|
||||
// 4 and 6 into 24: 0*4+4*6=24 or 3*4+2*6=24 or 6*4+0*6=24
|
||||
// Algorithm iterates from 0 length1 upward, finds zero remnant and breaks
|
||||
var result = BestCombination.FindFrom2(4, 6, 24, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 24.0 - (c1 * 4.0 + c2 * 6.0), 5);
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
28
OpenNest.Tests/NestPhaseExtensionsTests.cs
Normal file
28
OpenNest.Tests/NestPhaseExtensionsTests.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NestPhaseExtensionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(NestPhase.Linear, "Trying rotations...")]
|
||||
[InlineData(NestPhase.RectBestFit, "Trying best fit...")]
|
||||
[InlineData(NestPhase.Pairs, "Trying pairs...")]
|
||||
[InlineData(NestPhase.Nfp, "Trying NFP...")]
|
||||
[InlineData(NestPhase.Extents, "Trying extents...")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void DisplayName_ReturnsDescription(NestPhase phase, string expected)
|
||||
{
|
||||
Assert.Equal(expected, phase.DisplayName());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NestPhase.Linear, "Linear")]
|
||||
[InlineData(NestPhase.RectBestFit, "BestFit")]
|
||||
[InlineData(NestPhase.Pairs, "Pairs")]
|
||||
[InlineData(NestPhase.Nfp, "NFP")]
|
||||
[InlineData(NestPhase.Extents, "Extents")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void ShortName_ReturnsShortLabel(NestPhase phase, string expected)
|
||||
{
|
||||
Assert.Equal(expected, phase.ShortName());
|
||||
}
|
||||
}
|
||||
100
OpenNest.Tests/NestProgressTests.cs
Normal file
100
OpenNest.Tests/NestProgressTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NestProgressTests
|
||||
{
|
||||
[Fact]
|
||||
public void BestPartCount_NullParts_ReturnsZero()
|
||||
{
|
||||
var progress = new NestProgress { BestParts = null };
|
||||
Assert.Equal(0, progress.BestPartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestPartCount_ReturnsBestPartsCount()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(2, progress.BestPartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestDensity_NullParts_ReturnsZero()
|
||||
{
|
||||
var progress = new NestProgress { BestParts = null };
|
||||
Assert.Equal(0, progress.BestDensity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestDensity_MatchesFillScoreFormula()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(5, 0, 5),
|
||||
};
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var progress = new NestProgress { BestParts = parts, ActiveWorkArea = workArea };
|
||||
Assert.Equal(1.0, progress.BestDensity, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedWidth_ReturnsPartsSpan()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedWidth, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedLength_ReturnsPartsSpan()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(0, 10, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedLength, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedArea_ReturnsSumOfPartAreas()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(50, progress.NestedArea, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingBestParts_InvalidatesCache()
|
||||
{
|
||||
var parts1 = new List<Part> { TestHelpers.MakePartAt(0, 0, 5) };
|
||||
var parts2 = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
|
||||
var progress = new NestProgress { BestParts = parts1 };
|
||||
Assert.Equal(1, progress.BestPartCount);
|
||||
Assert.Equal(25, progress.NestedArea, precision: 4);
|
||||
|
||||
progress.BestParts = parts2;
|
||||
Assert.Equal(2, progress.BestPartCount);
|
||||
Assert.Equal(50, progress.NestedArea, precision: 4);
|
||||
}
|
||||
}
|
||||
57
OpenNest.Tests/NfpBestFitIntegrationTests.cs
Normal file
57
OpenNest.Tests/NfpBestFitIntegrationTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NfpBestFitIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindBestFits_ReturnsKeptResults_ForSquare()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
Assert.NotEmpty(results);
|
||||
Assert.NotEmpty(results.Where(r => r.Keep));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_ResultsHaveValidDimensions()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
foreach (var result in results.Where(r => r.Keep))
|
||||
{
|
||||
Assert.True(result.BoundingWidth > 0);
|
||||
Assert.True(result.BoundingHeight > 0);
|
||||
Assert.True(result.RotatedArea > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_LShape_HasBetterUtilization_ThanBoundingBox()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeLShapeDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
var bestUtilization = results
|
||||
.Where(r => r.Keep)
|
||||
.Max(r => r.Utilization);
|
||||
Assert.True(bestUtilization > 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindBestFits_NoOverlaps_InKeptResults()
|
||||
{
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
Assert.All(results.Where(r => r.Keep), r =>
|
||||
Assert.Equal("Valid", r.Reason));
|
||||
}
|
||||
}
|
||||
124
OpenNest.Tests/NfpSlideStrategyTests.cs
Normal file
124
OpenNest.Tests/NfpSlideStrategyTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NfpSlideStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.NotEmpty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_Part1RotationIsAlwaysZero()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, Angle.HalfPI, 1, "90 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_Part2RotationMatchesStrategy()
|
||||
{
|
||||
var rotation = Angle.HalfPI;
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, rotation, 1, "90 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_ProducesReasonableCandidateCount()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||
|
||||
// Convex hull NFP for a square produces vertices + edge samples.
|
||||
// Should have more than just vertices but not thousands.
|
||||
Assert.True(candidates.Count >= 4);
|
||||
Assert.True(candidates.Count < 1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var largeStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
var smallStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.NotNull(largeStepStrategy);
|
||||
Assert.NotNull(smallStepStrategy);
|
||||
var largeStep = largeStepStrategy.GenerateCandidates(drawing, 0.25, 5.0);
|
||||
var smallStep = smallStepStrategy.GenerateCandidates(drawing, 0.25, 0.5);
|
||||
Assert.True(smallStep.Count >= largeStep.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ReturnsNull_ForEmptyDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
var drawing = new Drawing("empty", pgm);
|
||||
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.Null(strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_LShape_ProducesCandidates()
|
||||
{
|
||||
var lshape = TestHelpers.MakeLShapeDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(lshape, 0, 1, "0 deg NFP", 0.25);
|
||||
Assert.NotNull(strategy);
|
||||
var candidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
|
||||
Assert.NotEmpty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateCandidates_At180Degrees_ProducesAtLeastOneNonOverlappingCandidate()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var strategy = NfpSlideStrategy.Create(drawing, System.Math.PI, 1, "180 deg NFP", 1.0);
|
||||
Assert.NotNull(strategy);
|
||||
// Use a large spacing (1.0) and step size.
|
||||
// This should make NFP much larger than the parts.
|
||||
var candidates = strategy.GenerateCandidates(drawing, 1.0, 1.0);
|
||||
|
||||
Assert.NotEmpty(candidates);
|
||||
|
||||
var part1 = Part.CreateAtOrigin(drawing);
|
||||
var validCount = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
|
||||
part2.Location = candidate.Part2Offset;
|
||||
|
||||
// With 1.0 spacing, parts should NOT intersect even with tiny precision errors.
|
||||
if (!part1.Intersects(part2, out _))
|
||||
validCount++;
|
||||
}
|
||||
|
||||
Assert.True(validCount > 0, $"No non-overlapping candidates found out of {candidates.Count} total. Candidate 0 offset: {candidates[0].Part2Offset}");
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,15 @@ public class PairFillerTests
|
||||
return new Drawing("rect", pgm);
|
||||
}
|
||||
|
||||
private static Plate MakePlate(double width, double length, double spacing = 0.5)
|
||||
{
|
||||
return new Plate { Size = new Size(width, length), PartSpacing = spacing };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ReturnsPartsForSimpleDrawing()
|
||||
{
|
||||
var plateSize = new Size(120, 60);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(120, 60));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
@@ -33,8 +37,7 @@ public class PairFillerTests
|
||||
[Fact]
|
||||
public void Fill_EmptyResult_WhenPartTooLarge()
|
||||
{
|
||||
var plateSize = new Size(10, 10);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(10, 10));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
|
||||
var workArea = new Box(0, 0, 10, 10);
|
||||
|
||||
@@ -50,8 +53,7 @@ public class PairFillerTests
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var plateSize = new Size(120, 60);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(120, 60));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
|
||||
88
OpenNest.Tests/PolygonHelperTests.cs
Normal file
88
OpenNest.Tests/PolygonHelperTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PolygonHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
Assert.True(result.Polygon.Vertices.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing(10);
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
// The offset polygon should differ in size from the non-offset polygon.
|
||||
// OffsetSide.Left offsets outward or inward depending on winding,
|
||||
// but either way the result must be a different size.
|
||||
Assert.True(
|
||||
System.Math.Abs(withSpacing.Polygon.BoundingBox.Width - noSpacing.Polygon.BoundingBox.Width) > 0.5,
|
||||
$"Expected polygon width to differ by >0.5 with 1mm spacing. " +
|
||||
$"No-spacing width: {noSpacing.Polygon.BoundingBox.Width:F3}, " +
|
||||
$"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
var drawing = new Drawing("empty", pgm);
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.Null(result.Polygon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
|
||||
{
|
||||
var drawing = TestHelpers.MakeSquareDrawing();
|
||||
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
Assert.NotNull(result.Polygon);
|
||||
Assert.True(System.Math.Abs(result.Correction.X) < 1);
|
||||
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_AtZero_ReturnsSamePolygon()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 0));
|
||||
polygon.Vertices.Add(new Vector(10, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
|
||||
Assert.Same(polygon, rotated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatePolygon_At90Degrees_SwapsDimensions()
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.Add(new Vector(0, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 0));
|
||||
polygon.Vertices.Add(new Vector(20, 10));
|
||||
polygon.Vertices.Add(new Vector(0, 10));
|
||||
polygon.UpdateBounds();
|
||||
|
||||
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
|
||||
rotated.UpdateBounds();
|
||||
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
|
||||
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
|
||||
}
|
||||
}
|
||||
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})");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
@@ -24,8 +25,8 @@ public class FillPipelineTests
|
||||
|
||||
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(engine.PhaseResults.Count >= 4,
|
||||
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||
Assert.True(engine.PhaseResults.Count >= FillStrategyRegistry.Strategies.Count,
|
||||
$"Expected phase results from all active strategies, got {engine.PhaseResults.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,7 +42,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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,28 @@ internal static class TestHelpers
|
||||
plate.Parts.Add(p);
|
||||
return plate;
|
||||
}
|
||||
|
||||
public static Drawing MakeSquareDrawing(double size = 10)
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("square", pgm);
|
||||
}
|
||||
|
||||
public static Drawing MakeLShapeDrawing()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("lshape", pgm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace OpenNest.Controls
|
||||
metadataLines = new[]
|
||||
{
|
||||
string.Format("#{0} {1:F1}x{2:F1} Area={3:F1}",
|
||||
rank, result.BoundingWidth, result.BoundingHeight, result.RotatedArea),
|
||||
rank, result.BoundingHeight, result.BoundingWidth, result.RotatedArea),
|
||||
string.Format("Util={0:P1} Rot={1:F1}\u00b0",
|
||||
result.Utilization,
|
||||
Angle.ToDegrees(result.OptimalRotation)),
|
||||
|
||||
@@ -59,16 +59,6 @@ namespace OpenNest.Controls
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDisplayName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Nfp: return "NFP";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
@@ -134,7 +124,7 @@ namespace OpenNest.Controls
|
||||
}
|
||||
|
||||
// Label
|
||||
var label = GetDisplayName(phase);
|
||||
var label = phase.ShortName();
|
||||
var font = isVisited || isActive ? BoldLabelFont : LabelFont;
|
||||
var brush = isVisited || isActive ? activeTextBrush : pendingTextBrush;
|
||||
var labelSize = g.MeasureString(label, font);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Nfp;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -961,7 +960,7 @@ namespace OpenNest.Controls
|
||||
{
|
||||
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
|
||||
Compactor.Settle(result, workArea, spacing);
|
||||
return AutoNester.Optimize(result, workArea, spacing);
|
||||
return result;
|
||||
});
|
||||
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
@@ -1099,23 +1098,16 @@ namespace OpenNest.Controls
|
||||
var bounds = parts.GetBoundingBox();
|
||||
var center = bounds.Center;
|
||||
var anchor = bounds.Location;
|
||||
var rotatedPrograms = new HashSet<Program>();
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
for (var i = 0; i < SelectedParts.Count; ++i)
|
||||
{
|
||||
var part = SelectedParts[i];
|
||||
var basePart = part.BasePart;
|
||||
|
||||
if (rotatedPrograms.Add(basePart.Program))
|
||||
basePart.Program.Rotate(angle);
|
||||
|
||||
part.Location = part.Location.Rotate(angle, center);
|
||||
basePart.UpdateBounds();
|
||||
part.BasePart.Rotate(angle, center);
|
||||
}
|
||||
|
||||
var diff = anchor - parts.GetBoundingBox().Location;
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
for (var i = 0; i < SelectedParts.Count; ++i)
|
||||
SelectedParts[i].Offset(diff);
|
||||
}
|
||||
|
||||
|
||||
@@ -291,8 +291,8 @@ namespace OpenNest.Forms
|
||||
cell.PartColor = partColor;
|
||||
cell.Dock = DockStyle.Fill;
|
||||
cell.Plate.Size = new Geometry.Size(
|
||||
result.BoundingWidth,
|
||||
result.BoundingHeight);
|
||||
result.BoundingHeight,
|
||||
result.BoundingWidth);
|
||||
|
||||
var parts = result.BuildParts(drawing);
|
||||
|
||||
|
||||
14
OpenNest/Forms/MainForm.Designer.cs
generated
14
OpenNest/Forms/MainForm.Designer.cs
generated
@@ -131,6 +131,7 @@
|
||||
plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
plateUtilStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
selectionStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
toolStrip1 = new System.Windows.Forms.ToolStrip();
|
||||
@@ -829,7 +830,7 @@
|
||||
//
|
||||
// statusStrip1
|
||||
//
|
||||
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, gpuStatusLabel });
|
||||
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, plateUtilStatusLabel, gpuStatusLabel });
|
||||
statusStrip1.Location = new System.Drawing.Point(0, 630);
|
||||
statusStrip1.Name = "statusStrip1";
|
||||
statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
|
||||
@@ -889,7 +890,15 @@
|
||||
plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
|
||||
plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19);
|
||||
plateQtyStatusLabel.Text = "Qty : 0";
|
||||
//
|
||||
//
|
||||
// plateUtilStatusLabel
|
||||
//
|
||||
plateUtilStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
|
||||
plateUtilStatusLabel.Name = "plateUtilStatusLabel";
|
||||
plateUtilStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
|
||||
plateUtilStatusLabel.Size = new System.Drawing.Size(75, 19);
|
||||
plateUtilStatusLabel.Text = "Util : 0.0%";
|
||||
//
|
||||
// gpuStatusLabel
|
||||
//
|
||||
gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
|
||||
@@ -1128,6 +1137,7 @@
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem10;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
|
||||
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
|
||||
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
|
||||
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
|
||||
|
||||
@@ -204,6 +204,7 @@ namespace OpenNest.Forms
|
||||
plateIndexStatusLabel.Text = string.Empty;
|
||||
plateSizeStatusLabel.Text = string.Empty;
|
||||
plateQtyStatusLabel.Text = string.Empty;
|
||||
plateUtilStatusLabel.Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,6 +220,10 @@ namespace OpenNest.Forms
|
||||
plateQtyStatusLabel.Text = string.Format(
|
||||
"Qty: {0}",
|
||||
activeForm.PlateView.Plate.Quantity);
|
||||
|
||||
plateUtilStatusLabel.Text = string.Format(
|
||||
"Util: {0:P1}",
|
||||
activeForm.PlateView.Plate.Utilization());
|
||||
}
|
||||
|
||||
private void UpdateSelectionStatus()
|
||||
@@ -342,6 +347,8 @@ namespace OpenNest.Forms
|
||||
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
|
||||
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
|
||||
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
|
||||
activeForm.PlateView.PartAdded -= PlateView_PartAdded;
|
||||
activeForm.PlateView.PartRemoved -= PlateView_PartRemoved;
|
||||
}
|
||||
|
||||
// If nesting is in progress and the active form changed, cancel nesting
|
||||
@@ -367,6 +374,8 @@ namespace OpenNest.Forms
|
||||
UpdateSelectionStatus();
|
||||
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
|
||||
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
|
||||
activeForm.PlateView.PartAdded += PlateView_PartAdded;
|
||||
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
|
||||
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
|
||||
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
|
||||
statusLabel1.Text = activeForm.PlateView.Status;
|
||||
@@ -1215,6 +1224,9 @@ namespace OpenNest.Forms
|
||||
|
||||
#region PlateView Events
|
||||
|
||||
private void PlateView_PartAdded(object sender, ItemAddedEventArgs<Part> e) => UpdatePlateStatus();
|
||||
private void PlateView_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => UpdatePlateStatus();
|
||||
|
||||
private void PlateView_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
UpdateLocationStatus();
|
||||
|
||||
64
OpenNest/Forms/NestProgressForm.Designer.cs
generated
64
OpenNest/Forms/NestProgressForm.Designer.cs
generated
@@ -85,13 +85,13 @@ namespace OpenNest.Forms
|
||||
resultsTable.Controls.Add(nestedAreaLabel, 0, 2);
|
||||
resultsTable.Controls.Add(nestedAreaValue, 1, 2);
|
||||
resultsTable.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
resultsTable.Location = new System.Drawing.Point(14, 29);
|
||||
resultsTable.Location = new System.Drawing.Point(14, 33);
|
||||
resultsTable.Name = "resultsTable";
|
||||
resultsTable.RowCount = 3;
|
||||
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
resultsTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
resultsTable.Size = new System.Drawing.Size(422, 57);
|
||||
resultsTable.Size = new System.Drawing.Size(422, 69);
|
||||
resultsTable.TabIndex = 1;
|
||||
//
|
||||
// partsLabel
|
||||
@@ -102,7 +102,7 @@ namespace OpenNest.Forms
|
||||
partsLabel.Location = new System.Drawing.Point(0, 3);
|
||||
partsLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
partsLabel.Name = "partsLabel";
|
||||
partsLabel.Size = new System.Drawing.Size(36, 13);
|
||||
partsLabel.Size = new System.Drawing.Size(43, 17);
|
||||
partsLabel.TabIndex = 0;
|
||||
partsLabel.Text = "Parts:";
|
||||
//
|
||||
@@ -110,10 +110,10 @@ namespace OpenNest.Forms
|
||||
//
|
||||
partsValue.AutoSize = true;
|
||||
partsValue.Font = new System.Drawing.Font("Consolas", 9.75F);
|
||||
partsValue.Location = new System.Drawing.Point(80, 3);
|
||||
partsValue.Location = new System.Drawing.Point(90, 3);
|
||||
partsValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
|
||||
partsValue.Name = "partsValue";
|
||||
partsValue.Size = new System.Drawing.Size(13, 13);
|
||||
partsValue.Size = new System.Drawing.Size(13, 15);
|
||||
partsValue.TabIndex = 1;
|
||||
partsValue.Text = "<22>";
|
||||
//
|
||||
@@ -122,10 +122,10 @@ namespace OpenNest.Forms
|
||||
densityLabel.AutoSize = true;
|
||||
densityLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
|
||||
densityLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
|
||||
densityLabel.Location = new System.Drawing.Point(0, 22);
|
||||
densityLabel.Location = new System.Drawing.Point(0, 26);
|
||||
densityLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
densityLabel.Name = "densityLabel";
|
||||
densityLabel.Size = new System.Drawing.Size(49, 13);
|
||||
densityLabel.Size = new System.Drawing.Size(59, 17);
|
||||
densityLabel.TabIndex = 2;
|
||||
densityLabel.Text = "Density:";
|
||||
//
|
||||
@@ -134,10 +134,10 @@ namespace OpenNest.Forms
|
||||
densityPanel.AutoSize = true;
|
||||
densityPanel.Controls.Add(densityValue);
|
||||
densityPanel.Controls.Add(densityBar);
|
||||
densityPanel.Location = new System.Drawing.Point(80, 19);
|
||||
densityPanel.Location = new System.Drawing.Point(90, 23);
|
||||
densityPanel.Margin = new System.Windows.Forms.Padding(0);
|
||||
densityPanel.Name = "densityPanel";
|
||||
densityPanel.Size = new System.Drawing.Size(311, 19);
|
||||
densityPanel.Size = new System.Drawing.Size(262, 21);
|
||||
densityPanel.TabIndex = 3;
|
||||
densityPanel.WrapContents = false;
|
||||
//
|
||||
@@ -148,7 +148,7 @@ namespace OpenNest.Forms
|
||||
densityValue.Location = new System.Drawing.Point(0, 3);
|
||||
densityValue.Margin = new System.Windows.Forms.Padding(0, 3, 8, 3);
|
||||
densityValue.Name = "densityValue";
|
||||
densityValue.Size = new System.Drawing.Size(13, 13);
|
||||
densityValue.Size = new System.Drawing.Size(13, 15);
|
||||
densityValue.TabIndex = 0;
|
||||
densityValue.Text = "<22>";
|
||||
//
|
||||
@@ -157,7 +157,7 @@ namespace OpenNest.Forms
|
||||
densityBar.Location = new System.Drawing.Point(21, 5);
|
||||
densityBar.Margin = new System.Windows.Forms.Padding(0, 5, 0, 0);
|
||||
densityBar.Name = "densityBar";
|
||||
densityBar.Size = new System.Drawing.Size(290, 8);
|
||||
densityBar.Size = new System.Drawing.Size(241, 8);
|
||||
densityBar.TabIndex = 1;
|
||||
densityBar.Value = 0D;
|
||||
//
|
||||
@@ -166,10 +166,10 @@ namespace OpenNest.Forms
|
||||
nestedAreaLabel.AutoSize = true;
|
||||
nestedAreaLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
|
||||
nestedAreaLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
|
||||
nestedAreaLabel.Location = new System.Drawing.Point(0, 41);
|
||||
nestedAreaLabel.Location = new System.Drawing.Point(0, 49);
|
||||
nestedAreaLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
nestedAreaLabel.Name = "nestedAreaLabel";
|
||||
nestedAreaLabel.Size = new System.Drawing.Size(47, 13);
|
||||
nestedAreaLabel.Size = new System.Drawing.Size(55, 17);
|
||||
nestedAreaLabel.TabIndex = 4;
|
||||
nestedAreaLabel.Text = "Nested:";
|
||||
//
|
||||
@@ -177,10 +177,10 @@ namespace OpenNest.Forms
|
||||
//
|
||||
nestedAreaValue.AutoSize = true;
|
||||
nestedAreaValue.Font = new System.Drawing.Font("Consolas", 9.75F);
|
||||
nestedAreaValue.Location = new System.Drawing.Point(80, 41);
|
||||
nestedAreaValue.Location = new System.Drawing.Point(90, 49);
|
||||
nestedAreaValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
|
||||
nestedAreaValue.Name = "nestedAreaValue";
|
||||
nestedAreaValue.Size = new System.Drawing.Size(13, 13);
|
||||
nestedAreaValue.Size = new System.Drawing.Size(13, 15);
|
||||
nestedAreaValue.TabIndex = 5;
|
||||
nestedAreaValue.Text = "<22>";
|
||||
//
|
||||
@@ -193,7 +193,7 @@ namespace OpenNest.Forms
|
||||
resultsHeader.Location = new System.Drawing.Point(14, 10);
|
||||
resultsHeader.Name = "resultsHeader";
|
||||
resultsHeader.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4);
|
||||
resultsHeader.Size = new System.Drawing.Size(56, 19);
|
||||
resultsHeader.Size = new System.Drawing.Size(65, 23);
|
||||
resultsHeader.TabIndex = 0;
|
||||
resultsHeader.Text = "RESULTS";
|
||||
//
|
||||
@@ -203,7 +203,7 @@ namespace OpenNest.Forms
|
||||
statusPanel.Controls.Add(statusTable);
|
||||
statusPanel.Controls.Add(statusHeader);
|
||||
statusPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
statusPanel.Location = new System.Drawing.Point(0, 165);
|
||||
statusPanel.Location = new System.Drawing.Point(0, 180);
|
||||
statusPanel.Name = "statusPanel";
|
||||
statusPanel.Padding = new System.Windows.Forms.Padding(14, 10, 14, 10);
|
||||
statusPanel.Size = new System.Drawing.Size(450, 115);
|
||||
@@ -222,13 +222,13 @@ namespace OpenNest.Forms
|
||||
statusTable.Controls.Add(descriptionLabel, 0, 2);
|
||||
statusTable.Controls.Add(descriptionValue, 1, 2);
|
||||
statusTable.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
statusTable.Location = new System.Drawing.Point(14, 29);
|
||||
statusTable.Location = new System.Drawing.Point(14, 33);
|
||||
statusTable.Name = "statusTable";
|
||||
statusTable.RowCount = 3;
|
||||
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
statusTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
statusTable.Size = new System.Drawing.Size(422, 57);
|
||||
statusTable.Size = new System.Drawing.Size(422, 69);
|
||||
statusTable.TabIndex = 1;
|
||||
//
|
||||
// plateLabel
|
||||
@@ -239,7 +239,7 @@ namespace OpenNest.Forms
|
||||
plateLabel.Location = new System.Drawing.Point(0, 3);
|
||||
plateLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
plateLabel.Name = "plateLabel";
|
||||
plateLabel.Size = new System.Drawing.Size(36, 13);
|
||||
plateLabel.Size = new System.Drawing.Size(43, 17);
|
||||
plateLabel.TabIndex = 0;
|
||||
plateLabel.Text = "Plate:";
|
||||
//
|
||||
@@ -247,10 +247,10 @@ namespace OpenNest.Forms
|
||||
//
|
||||
plateValue.AutoSize = true;
|
||||
plateValue.Font = new System.Drawing.Font("Consolas", 9.75F);
|
||||
plateValue.Location = new System.Drawing.Point(80, 3);
|
||||
plateValue.Location = new System.Drawing.Point(90, 3);
|
||||
plateValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
|
||||
plateValue.Name = "plateValue";
|
||||
plateValue.Size = new System.Drawing.Size(13, 13);
|
||||
plateValue.Size = new System.Drawing.Size(13, 15);
|
||||
plateValue.TabIndex = 1;
|
||||
plateValue.Text = "<22>";
|
||||
//
|
||||
@@ -259,10 +259,10 @@ namespace OpenNest.Forms
|
||||
elapsedLabel.AutoSize = true;
|
||||
elapsedLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
|
||||
elapsedLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
|
||||
elapsedLabel.Location = new System.Drawing.Point(0, 22);
|
||||
elapsedLabel.Location = new System.Drawing.Point(0, 26);
|
||||
elapsedLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
elapsedLabel.Name = "elapsedLabel";
|
||||
elapsedLabel.Size = new System.Drawing.Size(50, 13);
|
||||
elapsedLabel.Size = new System.Drawing.Size(59, 17);
|
||||
elapsedLabel.TabIndex = 2;
|
||||
elapsedLabel.Text = "Elapsed:";
|
||||
//
|
||||
@@ -270,10 +270,10 @@ namespace OpenNest.Forms
|
||||
//
|
||||
elapsedValue.AutoSize = true;
|
||||
elapsedValue.Font = new System.Drawing.Font("Consolas", 9.75F);
|
||||
elapsedValue.Location = new System.Drawing.Point(80, 22);
|
||||
elapsedValue.Location = new System.Drawing.Point(90, 26);
|
||||
elapsedValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
|
||||
elapsedValue.Name = "elapsedValue";
|
||||
elapsedValue.Size = new System.Drawing.Size(31, 13);
|
||||
elapsedValue.Size = new System.Drawing.Size(35, 15);
|
||||
elapsedValue.TabIndex = 3;
|
||||
elapsedValue.Text = "0:00";
|
||||
//
|
||||
@@ -282,10 +282,10 @@ namespace OpenNest.Forms
|
||||
descriptionLabel.AutoSize = true;
|
||||
descriptionLabel.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold);
|
||||
descriptionLabel.ForeColor = System.Drawing.Color.FromArgb(51, 51, 51);
|
||||
descriptionLabel.Location = new System.Drawing.Point(0, 41);
|
||||
descriptionLabel.Location = new System.Drawing.Point(0, 49);
|
||||
descriptionLabel.Margin = new System.Windows.Forms.Padding(0, 3, 5, 3);
|
||||
descriptionLabel.Name = "descriptionLabel";
|
||||
descriptionLabel.Size = new System.Drawing.Size(40, 13);
|
||||
descriptionLabel.Size = new System.Drawing.Size(49, 17);
|
||||
descriptionLabel.TabIndex = 4;
|
||||
descriptionLabel.Text = "Detail:";
|
||||
//
|
||||
@@ -293,10 +293,10 @@ namespace OpenNest.Forms
|
||||
//
|
||||
descriptionValue.AutoSize = true;
|
||||
descriptionValue.Font = new System.Drawing.Font("Segoe UI", 9.75F);
|
||||
descriptionValue.Location = new System.Drawing.Point(80, 41);
|
||||
descriptionValue.Location = new System.Drawing.Point(90, 49);
|
||||
descriptionValue.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
|
||||
descriptionValue.Name = "descriptionValue";
|
||||
descriptionValue.Size = new System.Drawing.Size(18, 13);
|
||||
descriptionValue.Size = new System.Drawing.Size(20, 17);
|
||||
descriptionValue.TabIndex = 5;
|
||||
descriptionValue.Text = "<22>";
|
||||
//
|
||||
@@ -309,7 +309,7 @@ namespace OpenNest.Forms
|
||||
statusHeader.Location = new System.Drawing.Point(14, 10);
|
||||
statusHeader.Name = "statusHeader";
|
||||
statusHeader.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4);
|
||||
statusHeader.Size = new System.Drawing.Size(50, 19);
|
||||
statusHeader.Size = new System.Drawing.Size(59, 23);
|
||||
statusHeader.TabIndex = 0;
|
||||
statusHeader.Text = "STATUS";
|
||||
//
|
||||
@@ -320,7 +320,7 @@ namespace OpenNest.Forms
|
||||
buttonPanel.Controls.Add(acceptButton);
|
||||
buttonPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
|
||||
buttonPanel.Location = new System.Drawing.Point(0, 265);
|
||||
buttonPanel.Location = new System.Drawing.Point(0, 295);
|
||||
buttonPanel.Name = "buttonPanel";
|
||||
buttonPanel.Padding = new System.Windows.Forms.Padding(9, 6, 9, 6);
|
||||
buttonPanel.Size = new System.Drawing.Size(450, 45);
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace OpenNest.Forms
|
||||
|
||||
descriptionValue.Text = !string.IsNullOrEmpty(progress.Description)
|
||||
? progress.Description
|
||||
: FormatPhase(progress.Phase);
|
||||
: progress.Phase.DisplayName();
|
||||
}
|
||||
|
||||
public void ShowCompleted()
|
||||
@@ -196,18 +196,5 @@ namespace OpenNest.Forms
|
||||
return DensityMidColor;
|
||||
return DensityHighColor;
|
||||
}
|
||||
|
||||
private static string FormatPhase(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Linear: return "Trying rotations...";
|
||||
case NestPhase.RectBestFit: return "Trying best fit...";
|
||||
case NestPhase.Pairs: return "Trying pairs...";
|
||||
case NestPhase.Extents: return "Trying extents...";
|
||||
case NestPhase.Nfp: return "Trying NFP...";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
# Best-Fit Viewer Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a BestFitViewerForm that shows all pair candidates in a dense 5-column grid with metadata overlay, similar to PEP's best-fit viewer.
|
||||
|
||||
**Architecture:** A modal `Form` with a scrollable `TableLayoutPanel` (5 columns). Each cell is a read-only `PlateView` with the pair's two parts placed on it. Metadata is painted as overlay text on each cell. Dropped candidates use a different background color. Invoked from Tools menu when a drawing and plate are available.
|
||||
|
||||
**Tech Stack:** WinForms, `BestFitFinder` from `OpenNest.Engine.BestFit`, `PlateView` control.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract BuildPairParts to a static helper
|
||||
|
||||
`NestEngine.BuildPairParts` is private and contains the pair-building logic we need. Extract it to a public static method so both `NestEngine` and the new form can use it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
|
||||
**Step 1: Make BuildPairParts internal static**
|
||||
|
||||
In `OpenNest.Engine/NestEngine.cs`, change the method signature from private instance to internal static. It doesn't use any instance state — only `BestFitResult` and `Drawing` parameters.
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
private List<Part> BuildPairParts(BestFitResult bestFit, Drawing drawing)
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
internal static List<Part> BuildPairParts(BestFitResult bestFit, Drawing drawing)
|
||||
```
|
||||
|
||||
**Step 2: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "refactor: make BuildPairParts internal static for reuse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create BestFitViewerForm
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest/Forms/BestFitViewerForm.cs`
|
||||
- Create: `OpenNest/Forms/BestFitViewerForm.Designer.cs`
|
||||
|
||||
**Step 1: Create the Designer file**
|
||||
|
||||
Create `OpenNest/Forms/BestFitViewerForm.Designer.cs` with a `TableLayoutPanel` (5 columns, auto-scroll, dock-fill) inside the form. Form should be sizable, start centered on parent, ~1200x800 default size, title "Best-Fit Viewer".
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class BestFitViewerForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
components.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.gridPanel = new System.Windows.Forms.TableLayoutPanel();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// gridPanel
|
||||
//
|
||||
this.gridPanel.AutoScroll = true;
|
||||
this.gridPanel.ColumnCount = 5;
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.gridPanel.Location = new System.Drawing.Point(0, 0);
|
||||
this.gridPanel.Name = "gridPanel";
|
||||
this.gridPanel.RowCount = 1;
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
this.gridPanel.Size = new System.Drawing.Size(1200, 800);
|
||||
this.gridPanel.TabIndex = 0;
|
||||
//
|
||||
// BestFitViewerForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(1200, 800);
|
||||
this.Controls.Add(this.gridPanel);
|
||||
this.KeyPreview = true;
|
||||
this.Name = "BestFitViewerForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Best-Fit Viewer";
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.TableLayoutPanel gridPanel;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create the code-behind file**
|
||||
|
||||
Create `OpenNest/Forms/BestFitViewerForm.cs`. The constructor takes a `Drawing` and a `Plate`. It calls `BestFitFinder.FindBestFits()` to get all candidates, then for each result:
|
||||
1. Creates a `PlateView` configured read-only (no pan/zoom/select/origin, no plate outline)
|
||||
2. Sizes the PlateView's plate to the pair bounding box
|
||||
3. Builds pair parts via `NestEngine.BuildPairParts()` and adds them to the plate
|
||||
4. Sets background color based on `Keep` (kept = default, dropped = maroon)
|
||||
5. Subscribes to `Paint` to overlay metadata text
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
public partial class BestFitViewerForm : Form
|
||||
{
|
||||
private static readonly Color KeptColor = Color.FromArgb(0, 0, 100);
|
||||
private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0);
|
||||
|
||||
public BestFitViewerForm(Drawing drawing, Plate plate)
|
||||
{
|
||||
InitializeComponent();
|
||||
PopulateGrid(drawing, plate);
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
if (keyData == Keys.Escape)
|
||||
{
|
||||
Close();
|
||||
return true;
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
private void PopulateGrid(Drawing drawing, Plate plate)
|
||||
{
|
||||
var finder = new BestFitFinder(plate.Size.Width, plate.Size.Height);
|
||||
var results = finder.FindBestFits(drawing, plate.PartSpacing);
|
||||
|
||||
var rows = (int)System.Math.Ceiling(results.Count / 5.0);
|
||||
gridPanel.RowCount = rows;
|
||||
gridPanel.RowStyles.Clear();
|
||||
|
||||
for (var i = 0; i < rows; i++)
|
||||
gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, 200));
|
||||
|
||||
for (var i = 0; i < results.Count; i++)
|
||||
{
|
||||
var result = results[i];
|
||||
var view = CreateCellView(result, drawing);
|
||||
gridPanel.Controls.Add(view, i % 5, i / 5);
|
||||
}
|
||||
}
|
||||
|
||||
private PlateView CreateCellView(BestFitResult result, Drawing drawing)
|
||||
{
|
||||
var bgColor = result.Keep ? KeptColor : DroppedColor;
|
||||
|
||||
var colorScheme = new ColorScheme
|
||||
{
|
||||
BackgroundColor = bgColor,
|
||||
LayoutOutlineColor = bgColor,
|
||||
LayoutFillColor = bgColor,
|
||||
BoundingBoxColor = bgColor,
|
||||
RapidColor = Color.DodgerBlue,
|
||||
OriginColor = bgColor,
|
||||
EdgeSpacingColor = bgColor
|
||||
};
|
||||
|
||||
var view = new PlateView(colorScheme);
|
||||
view.DrawOrigin = false;
|
||||
view.DrawBounds = false;
|
||||
view.AllowPan = false;
|
||||
view.AllowSelect = false;
|
||||
view.AllowZoom = false;
|
||||
view.AllowDrop = false;
|
||||
view.Dock = DockStyle.Fill;
|
||||
view.Plate.Size = new Geometry.Size(
|
||||
result.BoundingWidth,
|
||||
result.BoundingHeight);
|
||||
|
||||
var parts = NestEngine.BuildPairParts(result, drawing);
|
||||
|
||||
foreach (var part in parts)
|
||||
view.Plate.Parts.Add(part);
|
||||
|
||||
view.Paint += (sender, e) =>
|
||||
{
|
||||
PaintMetadata(e.Graphics, view, result);
|
||||
};
|
||||
|
||||
view.HandleCreated += (sender, e) =>
|
||||
{
|
||||
view.ZoomToFit(true);
|
||||
};
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
private void PaintMetadata(Graphics g, PlateView view, BestFitResult result)
|
||||
{
|
||||
var font = view.Font;
|
||||
var brush = Brushes.White;
|
||||
var y = 2f;
|
||||
var lineHeight = font.GetHeight(g) + 1;
|
||||
|
||||
var lines = new[]
|
||||
{
|
||||
string.Format("RotatedArea={0:F4}", result.RotatedArea),
|
||||
string.Format("{0:F4}x{1:F4}={2:F4}",
|
||||
result.BoundingWidth, result.BoundingHeight, result.RotatedArea),
|
||||
string.Format("Why={0}", result.Keep ? "0" : result.Reason),
|
||||
string.Format("Type={0} Test={1} Spacing={2}",
|
||||
result.Candidate.StrategyType,
|
||||
result.Candidate.TestNumber,
|
||||
result.Candidate.Spacing),
|
||||
string.Format("Util={0:P0} Rot={1:F1}°",
|
||||
result.Utilization,
|
||||
Angle.ToDegrees(result.OptimalRotation))
|
||||
};
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
g.DrawString(line, font, brush, 2, y);
|
||||
y += lineHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/BestFitViewerForm.cs OpenNest/Forms/BestFitViewerForm.Designer.cs
|
||||
git commit -m "feat: add BestFitViewerForm with pair candidate grid"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add menu item to MainForm
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.Designer.cs`
|
||||
- Modify: `OpenNest/Forms/MainForm.cs`
|
||||
|
||||
**Step 1: Add the menu item field and wire it up in Designer**
|
||||
|
||||
In `MainForm.Designer.cs`:
|
||||
|
||||
1. Add field declaration near the other `mnuTools*` fields (~line 1198):
|
||||
```csharp
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
|
||||
```
|
||||
|
||||
2. Add instantiation in `InitializeComponent()` near other mnuTools instantiations (~line 62):
|
||||
```csharp
|
||||
this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
|
||||
```
|
||||
|
||||
3. Add to the Tools menu `DropDownItems` array (after `mnuToolsMeasureArea`, ~line 413-420). Insert `mnuToolsBestFitViewer` before the `toolStripMenuItem14` separator:
|
||||
```csharp
|
||||
this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
this.mnuToolsMeasureArea,
|
||||
this.mnuToolsBestFitViewer,
|
||||
this.mnuToolsAlign,
|
||||
this.toolStripMenuItem14,
|
||||
this.mnuSetOffsetIncrement,
|
||||
this.mnuSetRotationIncrement,
|
||||
this.toolStripMenuItem15,
|
||||
this.mnuToolsOptions});
|
||||
```
|
||||
|
||||
4. Add menu item configuration after the `mnuToolsMeasureArea` block (~line 431):
|
||||
```csharp
|
||||
//
|
||||
// mnuToolsBestFitViewer
|
||||
//
|
||||
this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer";
|
||||
this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22);
|
||||
this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer";
|
||||
this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click);
|
||||
```
|
||||
|
||||
**Step 2: Add the click handler in MainForm.cs**
|
||||
|
||||
Add a method to `MainForm.cs` that opens the form. It needs the active `EditNestForm` to get the current plate and a selected drawing. If no drawing is available from the selected plate's parts, show a message.
|
||||
|
||||
```csharp
|
||||
private void BestFitViewer_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
var plate = activeForm.PlateView.Plate;
|
||||
var drawing = activeForm.Nest.Drawings.Count > 0
|
||||
? activeForm.Nest.Drawings[0]
|
||||
: null;
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
MessageBox.Show("No drawings available.", "Best-Fit Viewer",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
using (var form = new BestFitViewerForm(drawing, plate))
|
||||
{
|
||||
form.ShowDialog(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.Designer.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat: add Best-Fit Viewer menu item under Tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manual smoke test
|
||||
|
||||
**Step 1: Run the application**
|
||||
|
||||
Run: `dotnet run --project OpenNest`
|
||||
|
||||
**Step 2: Test the flow**
|
||||
|
||||
1. Open or create a nest file
|
||||
2. Import a DXF drawing
|
||||
3. Go to Tools > Best-Fit Viewer
|
||||
4. Verify the grid appears with pair candidates
|
||||
5. Verify kept candidates have dark blue background
|
||||
6. Verify dropped candidates have dark red/maroon background
|
||||
7. Verify metadata text is readable on each cell
|
||||
8. Verify ESC closes the dialog
|
||||
9. Verify scroll works when many results exist
|
||||
|
||||
**Step 3: Fix any visual issues**
|
||||
|
||||
Adjust cell heights, font sizes, or zoom-to-fit timing if needed.
|
||||
|
||||
**Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: polish BestFitViewerForm layout and appearance"
|
||||
```
|
||||
@@ -1,963 +0,0 @@
|
||||
# Best-Fit Pair Finding Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a pair-finding engine that arranges two copies of a part in the tightest configuration, then tiles that pair across a plate.
|
||||
|
||||
**Architecture:** Strategy pattern where `RotationSlideStrategy` instances (parameterized by angle) generate candidate pair configurations by sliding one part against another using existing raycast collision. A `PairEvaluator` scores candidates by bounding area, a `BestFitFilter` prunes bad fits, and a `TileEvaluator` simulates tiling the best pairs onto a plate.
|
||||
|
||||
**Tech Stack:** .NET Framework 4.8, C# 7.3, OpenNest.Engine (class library referencing OpenNest.Core)
|
||||
|
||||
---
|
||||
|
||||
## Important Context
|
||||
|
||||
### Codebase Conventions
|
||||
- **All angles are in radians** — use `Angle.ToRadians()`, `Angle.HalfPI`, `Angle.TwoPI`
|
||||
- **Always use `var`** instead of explicit types
|
||||
- **`OpenNest.Math` shadows `System.Math`** — use `System.Math` fully qualified
|
||||
- **Legacy `.csproj`** — every new `.cs` file must be added to `OpenNest.Engine.csproj` `<Compile>` items
|
||||
- **No test project exists** — skip TDD steps, verify by building
|
||||
|
||||
### Key Existing Types
|
||||
- `Vector` (struct, `OpenNest.Geometry`) — 2D point, has `Rotate()`, `Offset()`, `DistanceTo()`, operators
|
||||
- `Box` (class, `OpenNest.Geometry`) — AABB with `Left/Right/Top/Bottom/Width/Height`, `Contains()`, `Intersects()`
|
||||
- `Part` (class, `OpenNest`) — wraps `Drawing` + `Program`, has `Location`, `Rotation`, `Rotate()`, `Offset()`, `Clone()`, `BoundingBox`
|
||||
- `Drawing` (class, `OpenNest`) — has `Program`, `Area`, `Name`
|
||||
- `Program` (class, `OpenNest.CNC`) — G-code program, has `BoundingBox()`, `Rotate()`, `Clone()`
|
||||
- `Plate` (class, `OpenNest`) — has `Size` (Width/Height), `EdgeSpacing`, `PartSpacing`, `WorkArea()`
|
||||
- `Shape` (class, `OpenNest.Geometry`) — closed contour, has `Intersects(Shape)`, `Area()`, `ToPolygon()`, `OffsetEntity()`
|
||||
- `Polygon` (class, `OpenNest.Geometry`) — vertex list, has `FindBestRotation()`, `Rotate()`, `Offset()`
|
||||
- `ConvexHull.Compute(IList<Vector>)` — returns closed `Polygon`
|
||||
- `BoundingRectangleResult` — `Angle`, `Width`, `Height`, `Area` from rotating calipers
|
||||
|
||||
### Key Existing Methods (in `Helper`)
|
||||
- `Helper.GetShapes(IEnumerable<Entity>)` — builds `Shape` list from geometry entities
|
||||
- `Helper.GetPartLines(Part, PushDirection)` — gets polygon edges facing a direction (uses chord tolerance 0.01)
|
||||
- `Helper.DirectionalDistance(movingLines, stationaryLines, PushDirection)` — raycasts to find minimum contact distance
|
||||
- `Helper.OppositeDirection(PushDirection)` — flips direction
|
||||
- `ConvertProgram.ToGeometry(Program)` — converts CNC program to geometry entities
|
||||
|
||||
### How Existing Push/Contact Works (in `FillLinear`)
|
||||
```
|
||||
1. Create partA at position
|
||||
2. Clone to partB, offset by bounding box dimension along axis
|
||||
3. Get facing lines: movingLines = GetPartLines(partB, pushDir)
|
||||
4. Get facing lines: stationaryLines = GetPartLines(partA, oppositeDir)
|
||||
5. slideDistance = DirectionalDistance(movingLines, stationaryLines, pushDir)
|
||||
6. copyDistance = bboxDim - slideDistance + spacing
|
||||
```
|
||||
The best-fit system adapts this: part2 is rotated, offset perpendicular to the push axis, then pushed toward part1.
|
||||
|
||||
### Hull Edge Angles (existing pattern in `NestEngine`)
|
||||
```
|
||||
1. Convert part to polygon via ConvertProgram.ToGeometry → GetShapes → ToPolygonWithTolerance
|
||||
2. Compute convex hull via ConvexHull.Compute(vertices)
|
||||
3. Extract edge angles: atan2(dy, dx) for each hull edge
|
||||
4. Deduplicate angles (within Tolerance.Epsilon)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: PairCandidate Data Class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/PairCandidate.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` (add Compile entry)
|
||||
|
||||
**Step 1: Create directory and file**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class PairCandidate
|
||||
{
|
||||
public Drawing Drawing { get; set; }
|
||||
public double Part1Rotation { get; set; }
|
||||
public double Part2Rotation { get; set; }
|
||||
public Vector Part2Offset { get; set; }
|
||||
public int StrategyType { get; set; }
|
||||
public int TestNumber { get; set; }
|
||||
public double Spacing { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
Add inside the `<ItemGroup>` that contains `<Compile>` entries, before `</ItemGroup>`:
|
||||
```xml
|
||||
<Compile Include="BestFit\PairCandidate.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add PairCandidate data class for best-fit pair finding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: BestFitResult Data Class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/BestFitResult.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class BestFitResult
|
||||
{
|
||||
public PairCandidate Candidate { get; set; }
|
||||
public double RotatedArea { get; set; }
|
||||
public double BoundingWidth { get; set; }
|
||||
public double BoundingHeight { get; set; }
|
||||
public double OptimalRotation { get; set; }
|
||||
public bool Keep { get; set; }
|
||||
public string Reason { get; set; }
|
||||
public double TrueArea { get; set; }
|
||||
|
||||
public double Utilization
|
||||
{
|
||||
get { return RotatedArea > 0 ? TrueArea / RotatedArea : 0; }
|
||||
}
|
||||
|
||||
public double LongestSide
|
||||
{
|
||||
get { return System.Math.Max(BoundingWidth, BoundingHeight); }
|
||||
}
|
||||
|
||||
public double ShortestSide
|
||||
{
|
||||
get { return System.Math.Min(BoundingWidth, BoundingHeight); }
|
||||
}
|
||||
}
|
||||
|
||||
public enum BestFitSortField
|
||||
{
|
||||
Area,
|
||||
LongestSide,
|
||||
ShortestSide,
|
||||
Type,
|
||||
OriginalSequence,
|
||||
Keep,
|
||||
WhyKeepDrop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\BestFitResult.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add BestFitResult data class and BestFitSortField enum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: IBestFitStrategy Interface
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/IBestFitStrategy.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public interface IBestFitStrategy
|
||||
{
|
||||
int Type { get; }
|
||||
string Description { get; }
|
||||
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\IBestFitStrategy.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add IBestFitStrategy interface
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: RotationSlideStrategy
|
||||
|
||||
This is the core algorithm. It generates pair candidates by:
|
||||
1. Creating part1 at origin
|
||||
2. Creating part2 with a specific rotation
|
||||
3. For each push direction (Left, Down):
|
||||
- For each perpendicular offset (stepping across the part):
|
||||
- Place part2 far away along the push axis
|
||||
- Use `DirectionalDistance` to find contact
|
||||
- Record position as a candidate
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class RotationSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
private const double ChordTolerance = 0.01;
|
||||
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description)
|
||||
{
|
||||
Part2Rotation = part2Rotation;
|
||||
Type = type;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public double Part2Rotation { get; }
|
||||
public int Type { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||
{
|
||||
var candidates = new List<PairCandidate>();
|
||||
|
||||
var part1 = new Part(drawing);
|
||||
var bbox1 = part1.Program.BoundingBox();
|
||||
part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
|
||||
part1.UpdateBounds();
|
||||
|
||||
var part2Template = new Part(drawing);
|
||||
if (!Part2Rotation.IsEqualTo(0))
|
||||
part2Template.Rotate(Part2Rotation);
|
||||
var bbox2 = part2Template.Program.BoundingBox();
|
||||
part2Template.Offset(-bbox2.Location.X, -bbox2.Location.Y);
|
||||
part2Template.UpdateBounds();
|
||||
|
||||
var testNumber = 0;
|
||||
|
||||
// Slide along horizontal axis (push left toward part1)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Left, candidates, ref testNumber);
|
||||
|
||||
// Slide along vertical axis (push down toward part1)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Down, candidates, ref testNumber);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private void GenerateCandidatesForAxis(
|
||||
Part part1, Part part2Template, Drawing drawing,
|
||||
double spacing, double stepSize, PushDirection pushDir,
|
||||
List<PairCandidate> candidates, ref int testNumber)
|
||||
{
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
|
||||
// Determine perpendicular range based on push direction
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
bool isHorizontalPush = (pushDir == PushDirection.Left || pushDir == PushDirection.Right);
|
||||
|
||||
if (isHorizontalPush)
|
||||
{
|
||||
// Pushing horizontally: perpendicular axis is Y
|
||||
perpMin = -(bbox2.Height + spacing);
|
||||
perpMax = bbox1.Height + bbox2.Height + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pushing vertically: perpendicular axis is X
|
||||
perpMin = -(bbox2.Width + spacing);
|
||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||
pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2;
|
||||
}
|
||||
|
||||
var part1Lines = Helper.GetOffsetPartLines(part1, spacing / 2);
|
||||
var opposite = Helper.OppositeDirection(pushDir);
|
||||
|
||||
for (var offset = perpMin; offset <= perpMax; offset += stepSize)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(pushStartOffset, offset);
|
||||
else
|
||||
part2.Offset(offset, pushStartOffset);
|
||||
|
||||
var movingLines = Helper.GetOffsetPartLines(part2, spacing / 2);
|
||||
var slideDist = Helper.DirectionalDistance(movingLines, part1Lines, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
// Move part2 to contact position
|
||||
var contactOffset = GetPushVector(pushDir, slideDist);
|
||||
var finalPosition = part2.Location + contactOffset;
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector GetPushVector(PushDirection direction, double distance)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return new Vector(-distance, 0);
|
||||
case PushDirection.Right: return new Vector(distance, 0);
|
||||
case PushDirection.Down: return new Vector(0, -distance);
|
||||
case PushDirection.Up: return new Vector(0, distance);
|
||||
default: return Vector.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\RotationSlideStrategy.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
Run: `msbuild OpenNest.Engine/OpenNest.Engine.csproj /p:Configuration=Debug /v:q`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add RotationSlideStrategy with directional push contact algorithm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: PairEvaluator
|
||||
|
||||
Scores each candidate by computing the combined bounding box, finding the optimal rotation (via rotating calipers on the convex hull), and checking for overlaps.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/PairEvaluator.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class PairEvaluator
|
||||
{
|
||||
private const double ChordTolerance = 0.01;
|
||||
|
||||
public BestFitResult Evaluate(PairCandidate candidate)
|
||||
{
|
||||
var drawing = candidate.Drawing;
|
||||
|
||||
// Build part1 at origin
|
||||
var part1 = new Part(drawing);
|
||||
var bbox1 = part1.Program.BoundingBox();
|
||||
part1.Offset(-bbox1.Location.X, -bbox1.Location.Y);
|
||||
part1.UpdateBounds();
|
||||
|
||||
// Build part2 with rotation and offset
|
||||
var part2 = new Part(drawing);
|
||||
if (!candidate.Part2Rotation.IsEqualTo(0))
|
||||
part2.Rotate(candidate.Part2Rotation);
|
||||
var bbox2 = part2.Program.BoundingBox();
|
||||
part2.Offset(-bbox2.Location.X, -bbox2.Location.Y);
|
||||
part2.Location = candidate.Part2Offset;
|
||||
part2.UpdateBounds();
|
||||
|
||||
// Check overlap via shape intersection
|
||||
var overlaps = CheckOverlap(part1, part2, candidate.Spacing);
|
||||
|
||||
// Collect all polygon vertices for convex hull / optimal rotation
|
||||
var allPoints = GetPartVertices(part1);
|
||||
allPoints.AddRange(GetPartVertices(part2));
|
||||
|
||||
// Find optimal bounding rectangle via rotating calipers
|
||||
double bestArea, bestWidth, bestHeight, bestRotation;
|
||||
|
||||
if (allPoints.Count >= 3)
|
||||
{
|
||||
var hull = ConvexHull.Compute(allPoints);
|
||||
var result = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||
bestArea = result.Area;
|
||||
bestWidth = result.Width;
|
||||
bestHeight = result.Height;
|
||||
bestRotation = result.Angle;
|
||||
}
|
||||
else
|
||||
{
|
||||
var combinedBox = ((IEnumerable<IBoundable>)new[] { part1, part2 }).GetBoundingBox();
|
||||
bestArea = combinedBox.Area();
|
||||
bestWidth = combinedBox.Width;
|
||||
bestHeight = combinedBox.Height;
|
||||
bestRotation = 0;
|
||||
}
|
||||
|
||||
var trueArea = drawing.Area * 2;
|
||||
|
||||
return new BestFitResult
|
||||
{
|
||||
Candidate = candidate,
|
||||
RotatedArea = bestArea,
|
||||
BoundingWidth = bestWidth,
|
||||
BoundingHeight = bestHeight,
|
||||
OptimalRotation = bestRotation,
|
||||
TrueArea = trueArea,
|
||||
Keep = !overlaps,
|
||||
Reason = overlaps ? "Overlap detected" : "Valid"
|
||||
};
|
||||
}
|
||||
|
||||
private bool CheckOverlap(Part part1, Part part2, double spacing)
|
||||
{
|
||||
var shapes1 = GetPartShapes(part1);
|
||||
var shapes2 = GetPartShapes(part2);
|
||||
|
||||
for (var i = 0; i < shapes1.Count; i++)
|
||||
{
|
||||
for (var j = 0; j < shapes2.Count; j++)
|
||||
{
|
||||
List<Vector> pts;
|
||||
|
||||
if (shapes1[i].Intersects(shapes2[j], out pts))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Shape> GetPartShapes(Part part)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
shapes.ForEach(s => s.Offset(part.Location));
|
||||
return shapes;
|
||||
}
|
||||
|
||||
private List<Vector> GetPartVertices(Part part)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
var points = new List<Vector>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
||||
polygon.Offset(part.Location);
|
||||
|
||||
foreach (var vertex in polygon.Vertices)
|
||||
points.Add(vertex);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\PairEvaluator.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add PairEvaluator with overlap detection and optimal rotation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: BestFitFilter
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/BestFitFilter.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class BestFitFilter
|
||||
{
|
||||
public double MaxPlateWidth { get; set; }
|
||||
public double MaxPlateHeight { get; set; }
|
||||
public double MaxAspectRatio { get; set; } = 5.0;
|
||||
public double MinUtilization { get; set; } = 0.3;
|
||||
|
||||
public void Apply(List<BestFitResult> results)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (!result.Keep)
|
||||
continue;
|
||||
|
||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = "Exceeds plate dimensions";
|
||||
continue;
|
||||
}
|
||||
|
||||
var aspect = result.LongestSide / result.ShortestSide;
|
||||
|
||||
if (aspect > MaxAspectRatio)
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.Utilization < MinUtilization)
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = string.Format("Utilization {0:P0} below minimum", result.Utilization);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Reason = "Valid";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\BestFitFilter.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build to verify**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add BestFitFilter with plate size, aspect ratio, and utilization rules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: TileResult and TileEvaluator
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/Tiling/TileResult.cs`
|
||||
- Create: `OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create TileResult.cs**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit.Tiling
|
||||
{
|
||||
public class TileResult
|
||||
{
|
||||
public BestFitResult BestFit { get; set; }
|
||||
public int PairsNested { get; set; }
|
||||
public int PartsNested { get; set; }
|
||||
public int Rows { get; set; }
|
||||
public int Columns { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
public List<PairPlacement> Placements { get; set; }
|
||||
public bool PairRotated { get; set; }
|
||||
}
|
||||
|
||||
public class PairPlacement
|
||||
{
|
||||
public Vector Position { get; set; }
|
||||
public double PairRotation { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create TileEvaluator.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.BestFit.Tiling
|
||||
{
|
||||
public class TileEvaluator
|
||||
{
|
||||
public TileResult Evaluate(BestFitResult bestFit, Plate plate)
|
||||
{
|
||||
var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right;
|
||||
var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom;
|
||||
|
||||
var result1 = TryTile(bestFit, plateWidth, plateHeight, false);
|
||||
var result2 = TryTile(bestFit, plateWidth, plateHeight, true);
|
||||
return result1.PartsNested >= result2.PartsNested ? result1 : result2;
|
||||
}
|
||||
|
||||
private TileResult TryTile(BestFitResult bestFit, double plateWidth, double plateHeight, bool rotatePair)
|
||||
{
|
||||
var pairWidth = rotatePair ? bestFit.BoundingHeight : bestFit.BoundingWidth;
|
||||
var pairHeight = rotatePair ? bestFit.BoundingWidth : bestFit.BoundingHeight;
|
||||
var spacing = bestFit.Candidate.Spacing;
|
||||
|
||||
var cols = (int)System.Math.Floor((plateWidth + spacing) / (pairWidth + spacing));
|
||||
var rows = (int)System.Math.Floor((plateHeight + spacing) / (pairHeight + spacing));
|
||||
var pairsNested = cols * rows;
|
||||
var partsNested = pairsNested * 2;
|
||||
|
||||
var usedArea = partsNested * (bestFit.TrueArea / 2);
|
||||
var plateArea = plateWidth * plateHeight;
|
||||
|
||||
var placements = new List<PairPlacement>();
|
||||
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
placements.Add(new PairPlacement
|
||||
{
|
||||
Position = new Vector(
|
||||
col * (pairWidth + spacing),
|
||||
row * (pairHeight + spacing)),
|
||||
PairRotation = rotatePair ? Angle.HalfPI : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new TileResult
|
||||
{
|
||||
BestFit = bestFit,
|
||||
PairsNested = pairsNested,
|
||||
PartsNested = partsNested,
|
||||
Rows = rows,
|
||||
Columns = cols,
|
||||
Utilization = plateArea > 0 ? usedArea / plateArea : 0,
|
||||
Placements = placements,
|
||||
PairRotated = rotatePair
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\Tiling\TileResult.cs" />
|
||||
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
|
||||
```
|
||||
|
||||
**Step 4: Build to verify**
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: add TileEvaluator and TileResult for pair tiling on plates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: BestFitFinder (Orchestrator)
|
||||
|
||||
Computes hull edge angles from the drawing, builds `RotationSlideStrategy` instances for each angle in `{0, pi/2, pi, 3pi/2} + hull edges + hull edges + pi`, runs all strategies, evaluates, filters, and sorts.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/BestFitFinder.cs`
|
||||
- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
|
||||
**Step 1: Create file**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.BestFit.Tiling;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class BestFitFinder
|
||||
{
|
||||
private readonly PairEvaluator _evaluator;
|
||||
private readonly BestFitFilter _filter;
|
||||
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight)
|
||||
{
|
||||
_evaluator = new PairEvaluator();
|
||||
_filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = maxPlateWidth,
|
||||
MaxPlateHeight = maxPlateHeight
|
||||
};
|
||||
}
|
||||
|
||||
public List<BestFitResult> FindBestFits(
|
||||
Drawing drawing,
|
||||
double spacing = 0.25,
|
||||
double stepSize = 0.25,
|
||||
BestFitSortField sortBy = BestFitSortField.Area)
|
||||
{
|
||||
var strategies = BuildStrategies(drawing);
|
||||
|
||||
var allCandidates = new List<PairCandidate>();
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize));
|
||||
|
||||
var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList();
|
||||
|
||||
_filter.Apply(results);
|
||||
|
||||
results = SortResults(results, sortBy);
|
||||
|
||||
for (var i = 0; i < results.Count; i++)
|
||||
results[i].Candidate.TestNumber = i;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<TileResult> FindAndTile(
|
||||
Drawing drawing, Plate plate,
|
||||
double spacing = 0.25, double stepSize = 0.25, int topN = 10)
|
||||
{
|
||||
var bestFits = FindBestFits(drawing, spacing, stepSize);
|
||||
var tileEvaluator = new TileEvaluator();
|
||||
|
||||
return bestFits
|
||||
.Where(r => r.Keep)
|
||||
.Take(topN)
|
||||
.Select(r => tileEvaluator.Evaluate(r, plate))
|
||||
.OrderByDescending(t => t.PartsNested)
|
||||
.ThenByDescending(t => t.Utilization)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
|
||||
{
|
||||
var angles = GetRotationAngles(drawing);
|
||||
var strategies = new List<IBestFitStrategy>();
|
||||
var type = 1;
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc));
|
||||
}
|
||||
|
||||
return strategies;
|
||||
}
|
||||
|
||||
private List<double> GetRotationAngles(Drawing drawing)
|
||||
{
|
||||
var angles = new List<double>
|
||||
{
|
||||
0,
|
||||
Angle.HalfPI,
|
||||
System.Math.PI,
|
||||
Angle.HalfPI * 3
|
||||
};
|
||||
|
||||
// Add hull edge angles
|
||||
var hullAngles = GetHullEdgeAngles(drawing);
|
||||
|
||||
foreach (var hullAngle in hullAngles)
|
||||
{
|
||||
AddUniqueAngle(angles, hullAngle);
|
||||
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
|
||||
}
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
private List<double> GetHullEdgeAngles(Drawing drawing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
|
||||
var points = new List<Vector>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(0.1);
|
||||
points.AddRange(polygon.Vertices);
|
||||
}
|
||||
|
||||
if (points.Count < 3)
|
||||
return new List<double>();
|
||||
|
||||
var hull = ConvexHull.Compute(points);
|
||||
var vertices = hull.Vertices;
|
||||
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
|
||||
var hullAngles = new List<double>();
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var next = (i + 1) % n;
|
||||
var dx = vertices[next].X - vertices[i].X;
|
||||
var dy = vertices[next].Y - vertices[i].Y;
|
||||
|
||||
if (dx * dx + dy * dy < Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(dy, dx));
|
||||
AddUniqueAngle(hullAngles, angle);
|
||||
}
|
||||
|
||||
return hullAngles;
|
||||
}
|
||||
|
||||
private static void AddUniqueAngle(List<double> angles, double angle)
|
||||
{
|
||||
angle = Angle.NormalizeRad(angle);
|
||||
|
||||
foreach (var existing in angles)
|
||||
{
|
||||
if (existing.IsEqualTo(angle))
|
||||
return;
|
||||
}
|
||||
|
||||
angles.Add(angle);
|
||||
}
|
||||
|
||||
private List<BestFitResult> SortResults(List<BestFitResult> results, BestFitSortField sortBy)
|
||||
{
|
||||
switch (sortBy)
|
||||
{
|
||||
case BestFitSortField.Area:
|
||||
return results.OrderBy(r => r.RotatedArea).ToList();
|
||||
case BestFitSortField.LongestSide:
|
||||
return results.OrderBy(r => r.LongestSide).ToList();
|
||||
case BestFitSortField.ShortestSide:
|
||||
return results.OrderBy(r => r.ShortestSide).ToList();
|
||||
case BestFitSortField.Type:
|
||||
return results.OrderBy(r => r.Candidate.StrategyType)
|
||||
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
||||
case BestFitSortField.OriginalSequence:
|
||||
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
|
||||
case BestFitSortField.Keep:
|
||||
return results.OrderByDescending(r => r.Keep)
|
||||
.ThenBy(r => r.RotatedArea).ToList();
|
||||
case BestFitSortField.WhyKeepDrop:
|
||||
return results.OrderBy(r => r.Reason)
|
||||
.ThenBy(r => r.RotatedArea).ToList();
|
||||
default:
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add to .csproj**
|
||||
|
||||
```xml
|
||||
<Compile Include="BestFit\BestFitFinder.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Build full solution to verify all references resolve**
|
||||
|
||||
Run: `msbuild OpenNest.sln /p:Configuration=Debug /v:q`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add BestFitFinder orchestrator with hull edge angle strategies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Final Integration Build and Smoke Test
|
||||
|
||||
**Step 1: Clean build of entire solution**
|
||||
|
||||
Run: `msbuild OpenNest.sln /t:Rebuild /p:Configuration=Debug /v:q`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
**Step 2: Verify all new files are included**
|
||||
|
||||
Check that all 8 new files appear in the build output by reviewing the .csproj has these entries:
|
||||
```xml
|
||||
<Compile Include="BestFit\PairCandidate.cs" />
|
||||
<Compile Include="BestFit\BestFitResult.cs" />
|
||||
<Compile Include="BestFit\IBestFitStrategy.cs" />
|
||||
<Compile Include="BestFit\RotationSlideStrategy.cs" />
|
||||
<Compile Include="BestFit\PairEvaluator.cs" />
|
||||
<Compile Include="BestFit\BestFitFilter.cs" />
|
||||
<Compile Include="BestFit\Tiling\TileResult.cs" />
|
||||
<Compile Include="BestFit\Tiling\TileEvaluator.cs" />
|
||||
<Compile Include="BestFit\BestFitFinder.cs" />
|
||||
```
|
||||
|
||||
**Step 3: Final commit**
|
||||
|
||||
If any build fixes were needed, commit them:
|
||||
```
|
||||
fix: resolve build issues in best-fit pair finding engine
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
# GPU Bitmap Best Fit Evaluation
|
||||
|
||||
## Overview
|
||||
|
||||
Add GPU-accelerated bitmap-based overlap testing to the best fit pair evaluation pipeline using ILGPU. Parts are rasterized to integer grids; overlap detection becomes cell comparison on the GPU. Runs alongside the existing geometry-based evaluator, selectable via flag.
|
||||
|
||||
## Architecture
|
||||
|
||||
New project `OpenNest.Gpu` (class library, `net8.0-windows`). References `OpenNest.Core` and `OpenNest.Engine`. NuGet: `ILGPU`, `ILGPU.Algorithms`.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `Polygon.ContainsPoint(Vector pt)` (Core)
|
||||
|
||||
Ray-cast from point rightward past bounding box. Count edge intersections with polygon segments. Odd = inside, even = outside.
|
||||
|
||||
### 2. `PartBitmap` (OpenNest.Gpu)
|
||||
|
||||
- Rasterizes a `Drawing` to `int[]` grid
|
||||
- Pipeline: `ConvertProgram.ToGeometry()` -> `Helper.GetShapes()` -> `Shape.ToPolygonWithTolerance()` -> `Polygon.ContainsPoint()` per cell center
|
||||
- Dilates filled cells by `spacing / 2 / cellSize` pixels to bake in part spacing
|
||||
- Default cell size: 0.05"
|
||||
- Cached per drawing (rasterize once, reuse across all candidates)
|
||||
|
||||
### 3. `IPairEvaluator` (Engine)
|
||||
|
||||
```csharp
|
||||
interface IPairEvaluator
|
||||
{
|
||||
List<BestFitResult> EvaluateAll(List<PairCandidate> candidates);
|
||||
}
|
||||
```
|
||||
|
||||
- `PairEvaluator` — existing geometry path (CPU parallel)
|
||||
- `GpuPairEvaluator` — bitmap path (GPU batch)
|
||||
|
||||
### 4. `GpuPairEvaluator` (OpenNest.Gpu)
|
||||
|
||||
- Constructor takes `Drawing`, `cellSize`, `spacing`. Rasterizes `PartBitmap` once.
|
||||
- `EvaluateAll()` uploads bitmap + candidate params to GPU, one kernel per candidate
|
||||
- Kernel: for each cell, transform to part2 space (rotation + offset), check overlap, track bounding extent
|
||||
- Results: overlap count (0 = valid), bounding width/height from min/max occupied cells
|
||||
- `IDisposable` — owns ILGPU `Context` + `Accelerator`
|
||||
|
||||
### 5. `BestFitFinder` modification (Engine)
|
||||
|
||||
- Constructor accepts optional `IPairEvaluator`
|
||||
- Falls back to `PairEvaluator` if none provided
|
||||
- Candidate generation (strategies, rotation angles, slide) unchanged
|
||||
- Calls `IPairEvaluator.EvaluateAll(candidates)` instead of inline `Parallel.ForEach`
|
||||
|
||||
### 6. Integration in `NestEngine`
|
||||
|
||||
- `FillWithPairs()` creates finder with either evaluator based on `UseGpu` flag
|
||||
- UI layer toggles the flag
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Drawing -> PartBitmap (rasterize once, dilate for spacing)
|
||||
|
|
||||
Strategies -> PairCandidates[] (rotation angles x slide offsets)
|
||||
|
|
||||
GpuPairEvaluator.EvaluateAll():
|
||||
- Upload bitmap + candidate float4[] to GPU
|
||||
- Kernel per candidate: overlap check + bounding box
|
||||
- Download results
|
||||
|
|
||||
BestFitFilter -> sort -> BestFitResults
|
||||
```
|
||||
|
||||
## Unchanged
|
||||
|
||||
- `RotationSlideStrategy` and candidate generation
|
||||
- `BestFitFilter`, `BestFitResult`, `TileEvaluator`
|
||||
- `NestEngine.FillWithPairs()` flow (just swaps evaluator)
|
||||
@@ -1,769 +0,0 @@
|
||||
# GPU Bitmap Best Fit Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add GPU-accelerated bitmap overlap testing to the best fit pair evaluator using ILGPU, alongside the existing geometry evaluator.
|
||||
|
||||
**Architecture:** New `OpenNest.Gpu` project holds `PartBitmap` and `GpuPairEvaluator`. Engine gets an `IPairEvaluator` interface that both geometry and GPU paths implement. `BestFitFinder` accepts the interface; `NestEngine` selects which evaluator via a `UseGpu` flag.
|
||||
|
||||
**Tech Stack:** .NET 8, ILGPU 1.5+, ILGPU.Algorithms
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `Polygon.ContainsPoint` to Core
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Polygon.cs:610` (before closing brace)
|
||||
|
||||
**Step 1: Add ContainsPoint method**
|
||||
|
||||
Insert before the closing `}` of the `Polygon` class (line 611):
|
||||
|
||||
```csharp
|
||||
public bool ContainsPoint(Vector pt)
|
||||
{
|
||||
var n = IsClosed() ? Vertices.Count - 1 : Vertices.Count;
|
||||
|
||||
if (n < 3)
|
||||
return false;
|
||||
|
||||
var inside = false;
|
||||
|
||||
for (var i = 0, j = n - 1; i < n; j = i++)
|
||||
{
|
||||
var vi = Vertices[i];
|
||||
var vj = Vertices[j];
|
||||
|
||||
if ((vi.Y > pt.Y) != (vj.Y > pt.Y) &&
|
||||
pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X)
|
||||
{
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
```
|
||||
|
||||
This is the standard even-odd ray casting algorithm. Casts a ray rightward from `pt`, toggles `inside` at each edge crossing.
|
||||
|
||||
**Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Polygon.cs
|
||||
git commit -m "feat: add Polygon.ContainsPoint using ray casting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract `IPairEvaluator` interface in Engine
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/BestFit/IPairEvaluator.cs`
|
||||
- Modify: `OpenNest.Engine/BestFit/PairEvaluator.cs`
|
||||
|
||||
**Step 1: Create the interface**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public interface IPairEvaluator
|
||||
{
|
||||
List<BestFitResult> EvaluateAll(List<PairCandidate> candidates);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Make `PairEvaluator` implement the interface**
|
||||
|
||||
In `PairEvaluator.cs`, change the class declaration (line 9) to:
|
||||
|
||||
```csharp
|
||||
public class PairEvaluator : IPairEvaluator
|
||||
```
|
||||
|
||||
Add the `EvaluateAll` method. This wraps the existing per-candidate `Evaluate` in a `Parallel.ForEach`, matching the current behavior in `BestFitFinder.FindBestFits()`:
|
||||
|
||||
```csharp
|
||||
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
||||
{
|
||||
var resultBag = new System.Collections.Concurrent.ConcurrentBag<BestFitResult>();
|
||||
|
||||
System.Threading.Tasks.Parallel.ForEach(candidates, c =>
|
||||
{
|
||||
resultBag.Add(Evaluate(c));
|
||||
});
|
||||
|
||||
return resultBag.ToList();
|
||||
}
|
||||
```
|
||||
|
||||
Add `using System.Linq;` if not already present (it is — line 2).
|
||||
|
||||
**Step 3: Update `BestFitFinder` to use `IPairEvaluator`**
|
||||
|
||||
In `BestFitFinder.cs`:
|
||||
|
||||
Change the field and constructor to accept an optional evaluator:
|
||||
|
||||
```csharp
|
||||
public class BestFitFinder
|
||||
{
|
||||
private readonly IPairEvaluator _evaluator;
|
||||
private readonly BestFitFilter _filter;
|
||||
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
|
||||
{
|
||||
_evaluator = evaluator ?? new PairEvaluator();
|
||||
_filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = maxPlateWidth,
|
||||
MaxPlateHeight = maxPlateHeight
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Replace the evaluation `Parallel.ForEach` block in `FindBestFits()` (lines 44-52) with:
|
||||
|
||||
```csharp
|
||||
var results = _evaluator.EvaluateAll(allCandidates);
|
||||
```
|
||||
|
||||
Remove the `ConcurrentBag<BestFitResult>` and the second `Parallel.ForEach` — those lines (44-52) are fully replaced by the single call above.
|
||||
|
||||
**Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 5: Build full solution to verify nothing broke**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded (NestEngine still creates BestFitFinder with 2 args — still valid)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/BestFit/IPairEvaluator.cs OpenNest.Engine/BestFit/PairEvaluator.cs OpenNest.Engine/BestFit/BestFitFinder.cs
|
||||
git commit -m "refactor: extract IPairEvaluator interface from PairEvaluator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `OpenNest.Gpu` project with `PartBitmap`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Gpu/OpenNest.Gpu.csproj`
|
||||
- Create: `OpenNest.Gpu/PartBitmap.cs`
|
||||
- Modify: `OpenNest.sln` (add project)
|
||||
|
||||
**Step 1: Create project**
|
||||
|
||||
```bash
|
||||
cd "C:\Users\aisaacs\Desktop\Projects\OpenNest"
|
||||
dotnet new classlib -n OpenNest.Gpu --framework net8.0-windows
|
||||
rm OpenNest.Gpu/Class1.cs
|
||||
dotnet sln OpenNest.sln add OpenNest.Gpu/OpenNest.Gpu.csproj
|
||||
```
|
||||
|
||||
**Step 2: Edit csproj**
|
||||
|
||||
Replace the generated `OpenNest.Gpu.csproj` with:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Gpu</RootNamespace>
|
||||
<AssemblyName>OpenNest.Gpu</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ILGPU" Version="1.5.1" />
|
||||
<PackageReference Include="ILGPU.Algorithms" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Step 3: Create `PartBitmap.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Gpu
|
||||
{
|
||||
public class PartBitmap
|
||||
{
|
||||
public int[] Cells { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public double CellSize { get; set; }
|
||||
public double OriginX { get; set; }
|
||||
public double OriginY { get; set; }
|
||||
|
||||
public static PartBitmap FromDrawing(Drawing drawing, double cellSize, double spacingDilation = 0)
|
||||
{
|
||||
var polygons = GetClosedPolygons(drawing);
|
||||
|
||||
if (polygons.Count == 0)
|
||||
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var poly in polygons)
|
||||
{
|
||||
poly.UpdateBounds();
|
||||
var bb = poly.BoundingBox;
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
// Expand bounds by dilation amount
|
||||
minX -= spacingDilation;
|
||||
minY -= spacingDilation;
|
||||
maxX += spacingDilation;
|
||||
maxY += spacingDilation;
|
||||
|
||||
var width = (int)System.Math.Ceiling((maxX - minX) / cellSize);
|
||||
var height = (int)System.Math.Ceiling((maxY - minY) / cellSize);
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
|
||||
|
||||
var cells = new int[width * height];
|
||||
var dilationCells = (int)System.Math.Ceiling(spacingDilation / cellSize);
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var px = minX + (x + 0.5) * cellSize;
|
||||
var py = minY + (y + 0.5) * cellSize;
|
||||
var pt = new Vector(px, py);
|
||||
|
||||
foreach (var poly in polygons)
|
||||
{
|
||||
if (poly.ContainsPoint(pt))
|
||||
{
|
||||
cells[y * width + x] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dilate: expand filled cells outward by dilationCells
|
||||
if (dilationCells > 0)
|
||||
Dilate(cells, width, height, dilationCells);
|
||||
|
||||
return new PartBitmap
|
||||
{
|
||||
Cells = cells,
|
||||
Width = width,
|
||||
Height = height,
|
||||
CellSize = cellSize,
|
||||
OriginX = minX,
|
||||
OriginY = minY
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Polygon> GetClosedPolygons(Drawing drawing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
var shapes = Helper.GetShapes(entities);
|
||||
|
||||
var polygons = new List<Polygon>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
if (!shape.IsClosed())
|
||||
continue;
|
||||
|
||||
var polygon = shape.ToPolygonWithTolerance(0.05);
|
||||
polygon.Close();
|
||||
polygons.Add(polygon);
|
||||
}
|
||||
|
||||
return polygons;
|
||||
}
|
||||
|
||||
private static void Dilate(int[] cells, int width, int height, int radius)
|
||||
{
|
||||
var source = (int[])cells.Clone();
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
if (source[y * width + x] != 1)
|
||||
continue;
|
||||
|
||||
for (var dy = -radius; dy <= radius; dy++)
|
||||
{
|
||||
for (var dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
var nx = x + dx;
|
||||
var ny = y + dy;
|
||||
|
||||
if (nx >= 0 && nx < width && ny >= 0 && ny < height)
|
||||
cells[ny * width + nx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
|
||||
Expected: Build succeeded (ILGPU NuGet restored)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Gpu/ OpenNest.sln
|
||||
git commit -m "feat: add OpenNest.Gpu project with PartBitmap rasterizer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement `GpuPairEvaluator` with ILGPU kernel
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Gpu/GpuPairEvaluator.cs`
|
||||
|
||||
**Step 1: Create the evaluator**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ILGPU;
|
||||
using ILGPU.Runtime;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Gpu
|
||||
{
|
||||
public class GpuPairEvaluator : IPairEvaluator, IDisposable
|
||||
{
|
||||
private readonly Context _context;
|
||||
private readonly Accelerator _accelerator;
|
||||
private readonly Drawing _drawing;
|
||||
private readonly PartBitmap _bitmap;
|
||||
private readonly double _spacing;
|
||||
|
||||
public const double DefaultCellSize = 0.05;
|
||||
|
||||
public GpuPairEvaluator(Drawing drawing, double spacing, double cellSize = DefaultCellSize)
|
||||
{
|
||||
_drawing = drawing;
|
||||
_spacing = spacing;
|
||||
_context = Context.CreateDefault();
|
||||
_accelerator = _context.GetPreferredDevice(preferCPU: false)
|
||||
.CreateAccelerator(_context);
|
||||
|
||||
var dilation = spacing / 2.0;
|
||||
_bitmap = PartBitmap.FromDrawing(drawing, cellSize, dilation);
|
||||
}
|
||||
|
||||
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
||||
{
|
||||
if (_bitmap.Width == 0 || _bitmap.Height == 0 || candidates.Count == 0)
|
||||
return new List<BestFitResult>();
|
||||
|
||||
var bitmapWidth = _bitmap.Width;
|
||||
var bitmapHeight = _bitmap.Height;
|
||||
var cellSize = (float)_bitmap.CellSize;
|
||||
var candidateCount = candidates.Count;
|
||||
|
||||
// Pack candidate parameters: offsetX, offsetY, rotation, unused
|
||||
var candidateParams = new float[candidateCount * 4];
|
||||
|
||||
for (var i = 0; i < candidateCount; i++)
|
||||
{
|
||||
candidateParams[i * 4 + 0] = (float)candidates[i].Part2Offset.X;
|
||||
candidateParams[i * 4 + 1] = (float)candidates[i].Part2Offset.Y;
|
||||
candidateParams[i * 4 + 2] = (float)candidates[i].Part2Rotation;
|
||||
candidateParams[i * 4 + 3] = 0f;
|
||||
}
|
||||
|
||||
// Results: overlapCount, minX, minY, maxX, maxY per candidate
|
||||
var resultData = new int[candidateCount * 5];
|
||||
|
||||
// Initialize min to large, max to small
|
||||
for (var i = 0; i < candidateCount; i++)
|
||||
{
|
||||
resultData[i * 5 + 0] = 0; // overlapCount
|
||||
resultData[i * 5 + 1] = int.MaxValue; // minX
|
||||
resultData[i * 5 + 2] = int.MaxValue; // minY
|
||||
resultData[i * 5 + 3] = int.MinValue; // maxX
|
||||
resultData[i * 5 + 4] = int.MinValue; // maxY
|
||||
}
|
||||
|
||||
using var gpuBitmap = _accelerator.Allocate1D(_bitmap.Cells);
|
||||
using var gpuParams = _accelerator.Allocate1D(candidateParams);
|
||||
using var gpuResults = _accelerator.Allocate1D(resultData);
|
||||
|
||||
var kernel = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
Index1D,
|
||||
ArrayView<int>,
|
||||
ArrayView<float>,
|
||||
ArrayView<int>,
|
||||
int, int, float, float, float>(EvaluateKernel);
|
||||
|
||||
kernel(
|
||||
candidateCount,
|
||||
gpuBitmap.View,
|
||||
gpuParams.View,
|
||||
gpuResults.View,
|
||||
bitmapWidth,
|
||||
bitmapHeight,
|
||||
cellSize,
|
||||
(float)_bitmap.OriginX,
|
||||
(float)_bitmap.OriginY);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
gpuResults.CopyToCPU(resultData);
|
||||
|
||||
var trueArea = _drawing.Area * 2;
|
||||
var results = new List<BestFitResult>(candidateCount);
|
||||
|
||||
for (var i = 0; i < candidateCount; i++)
|
||||
{
|
||||
var overlapCount = resultData[i * 5 + 0];
|
||||
var minX = resultData[i * 5 + 1];
|
||||
var minY = resultData[i * 5 + 2];
|
||||
var maxX = resultData[i * 5 + 3];
|
||||
var maxY = resultData[i * 5 + 4];
|
||||
|
||||
var hasOverlap = overlapCount > 0;
|
||||
var hasBounds = minX <= maxX && minY <= maxY;
|
||||
|
||||
double boundingWidth = 0, boundingHeight = 0, area = 0;
|
||||
|
||||
if (hasBounds)
|
||||
{
|
||||
boundingWidth = (maxX - minX + 1) * _bitmap.CellSize;
|
||||
boundingHeight = (maxY - minY + 1) * _bitmap.CellSize;
|
||||
area = boundingWidth * boundingHeight;
|
||||
}
|
||||
|
||||
results.Add(new BestFitResult
|
||||
{
|
||||
Candidate = candidates[i],
|
||||
RotatedArea = area,
|
||||
BoundingWidth = boundingWidth,
|
||||
BoundingHeight = boundingHeight,
|
||||
OptimalRotation = 0,
|
||||
TrueArea = trueArea,
|
||||
Keep = !hasOverlap && hasBounds,
|
||||
Reason = hasOverlap ? "Overlap detected" : hasBounds ? "Valid" : "No bounds"
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void EvaluateKernel(
|
||||
Index1D index,
|
||||
ArrayView<int> bitmap,
|
||||
ArrayView<float> candidateParams,
|
||||
ArrayView<int> results,
|
||||
int bitmapWidth, int bitmapHeight,
|
||||
float cellSize, float originX, float originY)
|
||||
{
|
||||
var paramIdx = index * 4;
|
||||
var offsetX = candidateParams[paramIdx + 0];
|
||||
var offsetY = candidateParams[paramIdx + 1];
|
||||
var rotation = candidateParams[paramIdx + 2];
|
||||
|
||||
// Convert world offset to cell offset relative to bitmap origin
|
||||
var offsetCellsX = (offsetX - originX) / cellSize;
|
||||
var offsetCellsY = (offsetY - originY) / cellSize;
|
||||
|
||||
var cosR = IntrinsicMath.Cos(rotation);
|
||||
var sinR = IntrinsicMath.Sin(rotation);
|
||||
|
||||
var halfW = bitmapWidth * 0.5f;
|
||||
var halfH = bitmapHeight * 0.5f;
|
||||
|
||||
var overlapCount = 0;
|
||||
var minX = int.MaxValue;
|
||||
var minY = int.MaxValue;
|
||||
var maxX = int.MinValue;
|
||||
var maxY = int.MinValue;
|
||||
|
||||
for (var y = 0; y < bitmapHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < bitmapWidth; x++)
|
||||
{
|
||||
var cell1 = bitmap[y * bitmapWidth + x];
|
||||
|
||||
// Transform (x,y) to part2 space: rotate around center then offset
|
||||
var cx = x - halfW;
|
||||
var cy = y - halfH;
|
||||
var rx = cx * cosR - cy * sinR;
|
||||
var ry = cx * sinR + cy * cosR;
|
||||
var bx = (int)(rx + halfW + offsetCellsX - x);
|
||||
var by = (int)(ry + halfH + offsetCellsY - y);
|
||||
|
||||
// Lookup part2 bitmap cell at transformed position
|
||||
var bx2 = x + bx;
|
||||
var by2 = y + by;
|
||||
var cell2 = 0;
|
||||
|
||||
if (bx2 >= 0 && bx2 < bitmapWidth && by2 >= 0 && by2 < bitmapHeight)
|
||||
cell2 = bitmap[by2 * bitmapWidth + bx2];
|
||||
|
||||
if (cell1 == 1 && cell2 == 1)
|
||||
overlapCount++;
|
||||
|
||||
if (cell1 == 1 || cell2 == 1)
|
||||
{
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resultIdx = index * 5;
|
||||
results[resultIdx + 0] = overlapCount;
|
||||
results[resultIdx + 1] = minX;
|
||||
results[resultIdx + 2] = minY;
|
||||
results[resultIdx + 3] = maxX;
|
||||
results[resultIdx + 4] = maxY;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accelerator?.Dispose();
|
||||
_context?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: The kernel uses `IntrinsicMath.Cos`/`Sin` which ILGPU compiles to GPU intrinsics. The `int.MaxValue`/`int.MinValue` initialization for bounds tracking is done CPU-side before upload.
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Gpu/GpuPairEvaluator.cs
|
||||
git commit -m "feat: add GpuPairEvaluator with ILGPU bitmap overlap kernel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire GPU evaluator into `NestEngine`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
- Modify: `OpenNest/OpenNest.csproj` (add reference to OpenNest.Gpu)
|
||||
|
||||
**Step 1: Add `UseGpu` property to `NestEngine`**
|
||||
|
||||
At the top of the `NestEngine` class (after the existing properties around line 23), add:
|
||||
|
||||
```csharp
|
||||
public bool UseGpu { get; set; }
|
||||
```
|
||||
|
||||
**Step 2: Update `FillWithPairs` to use GPU evaluator when enabled**
|
||||
|
||||
In `NestEngine.cs`, the `FillWithPairs(NestItem item, Box workArea)` method (line 268) creates a `BestFitFinder`. Change it to optionally pass a GPU evaluator.
|
||||
|
||||
Add at the top of the file:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Engine.BestFit;
|
||||
```
|
||||
|
||||
(Already present — line 6.)
|
||||
|
||||
Replace the `FillWithPairs(NestItem item, Box workArea)` method body. The key change is lines 270-271 where the finder is created:
|
||||
|
||||
```csharp
|
||||
private List<Part> FillWithPairs(NestItem item, Box workArea)
|
||||
{
|
||||
IPairEvaluator evaluator = null;
|
||||
|
||||
if (UseGpu)
|
||||
{
|
||||
try
|
||||
{
|
||||
evaluator = new Gpu.GpuPairEvaluator(item.Drawing, Plate.PartSpacing);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// GPU not available, fall back to geometry
|
||||
}
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(Plate.Size.Width, Plate.Size.Height, evaluator);
|
||||
var bestFits = finder.FindBestFits(item.Drawing, Plate.PartSpacing, stepSize: 0.25);
|
||||
|
||||
var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList();
|
||||
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}");
|
||||
|
||||
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(int count, List<Part> parts)>();
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, keptResults.Count, i =>
|
||||
{
|
||||
var result = keptResults[i];
|
||||
var pairParts = BuildPairParts(result, item.Drawing);
|
||||
var angles = FindHullEdgeAngles(pairParts);
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles);
|
||||
|
||||
if (filled != null && filled.Count > 0)
|
||||
resultBag.Add((filled.Count, filled));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var (count, parts) in resultBag)
|
||||
{
|
||||
if (best == null || count > best.Count)
|
||||
best = parts;
|
||||
}
|
||||
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add OpenNest.Gpu reference to UI project**
|
||||
|
||||
In `OpenNest/OpenNest.csproj`, add to the `<ItemGroup>` with other project references:
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
||||
```
|
||||
|
||||
**Step 4: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs OpenNest/OpenNest.csproj
|
||||
git commit -m "feat: wire GpuPairEvaluator into NestEngine with UseGpu flag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add UI toggle for GPU mode
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.cs`
|
||||
- Modify: `OpenNest/Forms/MainForm.Designer.cs`
|
||||
|
||||
This task adds a "Use GPU" checkbox menu item under the Tools menu. The exact placement depends on the existing menu structure.
|
||||
|
||||
**Step 1: Check existing menu structure**
|
||||
|
||||
Read `MainForm.Designer.cs` to find the Tools menu items and their initialization to determine where to add the GPU toggle. Look for `mnuTools` items.
|
||||
|
||||
**Step 2: Add menu item field**
|
||||
|
||||
In `MainForm.Designer.cs`, add a field declaration near the other menu fields:
|
||||
|
||||
```csharp
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsUseGpu;
|
||||
```
|
||||
|
||||
**Step 3: Initialize menu item**
|
||||
|
||||
In the `InitializeComponent()` method, initialize the item and add it to the Tools menu `DropDownItems`:
|
||||
|
||||
```csharp
|
||||
this.mnuToolsUseGpu = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.mnuToolsUseGpu.Name = "mnuToolsUseGpu";
|
||||
this.mnuToolsUseGpu.Text = "Use GPU for Best Fit";
|
||||
this.mnuToolsUseGpu.CheckOnClick = true;
|
||||
this.mnuToolsUseGpu.Click += new System.EventHandler(this.UseGpu_Click);
|
||||
```
|
||||
|
||||
Add `this.mnuToolsUseGpu` to the Tools menu's `DropDownItems` array.
|
||||
|
||||
**Step 4: Add click handler in MainForm.cs**
|
||||
|
||||
```csharp
|
||||
private void UseGpu_Click(object sender, EventArgs e)
|
||||
{
|
||||
// The CheckOnClick property handles toggling automatically
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Pass the flag when creating NestEngine**
|
||||
|
||||
Find where `NestEngine` is created in the codebase (likely in auto-nest or fill actions) and set `UseGpu = mnuToolsUseGpu.Checked` on the engine after creation.
|
||||
|
||||
This requires reading the code to find the exact creation points. Search for `new NestEngine(` in the codebase.
|
||||
|
||||
**Step 6: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
|
||||
git commit -m "feat: add Use GPU toggle in Tools menu"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Smoke test
|
||||
|
||||
**Step 1: Run the application**
|
||||
|
||||
Run: `dotnet run --project OpenNest/OpenNest.csproj`
|
||||
|
||||
**Step 2: Manual verification**
|
||||
|
||||
1. Open a nest file with parts
|
||||
2. Verify the geometry path still works (GPU unchecked) — auto-nest a plate
|
||||
3. Enable "Use GPU for Best Fit" in Tools menu
|
||||
4. Auto-nest the same plate with GPU enabled
|
||||
5. Compare part counts — GPU results should be close to geometry results (not exact due to bitmap approximation)
|
||||
6. Check Debug output for `[FillWithPairs]` timing differences
|
||||
|
||||
**Step 3: Commit any fixes**
|
||||
|
||||
If any issues found, fix and commit with appropriate message.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
# OpenNest MCP Service + IO Library Refactor
|
||||
|
||||
## Goal
|
||||
|
||||
Create an MCP server so Claude Code can load nest files, run nesting algorithms, and inspect results — enabling rapid iteration on nesting strategies without launching the WinForms app.
|
||||
|
||||
## Project Changes
|
||||
|
||||
```
|
||||
OpenNest.Core (no external deps) — add Plate.GetRemnants()
|
||||
OpenNest.Engine → Core
|
||||
OpenNest.IO (NEW) → Core + ACadSharp — extracted from OpenNest/IO/
|
||||
OpenNest.Mcp (NEW) → Core + Engine + IO
|
||||
OpenNest (WinForms) → Core + Engine + IO (drops ACadSharp direct ref)
|
||||
```
|
||||
|
||||
## OpenNest.IO Library
|
||||
|
||||
New class library. Move from the UI project (`OpenNest/IO/`):
|
||||
|
||||
- `DxfImporter`
|
||||
- `DxfExporter`
|
||||
- `NestReader`
|
||||
- `NestWriter`
|
||||
- `ProgramReader`
|
||||
- ACadSharp NuGet dependency (3.1.32)
|
||||
|
||||
The WinForms project drops its direct ACadSharp reference and references OpenNest.IO instead.
|
||||
|
||||
## Plate.GetRemnants()
|
||||
|
||||
Add to `Plate` in Core. Simple strip-based scan:
|
||||
|
||||
1. Collect all part bounding boxes inflated by `PartSpacing`.
|
||||
2. Scan the work area for clear rectangular strips along edges and between part columns/rows.
|
||||
3. Return `List<Box>` of usable empty regions.
|
||||
|
||||
No engine dependency — uses only work area and part bounding boxes already available on Plate.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Input
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `load_nest` | Load a `.nest` zip file, returns nest summary (plates, drawings, part counts) |
|
||||
| `import_dxf` | Import a DXF file as a drawing |
|
||||
| `create_drawing` | Create from built-in shape primitive (rect, circle, L, T) or raw G-code string |
|
||||
|
||||
### Setup
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `create_plate` | Define a plate with dimensions, spacing, edge spacing, quadrant |
|
||||
| `clear_plate` | Remove all parts from a plate |
|
||||
|
||||
### Nesting
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fill_plate` | Fill entire plate with a single drawing (NestEngine.Fill) |
|
||||
| `fill_area` | Fill a specific box region on a plate |
|
||||
| `fill_remnants` | Auto-detect remnants via Plate.GetRemnants(), fill each with a drawing |
|
||||
| `pack_plate` | Multi-drawing bin packing (NestEngine.Pack) |
|
||||
|
||||
### Inspection
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_plate_info` | Dimensions, part count, utilization %, remnant boxes |
|
||||
| `get_parts` | List placed parts with location, rotation, bounding box |
|
||||
| `check_overlaps` | Run overlap detection, return collision points |
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```
|
||||
load_nest("N0308-008.zip")
|
||||
→ 1 plate (36x36), 75 parts, 1 drawing (Converto 3 YRD DUMPER), utilization 80.2%
|
||||
|
||||
get_plate_info(plate: 0)
|
||||
→ utilization: 80.2%, remnants: [{x:33.5, y:0, w:2.5, h:36}]
|
||||
|
||||
fill_remnants(plate: 0, drawing: "Converto 3 YRD DUMPER")
|
||||
→ added 3 parts, new utilization: 83.1%
|
||||
|
||||
check_overlaps(plate: 0)
|
||||
→ no overlaps
|
||||
```
|
||||
|
||||
## MCP Server Implementation
|
||||
|
||||
- .NET 8 console app using stdio transport
|
||||
- Published to `~/.claude/mcp/OpenNest.Mcp/`
|
||||
- Registered in `~/.claude/settings.local.json`
|
||||
- In-memory state: holds the current `Nest` object across tool calls
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
# Remnant Fill Optimization — Investigation & Fix
|
||||
|
||||
## Status: Both fixes done
|
||||
|
||||
## Problem 1 (FIXED): N0308-008 hinge plate remnant
|
||||
|
||||
`NestEngine.Fill(NestItem, Box)` got 7 parts in a 4.7x35.0 remnant strip where manual nesting gets 8 using a staggered brick pattern with alternating rotations.
|
||||
|
||||
### Test Case
|
||||
```
|
||||
load_nest("C:/Users/AJ/Desktop/N0308-008.zip")
|
||||
fill_remnants(0, "Converto 3 YRD DUMPERSTER HINGE PLATE #2") → was 7, now 9
|
||||
```
|
||||
Reference: `C:/Users/AJ/Desktop/N0308-008 - Copy.zip` (83 parts total, 8 in remnant).
|
||||
|
||||
### Root Cause Found
|
||||
- FillLinear rotation sweep works correctly — tested at 1° resolution, max is always 7
|
||||
- The reference uses a **staggered pair pattern** (alternating 90°/270° rotations with horizontal offset)
|
||||
- `FillWithPairs` generates ~2572 pair candidates but only tried top 50 sorted by minimum bounding area
|
||||
- The winning pair ranked ~882nd — excluded by the `Take(50)` cutoff
|
||||
- Top-50-by-area favors compact pairs for full-plate tiling, not narrow pairs suited for remnant strips
|
||||
|
||||
### Fix Applied (in `OpenNest.Engine/NestEngine.cs`)
|
||||
Added `SelectPairCandidates()` method:
|
||||
1. Always includes standard top 50 pairs by area (no change for full-plate fills)
|
||||
2. When work area is narrow (`shortSide < plateShortSide * 0.5`), includes **all** pairs whose shortest side fits the strip width
|
||||
3. Updated `FillWithPairs()` to call `SelectPairCandidates()` instead of `Take(50)`
|
||||
|
||||
### Results
|
||||
- Remnant fill: 7 → **9 parts** (beats reference of 8, with partial pattern fill)
|
||||
- Full-plate fill: 75 parts (unchanged, no regression)
|
||||
- Remnant fill time: ~440ms
|
||||
- Overlap check: PASS
|
||||
|
||||
---
|
||||
|
||||
## Problem 2 (FIXED): N0308-017 PT02 remnant
|
||||
|
||||
`N0308-017.zip` — 54 parts on a 144x72 plate. Two remnant areas:
|
||||
- Remnant 0: `(119.57, 0.75) 24.13x70.95` — end-of-sheet strip
|
||||
- Remnant 1: `(0.30, 66.15) 143.40x5.55` — bottom strip
|
||||
|
||||
Drawing "4980 A24 PT02" has bbox 10.562x15.406. Engine filled 8 parts (2 cols × 4 rows) in remnant 0. Reference (`N0308-017 - Copy.zip`) has 10 parts using alternating 0°/180° rows.
|
||||
|
||||
### Investigation
|
||||
1. Tested PT02 in remnant isolation → still 8 parts (not a multi-drawing ordering issue)
|
||||
2. Brute-forced all 7224 pair candidates → max was 8 (no pair yields >8 with full-pattern-only tiling)
|
||||
3. Tried finer offset resolution (0.05" step) across 0°/90°/180°/270° → still max 8
|
||||
4. Analyzed reference nest (`N0308-017 - Copy.zip`): **64 PT02 parts on full plate, 10 in remnant area**
|
||||
|
||||
### Root Cause Found
|
||||
The reference uses a 0°/170° staggered pair pattern that tiles in 5 rows × 2 columns:
|
||||
- Rows at y: 0.75, 14.88, 28.40, 42.53, 56.06 (alternating 0° and 170°)
|
||||
- Pattern copy distance: ~27.65" (pair tiling distance)
|
||||
- 2 full pairs = 8 parts, top at ~58.56"
|
||||
- Remaining height: 71.70 - 58.56 = ~13.14" — enough for 1 more row of 0° parts (height 15.41)
|
||||
- **But `FillLinear.TilePattern` only placed complete pattern copies**, so the partial 3rd pair (just the 0° row) was never attempted
|
||||
|
||||
The pair candidate DID exist in the candidate set and was being tried. The issue was entirely in `FillLinear.TilePattern` — it tiled 2 complete pairs (8 parts) and stopped, even though 2 more individual parts from the next incomplete pair would still fit within the work area.
|
||||
|
||||
### Fix Applied (in `OpenNest.Engine/FillLinear.cs`)
|
||||
Added **partial pattern fill** to `TilePattern()`:
|
||||
- After tiling complete pattern copies, if the pattern has multiple parts, clone the next would-be copy
|
||||
- Check each individual part's bounding box against the work area
|
||||
- Add any that fit — guaranteed no overlaps by the copy distance computation
|
||||
|
||||
This is safe because:
|
||||
- The copy distance ensures no overlaps between adjacent full copies → partial (subset) is also safe
|
||||
- Parts within the same pattern copy don't overlap by construction
|
||||
- Individual bounds checking catches parts that exceed the work area
|
||||
|
||||
### Results
|
||||
- PT02 remnant fill: 8 → **10 parts** (matches reference)
|
||||
- Hinge remnant fill: 8 → **9 parts** (bonus improvement from same fix)
|
||||
- Full-plate fill: 75 parts (unchanged, no regression)
|
||||
- All overlap checks: PASS
|
||||
- PT02 fill time: ~32s (unchanged, dominated by pair candidate evaluation)
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
- `OpenNest.Engine/NestEngine.cs` — Added `SelectPairCandidates()`, updated `FillWithPairs()`, rotation sweep (pre-existing change)
|
||||
- `OpenNest.Engine/FillLinear.cs` — Added partial pattern fill to `TilePattern()`
|
||||
|
||||
## Temp Files to Clean Up
|
||||
- `OpenNest.Test/` — temporary test console project (can be deleted or kept for debugging)
|
||||
@@ -1,382 +0,0 @@
|
||||
# Remainder Strip Re-Fill Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** After the main fill, detect oddball last column/row, remove it, and re-fill the remainder strip independently to maximize part count (30 -> 32 for the test case).
|
||||
|
||||
**Architecture:** Extract the strategy selection logic from `Fill(NestItem, Box)` into a reusable `FindBestFill` method. Add `TryRemainderImprovement` that clusters placed parts, detects oddball last cluster, computes the remainder strip box, and calls `FindBestFill` on it. Only used when it improves the count.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract FindBestFill from Fill(NestItem, Box)
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs:32-105`
|
||||
|
||||
**Step 1: Create `FindBestFill` by extracting the strategy logic**
|
||||
|
||||
Move lines 34-95 (everything except the quantity check and `Plate.Parts.AddRange`) into a new private method. `Fill` delegates to it.
|
||||
|
||||
```csharp
|
||||
private List<Part> FindBestFill(NestItem item, Box workArea)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
||||
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
|
||||
// Build candidate rotation angles — always try the best rotation and +90°.
|
||||
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
// When the work area is narrow relative to the part, sweep rotation
|
||||
// angles so we can find one that fits the part into the tight strip.
|
||||
var testPart = new Part(item.Drawing);
|
||||
|
||||
if (!bestRotation.IsEqualTo(0))
|
||||
testPart.Rotate(bestRotation);
|
||||
|
||||
testPart.UpdateBounds();
|
||||
|
||||
var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Height);
|
||||
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height);
|
||||
|
||||
if (workAreaShortSide < partLongestSide)
|
||||
{
|
||||
// Try every 5° from 0 to 175° to find rotations that fit.
|
||||
var step = Angle.ToRadians(5);
|
||||
|
||||
for (var a = 0.0; a < System.Math.PI; a += step)
|
||||
{
|
||||
if (!angles.Any(existing => existing.IsEqualTo(a)))
|
||||
angles.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var h = engine.Fill(item.Drawing, angle, NestDirection.Horizontal);
|
||||
var v = engine.Fill(item.Drawing, angle, NestDirection.Vertical);
|
||||
|
||||
if (IsBetterFill(h, best))
|
||||
best = h;
|
||||
|
||||
if (IsBetterFill(v, best))
|
||||
best = v;
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
|
||||
|
||||
// Try rectangle best-fit (mixes orientations to fill remnant strips).
|
||||
var rectResult = FillRectangleBestFit(item, workArea);
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(rectResult, best))
|
||||
best = rectResult;
|
||||
|
||||
// Try pair-based approach.
|
||||
var pairResult = FillWithPairs(item, workArea);
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts");
|
||||
|
||||
if (IsBetterFill(pairResult, best))
|
||||
best = pairResult;
|
||||
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Simplify `Fill(NestItem, Box)` to delegate**
|
||||
|
||||
```csharp
|
||||
public bool Fill(NestItem item, Box workArea)
|
||||
{
|
||||
var best = FindBestFill(item, workArea);
|
||||
|
||||
if (best == null || best.Count == 0)
|
||||
return false;
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
|
||||
Plate.Parts.AddRange(best);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build and verify no regressions**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds, no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "refactor: extract FindBestFill from Fill(NestItem, Box)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add ClusterParts helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
|
||||
**Step 1: Add the `ClusterParts` method**
|
||||
|
||||
Place after `IsBetterValidFill` (around line 287). Groups parts into positional clusters (columns or rows) based on center position gaps.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Groups parts into positional clusters along the given axis.
|
||||
/// Parts whose center positions are separated by more than half
|
||||
/// the part dimension start a new cluster.
|
||||
/// </summary>
|
||||
private static List<List<Part>> ClusterParts(List<Part> parts, bool horizontal)
|
||||
{
|
||||
var sorted = horizontal
|
||||
? parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
|
||||
: parts.OrderBy(p => p.BoundingBox.Center.Y).ToList();
|
||||
|
||||
var refDim = horizontal
|
||||
? sorted.Max(p => p.BoundingBox.Width)
|
||||
: sorted.Max(p => p.BoundingBox.Height);
|
||||
var gapThreshold = refDim * 0.5;
|
||||
|
||||
var clusters = new List<List<Part>>();
|
||||
var current = new List<Part> { sorted[0] };
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prevCenter = horizontal
|
||||
? sorted[i - 1].BoundingBox.Center.X
|
||||
: sorted[i - 1].BoundingBox.Center.Y;
|
||||
var currCenter = horizontal
|
||||
? sorted[i].BoundingBox.Center.X
|
||||
: sorted[i].BoundingBox.Center.Y;
|
||||
|
||||
if (currCenter - prevCenter > gapThreshold)
|
||||
{
|
||||
clusters.Add(current);
|
||||
current = new List<Part>();
|
||||
}
|
||||
|
||||
current.Add(sorted[i]);
|
||||
}
|
||||
|
||||
clusters.Add(current);
|
||||
return clusters;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat: add ClusterParts helper for positional grouping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add TryStripRefill and TryRemainderImprovement
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
|
||||
**Step 1: Add `TryStripRefill`**
|
||||
|
||||
This method analyzes one axis: clusters parts, checks if last cluster is an oddball, computes the strip, and fills it.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Checks whether the last column (horizontal) or row (vertical) is an
|
||||
/// oddball with fewer parts than the main grid. If so, removes those parts,
|
||||
/// computes the remainder strip, and fills it independently.
|
||||
/// Returns null if no improvement is possible.
|
||||
/// </summary>
|
||||
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> parts, bool horizontal)
|
||||
{
|
||||
var clusters = ClusterParts(parts, horizontal);
|
||||
|
||||
if (clusters.Count < 2)
|
||||
return null;
|
||||
|
||||
var lastCluster = clusters[clusters.Count - 1];
|
||||
var otherClusters = clusters.Take(clusters.Count - 1).ToList();
|
||||
|
||||
// Find the most common cluster size (mode).
|
||||
var modeCount = otherClusters
|
||||
.Select(c => c.Count)
|
||||
.GroupBy(x => x)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First().Key;
|
||||
|
||||
// Only proceed if last cluster is smaller (it's the oddball).
|
||||
if (lastCluster.Count >= modeCount)
|
||||
return null;
|
||||
|
||||
var mainParts = otherClusters.SelectMany(c => c).ToList();
|
||||
var mainBbox = ((IEnumerable<IBoundable>)mainParts).GetBoundingBox();
|
||||
|
||||
Box strip;
|
||||
|
||||
if (horizontal)
|
||||
{
|
||||
var stripLeft = mainBbox.Right + Plate.PartSpacing;
|
||||
var stripWidth = workArea.Right - stripLeft;
|
||||
|
||||
if (stripWidth < 1)
|
||||
return null;
|
||||
|
||||
strip = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stripBottom = mainBbox.Top + Plate.PartSpacing;
|
||||
var stripHeight = workArea.Top - stripBottom;
|
||||
|
||||
if (stripHeight < 1)
|
||||
return null;
|
||||
|
||||
strip = new Box(workArea.X, stripBottom, workArea.Width, stripHeight);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} strip: {strip.Width:F1}x{strip.Height:F1} | Main: {mainParts.Count} | Oddball: {lastCluster.Count}");
|
||||
|
||||
var stripParts = FindBestFill(item, strip);
|
||||
|
||||
if (stripParts == null || stripParts.Count <= lastCluster.Count)
|
||||
return null;
|
||||
|
||||
Debug.WriteLine($"[TryStripRefill] Strip fill: {stripParts.Count} parts (was {lastCluster.Count} oddball)");
|
||||
|
||||
var combined = new List<Part>(mainParts);
|
||||
combined.AddRange(stripParts);
|
||||
return combined;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add `TryRemainderImprovement`**
|
||||
|
||||
Tries both horizontal and vertical strip analysis.
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Attempts to improve a fill result by detecting an oddball last
|
||||
/// column or row and re-filling the remainder strip independently.
|
||||
/// Returns null if no improvement is found.
|
||||
/// </summary>
|
||||
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
|
||||
{
|
||||
if (currentBest == null || currentBest.Count < 3)
|
||||
return null;
|
||||
|
||||
List<Part> bestImproved = null;
|
||||
|
||||
var hImproved = TryStripRefill(item, workArea, currentBest, horizontal: true);
|
||||
|
||||
if (IsBetterFill(hImproved, bestImproved))
|
||||
bestImproved = hImproved;
|
||||
|
||||
var vImproved = TryStripRefill(item, workArea, currentBest, horizontal: false);
|
||||
|
||||
if (IsBetterFill(vImproved, bestImproved))
|
||||
bestImproved = vImproved;
|
||||
|
||||
return bestImproved;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat: add TryStripRefill and TryRemainderImprovement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire remainder improvement into Fill
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs` — the `Fill(NestItem, Box)` method
|
||||
|
||||
**Step 1: Add remainder improvement call**
|
||||
|
||||
Update `Fill(NestItem, Box)` to try improving the result after the initial fill:
|
||||
|
||||
```csharp
|
||||
public bool Fill(NestItem item, Box workArea)
|
||||
{
|
||||
var best = FindBestFill(item, workArea);
|
||||
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
|
||||
if (IsBetterFill(improved, best))
|
||||
{
|
||||
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
|
||||
best = improved;
|
||||
}
|
||||
|
||||
if (best == null || best.Count == 0)
|
||||
return false;
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
|
||||
Plate.Parts.AddRange(best);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat: wire remainder strip re-fill into Fill(NestItem, Box)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verify with MCP tools
|
||||
|
||||
**Step 1: Publish MCP server**
|
||||
|
||||
```bash
|
||||
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
|
||||
```
|
||||
|
||||
**Step 2: Test fill**
|
||||
|
||||
Use MCP tools to:
|
||||
1. Import the DXF drawing from `30pcs Fill.zip` (or create equivalent plate + drawing)
|
||||
2. Create a 96x48 plate with the same spacing (part=0.25, edges L=0.25 B=0.75 R=0.25 T=0.25)
|
||||
3. Fill the plate
|
||||
4. Verify part count is 32 (up from 30)
|
||||
5. Check for overlaps
|
||||
|
||||
**Step 3: Compare against 32pcs reference**
|
||||
|
||||
Verify the layout matches the 32pcs.zip reference — 24 parts in the main grid + 8 in the remainder strip.
|
||||
|
||||
**Step 4: Final commit if any fixups needed**
|
||||
@@ -1,417 +0,0 @@
|
||||
# OpenNest xUnit Test Suite Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Convert the ad-hoc OpenNest.Test console harness into a proper xUnit test suite with test data hosted in a separate git repo.
|
||||
|
||||
**Architecture:** Replace the console app with an xUnit test project. A `TestData` helper resolves the test data path from env var `OPENNEST_TEST_DATA` or fallback `../OpenNest.Test.Data/`. Tests skip with a message if data is missing. Test classes: `FillTests` (full plate fills), `RemnantFillTests` (filling remnant areas). All tests assert part count >= target and zero overlaps.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, xUnit, OpenNest.Core + Engine + IO
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Set up test data repo and push fixture files
|
||||
|
||||
**Step 1: Clone the empty repo next to OpenNest**
|
||||
|
||||
```bash
|
||||
cd C:/Users/AJ/Desktop/Projects
|
||||
git clone https://git.thecozycat.net/aj/OpenNest.Test.git OpenNest.Test.Data
|
||||
```
|
||||
|
||||
**Step 2: Copy fixture files into the repo**
|
||||
|
||||
```bash
|
||||
cp ~/Desktop/"N0308-017.zip" OpenNest.Test.Data/
|
||||
cp ~/Desktop/"N0308-008.zip" OpenNest.Test.Data/
|
||||
cp ~/Desktop/"30pcs Fill.zip" OpenNest.Test.Data/
|
||||
```
|
||||
|
||||
**Step 3: Commit and push**
|
||||
|
||||
```bash
|
||||
cd OpenNest.Test.Data
|
||||
git add .
|
||||
git commit -m "feat: add initial test fixture nest files"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Convert OpenNest.Test from console app to xUnit project
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Test/OpenNest.Test.csproj`
|
||||
- Delete: `OpenNest.Test/Program.cs`
|
||||
- Create: `OpenNest.Test/TestData.cs`
|
||||
|
||||
**Step 1: Replace the csproj with xUnit configuration**
|
||||
|
||||
Overwrite `OpenNest.Test/OpenNest.Test.csproj`:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||
<PackageReference Include="xunit" Version="2.*" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
```
|
||||
|
||||
**Step 2: Delete Program.cs**
|
||||
|
||||
```bash
|
||||
rm OpenNest.Test/Program.cs
|
||||
```
|
||||
|
||||
**Step 3: Create TestData helper**
|
||||
|
||||
Create `OpenNest.Test/TestData.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Test;
|
||||
|
||||
public static class TestData
|
||||
{
|
||||
private static readonly string? BasePath = ResolveBasePath();
|
||||
|
||||
public static bool IsAvailable => BasePath != null;
|
||||
|
||||
public static string SkipReason =>
|
||||
"Test data not found. Set OPENNEST_TEST_DATA env var or clone " +
|
||||
"https://git.thecozycat.net/aj/OpenNest.Test.git to ../OpenNest.Test.Data/";
|
||||
|
||||
public static string GetPath(string filename)
|
||||
{
|
||||
if (BasePath == null)
|
||||
throw new InvalidOperationException(SkipReason);
|
||||
|
||||
var path = Path.Combine(BasePath, filename);
|
||||
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"Test fixture not found: {path}");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
public static Nest LoadNest(string filename)
|
||||
{
|
||||
var reader = new NestReader(GetPath(filename));
|
||||
return reader.Read();
|
||||
}
|
||||
|
||||
public static Plate CleanPlateFrom(Plate reference)
|
||||
{
|
||||
var plate = new Plate();
|
||||
plate.Size = reference.Size;
|
||||
plate.PartSpacing = reference.PartSpacing;
|
||||
plate.EdgeSpacing = reference.EdgeSpacing;
|
||||
plate.Quadrant = reference.Quadrant;
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static string? ResolveBasePath()
|
||||
{
|
||||
// 1. Environment variable
|
||||
var envPath = Environment.GetEnvironmentVariable("OPENNEST_TEST_DATA");
|
||||
|
||||
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
|
||||
return envPath;
|
||||
|
||||
// 2. Sibling directory (../OpenNest.Test.Data/ relative to solution root)
|
||||
var dir = AppContext.BaseDirectory;
|
||||
|
||||
// Walk up from bin/Debug/net8.0-windows to find the solution root.
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
var parent = Directory.GetParent(dir);
|
||||
|
||||
if (parent == null)
|
||||
break;
|
||||
|
||||
dir = parent.FullName;
|
||||
var candidate = Path.Combine(dir, "OpenNest.Test.Data");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Build**
|
||||
|
||||
Run: `dotnet build OpenNest.Test/OpenNest.Test.csproj`
|
||||
Expected: Build succeeds.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Test/
|
||||
git commit -m "refactor: convert OpenNest.Test to xUnit project with TestData helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add FillTests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Test/FillTests.cs`
|
||||
|
||||
**Step 1: Create FillTests.cs**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Test;
|
||||
|
||||
public class FillTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public FillTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fill")]
|
||||
public void N0308_008_HingePlate_FillsAtLeast75()
|
||||
{
|
||||
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
|
||||
|
||||
var nest = TestData.LoadNest("N0308-008.zip");
|
||||
var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2"));
|
||||
var plate = TestData.CleanPlateFrom(nest.Plates[0]);
|
||||
|
||||
var engine = new NestEngine(plate);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 });
|
||||
sw.Stop();
|
||||
|
||||
_output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
Assert.True(plate.Parts.Count >= 75,
|
||||
$"Expected >= 75 parts, got {plate.Parts.Count}");
|
||||
AssertNoOverlaps(plate.Parts.ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Fill")]
|
||||
public void RemainderStripRefill_30pcs_FillsAtLeast32()
|
||||
{
|
||||
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
|
||||
|
||||
var nest = TestData.LoadNest("30pcs Fill.zip");
|
||||
var drawing = nest.Drawings.First();
|
||||
var plate = TestData.CleanPlateFrom(nest.Plates[0]);
|
||||
|
||||
var engine = new NestEngine(plate);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
engine.Fill(new NestItem { Drawing = drawing, Quantity = 0 });
|
||||
sw.Stop();
|
||||
|
||||
_output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
Assert.True(plate.Parts.Count >= 32,
|
||||
$"Expected >= 32 parts, got {plate.Parts.Count}");
|
||||
AssertNoOverlaps(plate.Parts.ToList());
|
||||
}
|
||||
|
||||
private void AssertNoOverlaps(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
Assert.Fail($"Overlap detected: part [{i}] vs [{j}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Test/ -v normal`
|
||||
Expected: 2 tests pass (or skip if data missing).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Test/FillTests.cs
|
||||
git commit -m "test: add FillTests for full plate fill and remainder strip refill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add RemnantFillTests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Test/RemnantFillTests.cs`
|
||||
|
||||
**Step 1: Create RemnantFillTests.cs**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Test;
|
||||
|
||||
public class RemnantFillTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public RemnantFillTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Remnant")]
|
||||
public void N0308_017_PT02_RemnantFillsAtLeast10()
|
||||
{
|
||||
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
|
||||
|
||||
var nest = TestData.LoadNest("N0308-017.zip");
|
||||
var plate = nest.Plates[0];
|
||||
var pt02 = nest.Drawings.First(d => d.Name.Contains("PT02"));
|
||||
var remnant = plate.GetRemnants()[0];
|
||||
|
||||
_output.WriteLine($"Remnant: ({remnant.X:F2},{remnant.Y:F2}) {remnant.Width:F2}x{remnant.Height:F2}");
|
||||
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = new NestEngine(plate);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
engine.Fill(new NestItem { Drawing = pt02, Quantity = 0 }, remnant);
|
||||
sw.Stop();
|
||||
|
||||
var added = plate.Parts.Count - countBefore;
|
||||
_output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
Assert.True(added >= 10, $"Expected >= 10 parts in remnant, got {added}");
|
||||
|
||||
var newParts = plate.Parts.Skip(countBefore).ToList();
|
||||
AssertNoOverlaps(newParts);
|
||||
AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "Remnant")]
|
||||
public void N0308_008_HingePlate_RemnantFillsAtLeast8()
|
||||
{
|
||||
Skip.IfNot(TestData.IsAvailable, TestData.SkipReason);
|
||||
|
||||
var nest = TestData.LoadNest("N0308-008.zip");
|
||||
var plate = nest.Plates[0];
|
||||
var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2"));
|
||||
var remnants = plate.GetRemnants();
|
||||
|
||||
_output.WriteLine($"Remnant 0: ({remnants[0].X:F2},{remnants[0].Y:F2}) {remnants[0].Width:F2}x{remnants[0].Height:F2}");
|
||||
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = new NestEngine(plate);
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }, remnants[0]);
|
||||
sw.Stop();
|
||||
|
||||
var added = plate.Parts.Count - countBefore;
|
||||
_output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
Assert.True(added >= 8, $"Expected >= 8 parts in remnant, got {added}");
|
||||
|
||||
var newParts = plate.Parts.Skip(countBefore).ToList();
|
||||
AssertNoOverlaps(newParts);
|
||||
AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts);
|
||||
}
|
||||
|
||||
private void AssertNoOverlaps(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
Assert.Fail($"Overlap detected: part [{i}] vs [{j}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AssertNoCrossOverlaps(List<Part> existing, List<Part> added)
|
||||
{
|
||||
for (var i = 0; i < existing.Count; i++)
|
||||
{
|
||||
for (var j = 0; j < added.Count; j++)
|
||||
{
|
||||
if (existing[i].Intersects(added[j], out _))
|
||||
Assert.Fail($"Cross-overlap: existing [{i}] vs added [{j}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Test/ -v normal`
|
||||
Expected: 4 tests pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Test/RemnantFillTests.cs
|
||||
git commit -m "test: add RemnantFillTests for remnant area filling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add test project to solution and final verification
|
||||
|
||||
**Step 1: Add to solution**
|
||||
|
||||
```bash
|
||||
cd C:/Users/AJ/Desktop/Projects/OpenNest
|
||||
dotnet sln OpenNest.sln add OpenNest.Test/OpenNest.Test.csproj
|
||||
```
|
||||
|
||||
**Step 2: Build entire solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: All projects build, 0 errors.
|
||||
|
||||
**Step 3: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.sln -v normal`
|
||||
Expected: 4 tests pass, 0 failures.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.sln
|
||||
git commit -m "chore: add OpenNest.Test to solution"
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
# GPU Pair Evaluator — Overlap Detection Bug
|
||||
|
||||
**Date**: 2026-03-10
|
||||
**Status**: RESOLVED — commit b55aa7a
|
||||
|
||||
## Problem
|
||||
|
||||
The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization).
|
||||
|
||||
## Root Cause (identified but not yet fully fixed)
|
||||
|
||||
The bitmap coordinate system doesn't match the `Part2Offset` coordinate system.
|
||||
|
||||
### How Part2Offset is computed
|
||||
`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which:
|
||||
1. Clones the drawing's program
|
||||
2. Rotates it
|
||||
3. Calls `Program.BoundingBox()` to get the bbox
|
||||
4. Offsets by `-bbox.Location` to normalize to origin
|
||||
|
||||
`Part2Offset` is the final position of Part2 in this **normalized** coordinate space.
|
||||
|
||||
### How bitmaps are rasterized
|
||||
`PartBitmap.FromDrawing` / `FromDrawingRotated`:
|
||||
1. Extracts closed polygons from the drawing (filters out rapids, open shapes)
|
||||
2. Rotates them (for B)
|
||||
3. Rasterizes with `OriginX/Y = polygon min`
|
||||
|
||||
### The mismatch
|
||||
`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means:
|
||||
- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change
|
||||
- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3
|
||||
- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space
|
||||
|
||||
For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0).
|
||||
|
||||
## What we tried
|
||||
|
||||
### Attempt 1: BlitPair approach (correct but too slow)
|
||||
- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid
|
||||
- Eliminated all offset math from the kernel (trivial element-wise AND)
|
||||
- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds.
|
||||
|
||||
### Attempt 2: Integer offsets with gap correction
|
||||
- Kept shared-bitmap approach (one A + one B per rotation group)
|
||||
- Changed offsets from `float` to `int` with `Math.Round()` on CPU
|
||||
- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location`
|
||||
- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice.
|
||||
|
||||
### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state)
|
||||
- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()`
|
||||
- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin`
|
||||
- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize`
|
||||
- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap.
|
||||
|
||||
## Current state of code
|
||||
|
||||
### Files modified
|
||||
|
||||
**`OpenNest.Gpu/PartBitmap.cs`**:
|
||||
- Added `BlitPair()` static method (from attempt 1, still present but unused)
|
||||
- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize
|
||||
- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes
|
||||
|
||||
**`OpenNest.Gpu/GpuPairEvaluator.cs`**:
|
||||
- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated`
|
||||
- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU
|
||||
- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1`
|
||||
- `PadBitmap` helper restored
|
||||
- Removed the old `NestingKernel` with float offsets
|
||||
|
||||
**`OpenNest/Forms/MainForm.cs`**:
|
||||
- Added `using OpenNest.Engine.BestFit;`
|
||||
- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);`
|
||||
|
||||
## Next steps to debug
|
||||
|
||||
1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate:
|
||||
- Print bitmapA: OriginX, OriginY, Width, Height
|
||||
- Print bitmapB: OriginX, OriginY, Width, Height
|
||||
- Print the computed integer offset
|
||||
- Print the overlap count from the kernel
|
||||
- Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate
|
||||
|
||||
2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**:
|
||||
- `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()`
|
||||
- If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong
|
||||
- Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)?
|
||||
|
||||
3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match.
|
||||
|
||||
4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong.
|
||||
|
||||
5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A.
|
||||
|
||||
## Performance notes
|
||||
- CPU evaluator: 25.0s compute, 5954 kept, correct results
|
||||
- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps
|
||||
- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group
|
||||
- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group
|
||||
|
||||
## Key files to reference
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator
|
||||
- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference)
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values
|
||||
- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()`
|
||||
- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0)
|
||||
- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in
|
||||
- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up
|
||||
@@ -1,475 +0,0 @@
|
||||
# FillScore Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace raw part-count comparisons with a structured FillScore (count → largest usable remnant → density) and expand remainder strip rotation coverage so denser pair patterns can win.
|
||||
|
||||
**Architecture:** New `FillScore` readonly struct with lexicographic comparison. Thread `workArea` parameter through `NestEngine` comparison methods. Expand `FillLinear.FillRemainingStrip` to try 0° and 90° in addition to seed rotations.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, OpenNest.Engine
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: FillScore and NestEngine Integration
|
||||
|
||||
### Task 1: Create FillScore struct
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/FillScore.cs`
|
||||
|
||||
- [ ] **Step 1: Create FillScore.cs**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum short-side dimension for a remnant to be considered usable.
|
||||
/// </summary>
|
||||
public const double MinRemnantDimension = 12.0;
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Area of the largest remnant whose short side >= MinRemnantDimension.
|
||||
/// Zero if no usable remnant exists.
|
||||
/// </summary>
|
||||
public double UsableRemnantArea { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double usableRemnantArea, double density)
|
||||
{
|
||||
Count = count;
|
||||
UsableRemnantArea = usableRemnantArea;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a fill score from placed parts and the work area they were placed in.
|
||||
/// </summary>
|
||||
public static FillScore Compute(List<Part> parts, Box workArea)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in parts)
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var bboxArea = bbox.Area();
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea);
|
||||
|
||||
return new FillScore(parts.Count, usableRemnantArea, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
|
||||
/// by checking right and top edge strips between placed parts and the work area boundary.
|
||||
/// </summary>
|
||||
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
|
||||
{
|
||||
var maxRight = double.MinValue;
|
||||
var maxTop = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Right > maxRight)
|
||||
maxRight = bb.Right;
|
||||
|
||||
if (bb.Top > maxTop)
|
||||
maxTop = bb.Top;
|
||||
}
|
||||
|
||||
var largest = 0.0;
|
||||
|
||||
// Right strip
|
||||
if (maxRight < workArea.Right)
|
||||
{
|
||||
var width = workArea.Right - maxRight;
|
||||
var height = workArea.Height;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
// Top strip
|
||||
if (maxTop < workArea.Top)
|
||||
{
|
||||
var width = workArea.Width;
|
||||
var height = workArea.Top - maxTop;
|
||||
|
||||
if (System.Math.Min(width, height) >= MinRemnantDimension)
|
||||
largest = System.Math.Max(largest, width * height);
|
||||
}
|
||||
|
||||
return largest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then usable remnant area, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = UsableRemnantArea.CompareTo(other.UsableRemnantArea);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return Density.CompareTo(other.Density);
|
||||
}
|
||||
|
||||
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
|
||||
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
|
||||
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
|
||||
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillScore.cs
|
||||
git commit -m "feat: add FillScore struct with lexicographic comparison"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update NestEngine to use FillScore
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs`
|
||||
|
||||
This task threads `workArea` through the comparison methods and replaces the inline logic with `FillScore`.
|
||||
|
||||
- [ ] **Step 1: Replace IsBetterFill**
|
||||
|
||||
Replace the existing `IsBetterFill` method (lines 299-315) with:
|
||||
|
||||
```csharp
|
||||
private 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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace IsBetterValidFill**
|
||||
|
||||
Replace the existing `IsBetterValidFill` method (lines 317-323) with:
|
||||
|
||||
```csharp
|
||||
private bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
|
||||
return false;
|
||||
|
||||
return IsBetterFill(candidate, current, workArea);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all IsBetterFill call sites in FindBestFill**
|
||||
|
||||
In `FindBestFill` (lines 55-121), the `workArea` parameter is already available. Update each call:
|
||||
|
||||
```csharp
|
||||
// Line 95 — was: if (IsBetterFill(h, best))
|
||||
if (IsBetterFill(h, best, workArea))
|
||||
|
||||
// Line 98 — was: if (IsBetterFill(v, best))
|
||||
if (IsBetterFill(v, best, workArea))
|
||||
|
||||
// Line 109 — was: if (IsBetterFill(rectResult, best))
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
|
||||
// Line 117 — was: if (IsBetterFill(pairResult, best))
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update IsBetterFill call sites in Fill(NestItem, Box)**
|
||||
|
||||
In `Fill(NestItem item, Box workArea)` (lines 32-53):
|
||||
|
||||
```csharp
|
||||
// Line 39 — was: if (IsBetterFill(improved, best))
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update call sites in Fill(List\<Part\>, Box)**
|
||||
|
||||
In `Fill(List<Part> groupParts, Box workArea)` (lines 123-166):
|
||||
|
||||
```csharp
|
||||
// Line 141 — was: if (IsBetterFill(rectResult, best))
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
|
||||
// Line 148 — was: if (IsBetterFill(pairResult, best))
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
|
||||
// Line 154 — was: if (IsBetterFill(improved, best))
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update FillPattern to accept and pass workArea**
|
||||
|
||||
Change the signature and update calls inside:
|
||||
|
||||
```csharp
|
||||
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
|
||||
if (IsBetterValidFill(h, best, workArea))
|
||||
best = h;
|
||||
|
||||
if (IsBetterValidFill(v, best, workArea))
|
||||
best = v;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update FillPattern call sites**
|
||||
|
||||
Two call sites — both have `workArea` available:
|
||||
|
||||
In `Fill(List<Part> groupParts, Box workArea)` (line 130):
|
||||
```csharp
|
||||
// was: var best = FillPattern(engine, groupParts, angles);
|
||||
var best = FillPattern(engine, groupParts, angles, workArea);
|
||||
```
|
||||
|
||||
In `FillWithPairs` (line 216):
|
||||
```csharp
|
||||
// was: var filled = FillPattern(engine, pairParts, angles);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update FillWithPairs to use FillScore**
|
||||
|
||||
Replace the `ConcurrentBag` and comparison logic (lines 208-228):
|
||||
|
||||
```csharp
|
||||
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, candidates.Count, i =>
|
||||
{
|
||||
var result = candidates[i];
|
||||
var pairParts = result.BuildParts(item.Drawing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
if (filled != null && filled.Count > 0)
|
||||
resultBag.Add((FillScore.Compute(filled, workArea), filled));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var (score, parts) in resultBag)
|
||||
{
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = parts;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Update TryRemainderImprovement call sites**
|
||||
|
||||
In `TryRemainderImprovement` (lines 438-456), the method already receives `workArea` — just update the internal `IsBetterFill` calls:
|
||||
|
||||
```csharp
|
||||
// Line 447 — was: if (IsBetterFill(hResult, best))
|
||||
if (IsBetterFill(hResult, best, workArea))
|
||||
|
||||
// Line 452 — was: if (IsBetterFill(vResult, best))
|
||||
if (IsBetterFill(vResult, best, workArea))
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update FillWithPairs debug logging**
|
||||
|
||||
Update the debug line after the `foreach` loop over `resultBag` (line 230):
|
||||
|
||||
```csharp
|
||||
// was: Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
```
|
||||
|
||||
Also update `FindBestFill` debug line (line 102):
|
||||
|
||||
```csharp
|
||||
// was: Debug.WriteLine($"[FindBestFill] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
|
||||
var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}");
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat: use FillScore for fill result comparisons in NestEngine"
|
||||
```
|
||||
|
||||
**Note — deliberately excluded comparisons:**
|
||||
|
||||
- `TryStripRefill` (line 424): `stripParts.Count <= lastCluster.Count` — this is a threshold check ("did the strip refill find more parts than the ragged cluster it replaced?"), not a quality comparison between two complete fills. FillScore is not meaningful here because we're comparing a fill result against a subset of existing parts.
|
||||
- `FillLinear.FillRemainingStrip` (line 436): internal sub-fill within a strip where remnant quality doesn't apply. Count-only is correct at this level.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Expanded Remainder Rotations
|
||||
|
||||
### Task 3: Expand FillRemainingStrip rotation coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/FillLinear.cs`
|
||||
|
||||
This is the change that fixes the 45→47 case. Currently `FillRemainingStrip` only tries rotations from the seed pattern. Adding 0° and 90° ensures the remainder strip can discover better orientations.
|
||||
|
||||
- [ ] **Step 1: Update FillRemainingStrip rotation loop**
|
||||
|
||||
Replace the rotation loop in `FillRemainingStrip` (lines 409-441) with:
|
||||
|
||||
```csharp
|
||||
// Build rotation set: always try cardinal orientations (0° and 90°),
|
||||
// plus any unique rotations from the seed pattern.
|
||||
var filler = new FillLinear(remainingStrip, PartSpacing);
|
||||
List<Part> best = null;
|
||||
var rotations = new List<(Drawing drawing, double rotation)>();
|
||||
|
||||
// Cardinal rotations for each unique drawing.
|
||||
var drawings = new List<Drawing>();
|
||||
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var found = false;
|
||||
|
||||
foreach (var d in drawings)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing)
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
drawings.Add(seedPart.BaseDrawing);
|
||||
}
|
||||
|
||||
foreach (var drawing in drawings)
|
||||
{
|
||||
rotations.Add((drawing, 0));
|
||||
rotations.Add((drawing, Angle.HalfPI));
|
||||
}
|
||||
|
||||
// Add seed pattern rotations that aren't already covered.
|
||||
foreach (var seedPart in seedPattern.Parts)
|
||||
{
|
||||
var skip = false;
|
||||
|
||||
foreach (var (d, r) in rotations)
|
||||
{
|
||||
if (d == seedPart.BaseDrawing && r.IsEqualTo(seedPart.Rotation))
|
||||
{
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip)
|
||||
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
|
||||
}
|
||||
|
||||
foreach (var (drawing, rotation) in rotations)
|
||||
{
|
||||
var h = filler.Fill(drawing, rotation, NestDirection.Horizontal);
|
||||
var v = filler.Fill(drawing, rotation, NestDirection.Vertical);
|
||||
|
||||
if (h != null && h.Count > 0 && (best == null || h.Count > best.Count))
|
||||
best = h;
|
||||
|
||||
if (v != null && v.Count > 0 && (best == null || v.Count > best.Count))
|
||||
best = v;
|
||||
}
|
||||
```
|
||||
|
||||
Note: The comparison inside `FillRemainingStrip` stays as count-only. This is an internal sub-fill within a strip — remnant quality doesn't apply at this level.
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillLinear.cs
|
||||
git commit -m "feat: try cardinal rotations in FillRemainingStrip for better strip fills"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full build and manual verification
|
||||
|
||||
- [ ] **Step 1: Build entire solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors
|
||||
|
||||
- [ ] **Step 2: Manual test with 4980 A24 PT02 nest**
|
||||
|
||||
Open the application, load the 4980 A24 PT02 drawing on a 60×120" plate, run Ctrl+F fill. Check Debug output for:
|
||||
1. Pattern #1 (89.7°) should now get 47 parts via expanded remainder rotations
|
||||
2. FillScore comparison should pick 47 over 45
|
||||
3. Verify no overlaps in the result
|
||||
@@ -1,367 +0,0 @@
|
||||
# OpenNest Test Harness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest.
|
||||
|
||||
**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path.
|
||||
|
||||
**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. |
|
||||
| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest |
|
||||
| Modify | `OpenNest.sln` | Add new project to solution |
|
||||
| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Console App + MCP Tool
|
||||
|
||||
### Task 1: Create the OpenNest.TestHarness project
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj`
|
||||
|
||||
- [ ] **Step 1: Create the project file**
|
||||
|
||||
Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool.
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.TestHarness</RootNamespace>
|
||||
<AssemblyName>OpenNest.TestHarness</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add project to solution**
|
||||
|
||||
```bash
|
||||
dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify it builds**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write the TestHarness Program.cs
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/Program.cs`
|
||||
|
||||
The console app does:
|
||||
1. Parse command-line args for nest file path, optional drawing name, plate index, output path
|
||||
2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file
|
||||
3. Load the nest file via `NestReader`
|
||||
4. Find the drawing and plate
|
||||
5. Clear existing parts from the plate
|
||||
6. Run `NestEngine.Fill()`
|
||||
7. Print summary (part count, utilization, log file path) to stdout
|
||||
8. Save the nest via `NestWriter`
|
||||
|
||||
- [ ] **Step 1: Write Program.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.IO;
|
||||
|
||||
// Parse arguments.
|
||||
var nestFile = args.Length > 0 ? args[0] : null;
|
||||
var drawingName = (string)null;
|
||||
var plateIndex = 0;
|
||||
var outputFile = (string)null;
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--drawing" when i + 1 < args.Length:
|
||||
drawingName = args[++i];
|
||||
break;
|
||||
case "--plate" when i + 1 < args.Length:
|
||||
plateIndex = int.Parse(args[++i]);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
outputFile = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.TestHarness <nest-file> [--drawing <name>] [--plate <index>] [--output <path>]");
|
||||
Console.Error.WriteLine(" nest-file Path to a .zip nest file");
|
||||
Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --output Output nest file path (default: <input>-result.zip)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Set up debug log file.
|
||||
var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
||||
var logWriter = new StreamWriter(logFile) { AutoFlush = true };
|
||||
Trace.Listeners.Add(new TextWriterTraceListener(logWriter));
|
||||
|
||||
// Load nest.
|
||||
var reader = new NestReader(nestFile);
|
||||
var nest = reader.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: nest file contains no plates");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (plateIndex >= nest.Plates.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var plate = nest.Plates[plateIndex];
|
||||
|
||||
// Find drawing.
|
||||
var drawing = drawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
Console.Error.WriteLine(drawingName != null
|
||||
? $"Error: drawing '{drawingName}' not found"
|
||||
: "Error: nest file contains no drawings");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear existing parts.
|
||||
var existingCount = plate.Parts.Count;
|
||||
plate.Parts.Clear();
|
||||
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}");
|
||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
||||
Console.WriteLine($"Cleared {existingCount} existing parts");
|
||||
Console.WriteLine("---");
|
||||
|
||||
// Run fill.
|
||||
var sw = Stopwatch.StartNew();
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = 0 };
|
||||
var success = engine.Fill(item);
|
||||
sw.Stop();
|
||||
|
||||
// Flush and close the log.
|
||||
Trace.Flush();
|
||||
logWriter.Dispose();
|
||||
|
||||
// Print results.
|
||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
||||
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
|
||||
Console.WriteLine($"Debug log: {logFile}");
|
||||
|
||||
// Save output.
|
||||
if (outputFile == null)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(nestFile);
|
||||
var name = Path.GetFileNameWithoutExtension(nestFile);
|
||||
outputFile = Path.Combine(dir, $"{name}-result.zip");
|
||||
}
|
||||
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
|
||||
return 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded with 0 errors.
|
||||
|
||||
- [ ] **Step 3: Run a smoke test with the real nest file**
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip"
|
||||
```
|
||||
|
||||
Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.TestHarness/ OpenNest.sln
|
||||
git commit -m "feat: add OpenNest.TestHarness console app for engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add the MCP test_engine tool
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Mcp/Tools/TestTools.cs`
|
||||
|
||||
The MCP tool:
|
||||
1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters
|
||||
2. Runs `dotnet run --project <path> -- <args>` capturing stdout (results) and stderr (errors only)
|
||||
3. Returns the summary + debug log file path (Claude can then Grep the log file)
|
||||
|
||||
Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree.
|
||||
|
||||
- [ ] **Step 1: Create TestTools.cs**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace OpenNest.Mcp.Tools
|
||||
{
|
||||
[McpServerToolType]
|
||||
public class TestTools
|
||||
{
|
||||
private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest";
|
||||
|
||||
private static readonly string HarnessProject = Path.Combine(
|
||||
SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj");
|
||||
|
||||
[McpServerTool(Name = "test_engine")]
|
||||
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
|
||||
public string TestEngine(
|
||||
[Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip",
|
||||
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
|
||||
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
|
||||
[Description("Output nest file path (default: <input>-result.zip)")] string outputFile = null)
|
||||
{
|
||||
if (!File.Exists(nestFile))
|
||||
return $"Error: nest file not found: {nestFile}";
|
||||
|
||||
var processArgs = new StringBuilder();
|
||||
processArgs.Append($"\"{nestFile}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(drawingName))
|
||||
processArgs.Append($" --drawing \"{drawingName}\"");
|
||||
|
||||
processArgs.Append($" --plate {plateIndex}");
|
||||
|
||||
if (!string.IsNullOrEmpty(outputFile))
|
||||
processArgs.Append($" --output \"{outputFile}\"");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = SolutionRoot
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(120_000);
|
||||
var stderr = stderrTask.Result;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
sb.Append(stdout.TrimEnd());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Errors ===");
|
||||
sb.Append(stderr.TrimEnd());
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Process exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
sb.AppendLine($"Error running test harness: {ex.Message}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the MCP project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Republish the MCP server**
|
||||
|
||||
```bash
|
||||
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
|
||||
```
|
||||
|
||||
Expected: Publish succeeded. The MCP server now has the `test_engine` tool.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/TestTools.cs
|
||||
git commit -m "feat: add test_engine MCP tool for iterative engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
After implementation, the workflow for iterating on FillLinear becomes:
|
||||
|
||||
1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs`
|
||||
2. **This session** calls `test_engine` (no args needed — defaults to the test nest file)
|
||||
3. The tool builds the latest code and runs it in a fresh process
|
||||
4. Returns: part count, utilization, timing, and **debug log file path**
|
||||
5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`)
|
||||
6. Repeat
|
||||
@@ -1,281 +0,0 @@
|
||||
# Contour Re-Indexing Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
|
||||
|
||||
**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point |
|
||||
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Splitting Primitives
|
||||
|
||||
### Task 1: Add `Line.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Line.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Line`**
|
||||
|
||||
Add the following method to the `Line` class (after the existing `ClosestPointTo` method):
|
||||
|
||||
```csharp
|
||||
public (Line first, Line second) SplitAt(Vector point)
|
||||
{
|
||||
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(StartPoint, point);
|
||||
|
||||
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(point, EndPoint);
|
||||
|
||||
return (first, second);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Line.cs
|
||||
git commit -m "feat: add Line.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
### Task 2: Add `Arc.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Arc.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Arc`**
|
||||
|
||||
Add the following method to the `Arc` class (after the existing `EndPoint` method):
|
||||
|
||||
```csharp
|
||||
public (Arc first, Arc second) SplitAt(Vector point)
|
||||
{
|
||||
if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon)
|
||||
return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed));
|
||||
|
||||
if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon)
|
||||
return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null);
|
||||
|
||||
var splitAngle = Angle.NormalizeRad(Center.AngleTo(point));
|
||||
|
||||
var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed);
|
||||
var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed);
|
||||
|
||||
return (firstArc, secondArc);
|
||||
}
|
||||
```
|
||||
|
||||
Key details from spec:
|
||||
- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues).
|
||||
- `splitAngle` is computed from `Center.AngleTo(point)`, normalized.
|
||||
- Both halves preserve center, radius, and `IsReversed` direction.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Arc.cs
|
||||
git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Shape.ReindexAt
|
||||
|
||||
### Task 3: Add `Shape.ReindexAt(Vector, Entity)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Shape.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ReindexAt` method to `Shape`**
|
||||
|
||||
Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201):
|
||||
|
||||
```csharp
|
||||
public Shape ReindexAt(Vector point, Entity entity)
|
||||
{
|
||||
// Circle case: return a new shape with just the circle
|
||||
if (entity is Circle)
|
||||
{
|
||||
var result = new Shape();
|
||||
result.Entities.Add(entity);
|
||||
return result;
|
||||
}
|
||||
|
||||
var i = Entities.IndexOf(entity);
|
||||
if (i < 0)
|
||||
throw new ArgumentException("Entity not found in shape", nameof(entity));
|
||||
|
||||
// Split the entity at the point
|
||||
Entity firstHalf = null;
|
||||
Entity secondHalf = null;
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (f, s) = line.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
var (f, s) = arc.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
|
||||
// Build reindexed entity list
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// secondHalf of split entity (if not null)
|
||||
if (secondHalf != null)
|
||||
entities.Add(secondHalf);
|
||||
|
||||
// Entities after the split index (wrapping)
|
||||
for (var j = i + 1; j < Entities.Count; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// Entities before the split index (wrapping)
|
||||
for (var j = 0; j < i; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// firstHalf of split entity (if not null)
|
||||
if (firstHalf != null)
|
||||
entities.Add(firstHalf);
|
||||
|
||||
var reindexed = new Shape();
|
||||
reindexed.Entities.AddRange(entities);
|
||||
return reindexed;
|
||||
}
|
||||
```
|
||||
|
||||
The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Shape.cs
|
||||
git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Wire into ContourCuttingStrategy
|
||||
|
||||
### Task 4: Add `ConvertShapeToMoves` and replace stubs
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ConvertShapeToMoves` private method**
|
||||
|
||||
Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class):
|
||||
|
||||
```csharp
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint));
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
```
|
||||
|
||||
This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape).
|
||||
|
||||
- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
// Contour re-indexing: split shape entities at closestPt so cutting
|
||||
// starts there, convert to ICode, and add to result.Codes
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 5: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
|
||||
git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,767 +0,0 @@
|
||||
# Nest File Format v2 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the XML+G-code nest file format with a single `nest.json` metadata file plus `programs/` folder inside the ZIP archive.
|
||||
|
||||
**Architecture:** Add a `NestFormat` static class containing DTO records and shared JSON options. Rewrite `NestWriter` to serialize DTOs to JSON and write programs under `programs/`. Rewrite `NestReader` to deserialize JSON and read programs from `programs/`. Public API unchanged.
|
||||
|
||||
**Tech Stack:** `System.Text.Json` (built into .NET 8, no new packages needed)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `OpenNest.IO/NestFormat.cs` | DTO records for JSON serialization + shared `JsonSerializerOptions` |
|
||||
| Rewrite | `OpenNest.IO/NestWriter.cs` | Serialize nest to JSON + write programs to `programs/` folder |
|
||||
| Rewrite | `OpenNest.IO/NestReader.cs` | Deserialize JSON + read programs from `programs/` folder |
|
||||
|
||||
No other files change. `ProgramReader.cs`, `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs`, all domain model classes, and all caller sites remain untouched.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: DTO Records and JSON Options
|
||||
|
||||
### Task 1: Create NestFormat.cs with DTO records
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.IO/NestFormat.cs`
|
||||
|
||||
These DTOs are the JSON shape — flat records that map 1:1 with the spec's JSON schema. They live in `OpenNest.IO` because they're serialization concerns, not domain model.
|
||||
|
||||
- [ ] **Step 1: Create `NestFormat.cs`**
|
||||
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public static class NestFormat
|
||||
{
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public record NestDto
|
||||
{
|
||||
public int Version { get; init; } = 2;
|
||||
public string Name { get; init; } = "";
|
||||
public string Units { get; init; } = "Inches";
|
||||
public string Customer { get; init; } = "";
|
||||
public string DateCreated { get; init; } = "";
|
||||
public string DateLastModified { get; init; } = "";
|
||||
public string Notes { get; init; } = "";
|
||||
public PlateDefaultsDto PlateDefaults { get; init; } = new();
|
||||
public List<DrawingDto> Drawings { get; init; } = new();
|
||||
public List<PlateDto> Plates { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PlateDefaultsDto
|
||||
{
|
||||
public SizeDto Size { get; init; } = new();
|
||||
public double Thickness { get; init; }
|
||||
public int Quadrant { get; init; } = 1;
|
||||
public double PartSpacing { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
}
|
||||
|
||||
public record DrawingDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public string Customer { get; init; } = "";
|
||||
public ColorDto Color { get; init; } = new();
|
||||
public QuantityDto Quantity { get; init; } = new();
|
||||
public int Priority { get; init; }
|
||||
public ConstraintsDto Constraints { get; init; } = new();
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SourceDto Source { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PlateDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public SizeDto Size { get; init; } = new();
|
||||
public double Thickness { get; init; }
|
||||
public int Quadrant { get; init; } = 1;
|
||||
public int Quantity { get; init; } = 1;
|
||||
public double PartSpacing { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PartDto
|
||||
{
|
||||
public int DrawingId { get; init; }
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
public double Rotation { get; init; }
|
||||
}
|
||||
|
||||
public record SizeDto
|
||||
{
|
||||
public double Width { get; init; }
|
||||
public double Height { get; init; }
|
||||
}
|
||||
|
||||
public record MaterialDto
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string Grade { get; init; } = "";
|
||||
public double Density { get; init; }
|
||||
}
|
||||
|
||||
public record SpacingDto
|
||||
{
|
||||
public double Left { get; init; }
|
||||
public double Top { get; init; }
|
||||
public double Right { get; init; }
|
||||
public double Bottom { get; init; }
|
||||
}
|
||||
|
||||
public record ColorDto
|
||||
{
|
||||
public int A { get; init; } = 255;
|
||||
public int R { get; init; }
|
||||
public int G { get; init; }
|
||||
public int B { get; init; }
|
||||
}
|
||||
|
||||
public record QuantityDto
|
||||
{
|
||||
public int Required { get; init; }
|
||||
}
|
||||
|
||||
public record ConstraintsDto
|
||||
{
|
||||
public double StepAngle { get; init; }
|
||||
public double StartAngle { get; init; }
|
||||
public double EndAngle { get; init; }
|
||||
public bool Allow180Equivalent { get; init; }
|
||||
}
|
||||
|
||||
public record SourceDto
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public OffsetDto Offset { get; init; } = new();
|
||||
}
|
||||
|
||||
public record OffsetDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify DTOs compile**
|
||||
|
||||
Run: `dotnet build OpenNest.IO/OpenNest.IO.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestFormat.cs
|
||||
git commit -m "feat: add NestFormat DTOs for JSON nest file format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Rewrite NestWriter
|
||||
|
||||
### Task 2: Rewrite NestWriter to use JSON serialization
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `OpenNest.IO/NestWriter.cs`
|
||||
|
||||
The writer keeps the same public API: `NestWriter(Nest nest)` constructor and `bool Write(string file)`. Internally it builds a `NestDto` from the domain model, serializes it to `nest.json`, and writes each drawing's program to `programs/program-N`.
|
||||
|
||||
The G-code writing methods (`WriteDrawing`, `GetCodeString`, `GetLayerString`) are preserved exactly — they write program G-code to streams, which is unchanged. The `WritePlate` method and all XML methods (`AddNestInfo`, `AddPlateInfo`, `AddDrawingInfo`) are removed.
|
||||
|
||||
- [ ] **Step 1: Rewrite `NestWriter.cs`**
|
||||
|
||||
Replace the entire file. Key changes:
|
||||
- Remove `using System.Xml`
|
||||
- Add `using System.Text.Json`
|
||||
- Remove `AddNestInfo()`, `AddPlateInfo()`, `AddDrawingInfo()`, `AddPlates()`, `WritePlate()` methods
|
||||
- Add `BuildNestDto()` method that maps domain model → DTOs
|
||||
- `Write()` now serializes `NestDto` to `nest.json` and writes programs to `programs/program-N`
|
||||
- Keep `WriteDrawing()`, `GetCodeString()`, `GetLayerString()` exactly as-is
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public sealed class NestWriter
|
||||
{
|
||||
private const int OutputPrecision = 10;
|
||||
private const string CoordinateFormat = "0.##########";
|
||||
|
||||
private readonly Nest nest;
|
||||
private Dictionary<int, Drawing> drawingDict;
|
||||
|
||||
public NestWriter(Nest nest)
|
||||
{
|
||||
this.drawingDict = new Dictionary<int, Drawing>();
|
||||
this.nest = nest;
|
||||
}
|
||||
|
||||
public bool Write(string file)
|
||||
{
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
SetDrawingIds();
|
||||
|
||||
using var fileStream = new FileStream(file, FileMode.Create);
|
||||
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create);
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
WritePrograms(zipArchive);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SetDrawingIds()
|
||||
{
|
||||
var id = 1;
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
drawingDict.Add(id, drawing);
|
||||
id++;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteNestJson(ZipArchive zipArchive)
|
||||
{
|
||||
var dto = BuildNestDto();
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var entry = zipArchive.CreateEntry("nest.json");
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(json);
|
||||
}
|
||||
|
||||
private NestDto BuildNestDto()
|
||||
{
|
||||
return new NestDto
|
||||
{
|
||||
Version = 2,
|
||||
Name = nest.Name ?? "",
|
||||
Units = nest.Units.ToString(),
|
||||
Customer = nest.Customer ?? "",
|
||||
DateCreated = nest.DateCreated.ToString("o"),
|
||||
DateLastModified = nest.DateLastModified.ToString("o"),
|
||||
Notes = nest.Notes ?? "",
|
||||
PlateDefaults = BuildPlateDefaultsDto(),
|
||||
Drawings = BuildDrawingDtos(),
|
||||
Plates = BuildPlateDtos()
|
||||
};
|
||||
}
|
||||
|
||||
private PlateDefaultsDto BuildPlateDefaultsDto()
|
||||
{
|
||||
var pd = nest.PlateDefaults;
|
||||
return new PlateDefaultsDto
|
||||
{
|
||||
Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height },
|
||||
Thickness = pd.Thickness,
|
||||
Quadrant = pd.Quadrant,
|
||||
PartSpacing = pd.PartSpacing,
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = pd.Material.Name ?? "",
|
||||
Grade = pd.Material.Grade ?? "",
|
||||
Density = pd.Material.Density
|
||||
},
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = pd.EdgeSpacing.Left,
|
||||
Top = pd.EdgeSpacing.Top,
|
||||
Right = pd.EdgeSpacing.Right,
|
||||
Bottom = pd.EdgeSpacing.Bottom
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private List<DrawingDto> BuildDrawingDtos()
|
||||
{
|
||||
var list = new List<DrawingDto>();
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var d = kvp.Value;
|
||||
list.Add(new DrawingDto
|
||||
{
|
||||
Id = kvp.Key,
|
||||
Name = d.Name ?? "",
|
||||
Customer = d.Customer ?? "",
|
||||
Color = new ColorDto { A = d.Color.A, R = d.Color.R, G = d.Color.G, B = d.Color.B },
|
||||
Quantity = new QuantityDto { Required = d.Quantity.Required },
|
||||
Priority = d.Priority,
|
||||
Constraints = new ConstraintsDto
|
||||
{
|
||||
StepAngle = d.Constraints.StepAngle,
|
||||
StartAngle = d.Constraints.StartAngle,
|
||||
EndAngle = d.Constraints.EndAngle,
|
||||
Allow180Equivalent = d.Constraints.Allow180Equivalent
|
||||
},
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = d.Material.Name ?? "",
|
||||
Grade = d.Material.Grade ?? "",
|
||||
Density = d.Material.Density
|
||||
},
|
||||
Source = new SourceDto
|
||||
{
|
||||
Path = d.Source.Path ?? "",
|
||||
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y }
|
||||
}
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<PlateDto> BuildPlateDtos()
|
||||
{
|
||||
var list = new List<PlateDto>();
|
||||
for (var i = 0; i < nest.Plates.Count; i++)
|
||||
{
|
||||
var plate = nest.Plates[i];
|
||||
var parts = new List<PartDto>();
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault();
|
||||
parts.Add(new PartDto
|
||||
{
|
||||
DrawingId = match.Key,
|
||||
X = part.Location.X,
|
||||
Y = part.Location.Y,
|
||||
Rotation = part.Rotation
|
||||
});
|
||||
}
|
||||
|
||||
list.Add(new PlateDto
|
||||
{
|
||||
Id = i + 1,
|
||||
Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height },
|
||||
Thickness = plate.Thickness,
|
||||
Quadrant = plate.Quadrant,
|
||||
Quantity = plate.Quantity,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
Material = new MaterialDto
|
||||
{
|
||||
Name = plate.Material.Name ?? "",
|
||||
Grade = plate.Material.Grade ?? "",
|
||||
Density = plate.Material.Density
|
||||
},
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = plate.EdgeSpacing.Left,
|
||||
Top = plate.EdgeSpacing.Top,
|
||||
Right = plate.EdgeSpacing.Right,
|
||||
Bottom = plate.EdgeSpacing.Bottom
|
||||
},
|
||||
Parts = parts
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private void WritePrograms(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var name = $"programs/program-{kvp.Key}";
|
||||
var stream = new MemoryStream();
|
||||
WriteDrawing(stream, kvp.Value);
|
||||
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using var entryStream = entry.Open();
|
||||
stream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDrawing(Stream stream, Drawing drawing)
|
||||
{
|
||||
var program = drawing.Program;
|
||||
var writer = new StreamWriter(stream);
|
||||
writer.AutoFlush = true;
|
||||
|
||||
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
|
||||
|
||||
for (var i = 0; i < drawing.Program.Length; ++i)
|
||||
{
|
||||
var code = drawing.Program[i];
|
||||
writer.WriteLine(GetCodeString(code));
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
private string GetCodeString(ICode code)
|
||||
{
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.ArcMove:
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var arcMove = (ArcMove)code;
|
||||
|
||||
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat);
|
||||
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat);
|
||||
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat);
|
||||
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat);
|
||||
|
||||
if (arcMove.Rotation == RotationType.CW)
|
||||
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j));
|
||||
else
|
||||
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
|
||||
|
||||
if (arcMove.Layer != LayerType.Cut)
|
||||
sb.Append(GetLayerString(arcMove.Layer));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
case CodeType.Comment:
|
||||
{
|
||||
var comment = (Comment)code;
|
||||
return ":" + comment.Value;
|
||||
}
|
||||
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var linearMove = (LinearMove)code;
|
||||
|
||||
sb.Append(string.Format("G01X{0}Y{1}",
|
||||
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
|
||||
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
|
||||
|
||||
if (linearMove.Layer != LayerType.Cut)
|
||||
sb.Append(GetLayerString(linearMove.Layer));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
case CodeType.RapidMove:
|
||||
{
|
||||
var rapidMove = (RapidMove)code;
|
||||
|
||||
return string.Format("G00X{0}Y{1}",
|
||||
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
|
||||
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
|
||||
}
|
||||
|
||||
case CodeType.SetFeedrate:
|
||||
{
|
||||
var setFeedrate = (Feedrate)code;
|
||||
return "F" + setFeedrate.Value;
|
||||
}
|
||||
|
||||
case CodeType.SetKerf:
|
||||
{
|
||||
var setKerf = (Kerf)code;
|
||||
|
||||
switch (setKerf.Value)
|
||||
{
|
||||
case KerfType.None: return "G40";
|
||||
case KerfType.Left: return "G41";
|
||||
case KerfType.Right: return "G42";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
var subProgramCall = (SubProgramCall)code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string GetLayerString(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Display:
|
||||
return ":DISPLAY";
|
||||
|
||||
case LayerType.Leadin:
|
||||
return ":LEADIN";
|
||||
|
||||
case LayerType.Leadout:
|
||||
return ":LEADOUT";
|
||||
|
||||
case LayerType.Scribe:
|
||||
return ":SCRIBE";
|
||||
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify NestWriter compiles**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestWriter.cs
|
||||
git commit -m "feat: rewrite NestWriter to use JSON format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Rewrite NestReader
|
||||
|
||||
### Task 3: Rewrite NestReader to use JSON deserialization
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `OpenNest.IO/NestReader.cs`
|
||||
|
||||
The reader keeps the same public API: `NestReader(string file)`, `NestReader(Stream stream)`, and `Nest Read()`. Internally it reads `nest.json`, deserializes to `NestDto`, reads programs from `programs/program-N`, and assembles the domain model.
|
||||
|
||||
All XML parsing, plate G-code parsing, dictionary-linking (`LinkProgramsToDrawings`, `LinkPartsToPlates`), and the helper enums/methods are removed.
|
||||
|
||||
- [ ] **Step 1: Rewrite `NestReader.cs`**
|
||||
|
||||
Replace the entire file:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public sealed class NestReader
|
||||
{
|
||||
private readonly Stream stream;
|
||||
private readonly ZipArchive zipArchive;
|
||||
|
||||
public NestReader(string file)
|
||||
{
|
||||
stream = new FileStream(file, FileMode.Open, FileAccess.Read);
|
||||
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
}
|
||||
|
||||
public NestReader(Stream stream)
|
||||
{
|
||||
this.stream = stream;
|
||||
zipArchive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
}
|
||||
|
||||
public Nest Read()
|
||||
{
|
||||
var nestJson = ReadEntry("nest.json");
|
||||
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
|
||||
|
||||
var programs = ReadPrograms(dto.Drawings.Count);
|
||||
var drawingMap = BuildDrawings(dto, programs);
|
||||
var nest = BuildNest(dto, drawingMap);
|
||||
|
||||
zipArchive.Dispose();
|
||||
stream.Close();
|
||||
|
||||
return nest;
|
||||
}
|
||||
|
||||
private string ReadEntry(string name)
|
||||
{
|
||||
var entry = zipArchive.GetEntry(name)
|
||||
?? throw new InvalidDataException($"Nest file is missing required entry '{name}'.");
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private Dictionary<int, Program> ReadPrograms(int count)
|
||||
{
|
||||
var programs = new Dictionary<int, Program>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"programs/program-{i}");
|
||||
if (entry == null) continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
var memStream = new MemoryStream();
|
||||
entryStream.CopyTo(memStream);
|
||||
memStream.Position = 0;
|
||||
|
||||
var reader = new ProgramReader(memStream);
|
||||
programs[i] = reader.Read();
|
||||
}
|
||||
return programs;
|
||||
}
|
||||
|
||||
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
|
||||
{
|
||||
var map = new Dictionary<int, Drawing>();
|
||||
foreach (var d in dto.Drawings)
|
||||
{
|
||||
var drawing = new Drawing(d.Name);
|
||||
drawing.Customer = d.Customer;
|
||||
drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B);
|
||||
drawing.Quantity.Required = d.Quantity.Required;
|
||||
drawing.Priority = d.Priority;
|
||||
drawing.Constraints.StepAngle = d.Constraints.StepAngle;
|
||||
drawing.Constraints.StartAngle = d.Constraints.StartAngle;
|
||||
drawing.Constraints.EndAngle = d.Constraints.EndAngle;
|
||||
drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent;
|
||||
drawing.Material = new Material(d.Material.Name, d.Material.Grade, d.Material.Density);
|
||||
drawing.Source.Path = d.Source.Path;
|
||||
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
|
||||
|
||||
if (programs.TryGetValue(d.Id, out var pgm))
|
||||
drawing.Program = pgm;
|
||||
|
||||
map[d.Id] = drawing;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = dto.Name;
|
||||
|
||||
Units units;
|
||||
if (Enum.TryParse(dto.Units, true, out units))
|
||||
nest.Units = units;
|
||||
|
||||
nest.Customer = dto.Customer;
|
||||
nest.DateCreated = DateTime.Parse(dto.DateCreated);
|
||||
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
|
||||
nest.Notes = dto.Notes;
|
||||
|
||||
// Plate defaults
|
||||
var pd = dto.PlateDefaults;
|
||||
nest.PlateDefaults.Size = new Size(pd.Size.Width, pd.Size.Height);
|
||||
nest.PlateDefaults.Thickness = pd.Thickness;
|
||||
nest.PlateDefaults.Quadrant = pd.Quadrant;
|
||||
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
|
||||
nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density);
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top);
|
||||
|
||||
// Drawings
|
||||
foreach (var d in drawingMap.OrderBy(k => k.Key))
|
||||
nest.Drawings.Add(d.Value);
|
||||
|
||||
// Plates
|
||||
foreach (var p in dto.Plates.OrderBy(p => p.Id))
|
||||
{
|
||||
var plate = new Plate();
|
||||
plate.Size = new Size(p.Size.Width, p.Size.Height);
|
||||
plate.Thickness = p.Thickness;
|
||||
plate.Quadrant = p.Quadrant;
|
||||
plate.Quantity = p.Quantity;
|
||||
plate.PartSpacing = p.PartSpacing;
|
||||
plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density);
|
||||
plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top);
|
||||
|
||||
foreach (var partDto in p.Parts)
|
||||
{
|
||||
if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg))
|
||||
continue;
|
||||
|
||||
var part = new Part(dwg);
|
||||
part.Rotate(partDto.Rotation);
|
||||
part.Offset(new Vector(partDto.X, partDto.Y));
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
|
||||
return nest;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify NestReader compiles**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.IO/NestReader.cs
|
||||
git commit -m "feat: rewrite NestReader to use JSON format v2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Smoke Test
|
||||
|
||||
### Task 4: Manual smoke test via OpenNest.Console
|
||||
|
||||
**Files:** None modified — this is a verification step.
|
||||
|
||||
Use the `OpenNest.Console` project (or the MCP server) to verify round-trip: create a nest, save it, reload it, confirm data is intact.
|
||||
|
||||
- [ ] **Step 1: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors.
|
||||
|
||||
- [ ] **Step 2: Round-trip test via MCP tools**
|
||||
|
||||
Use the OpenNest MCP tools to:
|
||||
1. Create a drawing (e.g. a rectangle via `create_drawing`)
|
||||
2. Create a plate via `create_plate`
|
||||
3. Fill the plate via `fill_plate`
|
||||
4. Save the nest via the console app or verify `get_plate_info` shows parts
|
||||
5. If a nest file exists on disk, load it with `load_nest` and verify `get_plate_info` returns the same data
|
||||
|
||||
- [ ] **Step 3: Inspect the ZIP contents**
|
||||
|
||||
Unzip a saved nest file and verify:
|
||||
- `nest.json` exists with correct structure
|
||||
- `programs/program-1` (etc.) exist with G-code content
|
||||
- No `info`, `drawing-info`, `plate-info`, or `plate-NNN` files exist
|
||||
|
||||
- [ ] **Step 4: Commit any fixes**
|
||||
|
||||
If any issues were found and fixed, commit them:
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "fix: address issues found during nest format v2 smoke test"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
# Abstract Nest Engine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor the concrete `NestEngine` into an abstract `NestEngineBase` with pluggable implementations, a registry for engine discovery/selection, and plugin loading from DLLs.
|
||||
|
||||
**Architecture:** Extract shared state and utilities into `NestEngineBase` (abstract). Current logic becomes `DefaultNestEngine`. `NestEngineRegistry` provides factory creation, built-in registration, and DLL plugin discovery. All callsites migrate from `new NestEngine(plate)` to `NestEngineRegistry.Create(plate)`.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Mcp, OpenNest.Console
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md`
|
||||
|
||||
**Deferred:** `StripNester.cs` → `StripNestEngine.cs` conversion is deferred to the strip nester implementation plan (`docs/superpowers/plans/2026-03-15-strip-nester.md`). That plan should be updated to create `StripNestEngine` as a `NestEngineBase` subclass and register it in `NestEngineRegistry`. The UI engine selector combobox is also deferred — it can be added once there are multiple engines to choose from.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: NestEngineBase and DefaultNestEngine
|
||||
|
||||
### Task 1: Create NestEngineBase abstract class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineBase.cs`
|
||||
|
||||
This is the abstract base class. It holds shared properties, abstract `Name`/`Description`, virtual methods that return empty lists by default, convenience overloads that mutate the plate, `FillExact` (non-virtual), and protected utility methods extracted from the current `NestEngine`.
|
||||
|
||||
- [ ] **Step 1: Create NestEngineBase.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public abstract class NestEngineBase
|
||||
{
|
||||
protected NestEngineBase(Plate plate)
|
||||
{
|
||||
Plate = plate;
|
||||
}
|
||||
|
||||
public Plate Plate { get; set; }
|
||||
|
||||
public int PlateNumber { get; set; }
|
||||
|
||||
public NestDirection NestDirection { get; set; }
|
||||
|
||||
public NestPhase WinnerPhase { get; protected set; }
|
||||
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract string Description { get; }
|
||||
|
||||
// --- Virtual methods (side-effect-free, return parts) ---
|
||||
|
||||
public virtual List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
public virtual List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return new List<Part>();
|
||||
}
|
||||
|
||||
// --- FillExact (non-virtual, delegates to virtual Fill) ---
|
||||
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
return Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
// --- Convenience overloads (mutate plate, return bool) ---
|
||||
|
||||
public bool Fill(NestItem item)
|
||||
{
|
||||
return Fill(item, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(NestItem item, Box workArea)
|
||||
{
|
||||
var parts = Fill(item, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts)
|
||||
{
|
||||
return Fill(groupParts, Plate.WorkArea());
|
||||
}
|
||||
|
||||
public bool Fill(List<Part> groupParts, Box workArea)
|
||||
{
|
||||
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Pack(List<NestItem> items)
|
||||
{
|
||||
var workArea = Plate.WorkArea();
|
||||
var parts = PackArea(workArea, items, null, CancellationToken.None);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return false;
|
||||
|
||||
Plate.Parts.AddRange(parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Protected utilities ---
|
||||
|
||||
protected static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description)
|
||||
{
|
||||
if (progress == null || best == null || best.Count == 0)
|
||||
return;
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
var bounds = best.GetBoundingBox();
|
||||
|
||||
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
|
||||
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
|
||||
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
|
||||
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
|
||||
Debug.WriteLine(msg);
|
||||
try { System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
BestParts = clonedParts,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
|
||||
protected string BuildProgressSummary()
|
||||
{
|
||||
if (PhaseResults.Count == 0)
|
||||
return null;
|
||||
|
||||
var parts = new List<string>(PhaseResults.Count);
|
||||
|
||||
foreach (var r in PhaseResults)
|
||||
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
|
||||
{
|
||||
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsBetterFill(candidate, current, workArea);
|
||||
}
|
||||
|
||||
protected static bool HasOverlaps(List<Part> parts, double spacing)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var box1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var box2 = parts[j].BoundingBox;
|
||||
|
||||
if (box1.Right < box2.Left || box2.Right < box1.Left ||
|
||||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
|
||||
continue;
|
||||
|
||||
List<Vector> pts;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out pts))
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
var b2 = parts[j].BoundingBox;
|
||||
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
|
||||
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
|
||||
$" intersections={pts?.Count ?? 0}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string FormatPhaseName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "feat: add NestEngineBase abstract class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Convert NestEngine to DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Rename: `OpenNest.Engine/NestEngine.cs` → `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
Rename the class, make it inherit `NestEngineBase`, add `Name`/`Description`, change the virtual methods to `override`, and remove methods that now live in the base class (convenience overloads, `ReportProgress`, `BuildProgressSummary`, `IsBetterFill`, `IsBetterValidFill`, `HasOverlaps`, `FormatPhaseName`, `FillExact`).
|
||||
|
||||
- [ ] **Step 1: Rename the file**
|
||||
|
||||
```bash
|
||||
git mv OpenNest.Engine/NestEngine.cs OpenNest.Engine/DefaultNestEngine.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update class declaration and add inheritance**
|
||||
|
||||
In `DefaultNestEngine.cs`, change the class declaration from:
|
||||
|
||||
```csharp
|
||||
public class NestEngine
|
||||
{
|
||||
public NestEngine(Plate plate)
|
||||
{
|
||||
Plate = plate;
|
||||
}
|
||||
|
||||
public Plate Plate { get; set; }
|
||||
|
||||
public NestDirection NestDirection { get; set; }
|
||||
|
||||
public int PlateNumber { get; set; }
|
||||
|
||||
public NestPhase WinnerPhase { get; private set; }
|
||||
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```csharp
|
||||
public class DefaultNestEngine : NestEngineBase
|
||||
{
|
||||
public DefaultNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Default";
|
||||
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
|
||||
public bool ForceFullAngleSweep { get; set; }
|
||||
```
|
||||
|
||||
This removes properties that now come from the base class (`Plate`, `PlateNumber`, `NestDirection`, `WinnerPhase`, `PhaseResults`, `AngleResults`).
|
||||
|
||||
- [ ] **Step 3: Convert the convenience Fill overloads to override the virtual methods**
|
||||
|
||||
Remove the non-progress `Fill` convenience overloads (they are now in the base class). The two remaining `Fill` methods that take `IProgress<NestProgress>` and `CancellationToken` become overrides.
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
```
|
||||
|
||||
Remove these methods entirely (now in base class):
|
||||
- `bool Fill(NestItem item)` (2-arg convenience)
|
||||
- `bool Fill(NestItem item, Box workArea)` (convenience that calls the 4-arg)
|
||||
- `bool Fill(List<Part> groupParts)` (convenience)
|
||||
- `bool Fill(List<Part> groupParts, Box workArea)` (convenience that calls the 4-arg)
|
||||
- `FillExact` (now in base class)
|
||||
- `ReportProgress` (now in base class)
|
||||
- `BuildProgressSummary` (now in base class)
|
||||
- `IsBetterFill` (now in base class)
|
||||
- `IsBetterValidFill` (now in base class)
|
||||
- `HasOverlaps` (now in base class)
|
||||
- `FormatPhaseName` (now in base class)
|
||||
|
||||
- [ ] **Step 4: Convert Pack/PackArea to override**
|
||||
|
||||
Remove `Pack(List<NestItem>)` (now in base class).
|
||||
|
||||
Convert `PackArea` to override with the new signature. Replace:
|
||||
|
||||
```csharp
|
||||
public bool Pack(List<NestItem> items)
|
||||
{
|
||||
var workArea = Plate.WorkArea();
|
||||
return PackArea(workArea, items);
|
||||
}
|
||||
|
||||
public bool PackArea(Box box, List<NestItem> items)
|
||||
{
|
||||
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
|
||||
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
|
||||
|
||||
var engine = new PackBottomLeft(bin);
|
||||
engine.Pack(binItems);
|
||||
|
||||
var parts = BinConverter.ToParts(bin, items);
|
||||
Plate.Parts.AddRange(parts);
|
||||
|
||||
return parts.Count > 0;
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
|
||||
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
|
||||
|
||||
var engine = new PackBottomLeft(bin);
|
||||
engine.Pack(binItems);
|
||||
|
||||
return BinConverter.ToParts(bin, items);
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `progress` and `token` parameters are not used yet in the default rectangle packing — the contract is there for engines that need them.
|
||||
|
||||
- [ ] **Step 5: Update BruteForceRunner to use DefaultNestEngine**
|
||||
|
||||
`BruteForceRunner.cs` is in the same project and still references `NestEngine`. It must be updated before the Engine project can compile. This is the one callsite that stays as a direct `DefaultNestEngine` reference (not via registry) because training data must come from the known algorithm.
|
||||
|
||||
In `OpenNest.Engine/ML/BruteForceRunner.cs`, change line 30:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```csharp
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded (other projects will have errors since their callsites still reference `NestEngine` — fixed in Chunk 3)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/ML/BruteForceRunner.cs
|
||||
git commit -m "refactor: rename NestEngine to DefaultNestEngine, inherit NestEngineBase"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: NestEngineRegistry and NestEngineInfo
|
||||
|
||||
### Task 3: Create NestEngineInfo
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineInfo.cs`
|
||||
|
||||
- [ ] **Step 1: Create NestEngineInfo.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class NestEngineInfo
|
||||
{
|
||||
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
Factory = factory;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public Func<Plate, NestEngineBase> Factory { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineInfo.cs
|
||||
git commit -m "feat: add NestEngineInfo metadata class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create NestEngineRegistry
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/NestEngineRegistry.cs`
|
||||
|
||||
Static class with built-in registration, plugin loading, active engine selection, and factory creation.
|
||||
|
||||
- [ ] **Step 1: Create NestEngineRegistry.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class NestEngineRegistry
|
||||
{
|
||||
private static readonly List<NestEngineInfo> engines = new();
|
||||
|
||||
static NestEngineRegistry()
|
||||
{
|
||||
Register("Default",
|
||||
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
plate => new DefaultNestEngine(plate));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
|
||||
|
||||
public static string ActiveEngineName { get; set; } = "Default";
|
||||
|
||||
public static NestEngineBase Create(Plate plate)
|
||||
{
|
||||
var info = engines.FirstOrDefault(e =>
|
||||
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
|
||||
info = engines[0];
|
||||
}
|
||||
|
||||
return info.Factory(plate);
|
||||
}
|
||||
|
||||
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
|
||||
{
|
||||
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(new[] { typeof(Plate) });
|
||||
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a temporary instance to read Name and Description.
|
||||
try
|
||||
{
|
||||
var tempPlate = new Plate();
|
||||
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
|
||||
Register(instance.Name, instance.Description,
|
||||
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
|
||||
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineRegistry.cs
|
||||
git commit -m "feat: add NestEngineRegistry with built-in registration and plugin loading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Callsite Migration
|
||||
|
||||
### Task 5: Migrate OpenNest.Mcp callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
|
||||
|
||||
Six `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`. The `PackArea` call on line 276 changes signature since `PackArea` now returns `List<Part>` instead of mutating the plate.
|
||||
|
||||
- [ ] **Step 1: Replace all NestEngine instantiations**
|
||||
|
||||
In `NestingTools.cs`, replace all six occurrences of `new NestEngine(plate)` with `NestEngineRegistry.Create(plate)`.
|
||||
|
||||
Lines to change:
|
||||
- Line 37: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 73: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 114: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 176: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 255: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 275: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 2: Fix PackArea call in AutoNestPlate**
|
||||
|
||||
The old code on line 276 was:
|
||||
```csharp
|
||||
engine.PackArea(workArea, packItems);
|
||||
```
|
||||
|
||||
This used the old `bool PackArea(Box, List<NestItem>)` which mutated the plate. The new virtual method returns `List<Part>`. Use the convenience `Pack`-like pattern instead. Replace lines 274-277:
|
||||
|
||||
```csharp
|
||||
var before = plate.Parts.Count;
|
||||
var engine = new NestEngine(plate);
|
||||
engine.PackArea(workArea, packItems);
|
||||
totalPlaced += plate.Parts.Count - before;
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
if (packParts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(packParts);
|
||||
totalPlaced += packParts.Count;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build OpenNest.Mcp**
|
||||
|
||||
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "refactor: migrate NestingTools to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Migrate OpenNest.Console callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Console/Program.cs`
|
||||
|
||||
Three `new NestEngine(plate)` calls. The `PackArea` call also needs the same signature update.
|
||||
|
||||
- [ ] **Step 1: Replace NestEngine instantiations**
|
||||
|
||||
In `Program.cs`, replace:
|
||||
- Line 351: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 380: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 2: Fix PackArea call**
|
||||
|
||||
Replace lines 370-372:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
var before = plate.Parts.Count;
|
||||
engine.PackArea(workArea, packItems);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(packParts);
|
||||
```
|
||||
|
||||
And update line 374-375 from:
|
||||
```csharp
|
||||
if (plate.Parts.Count > before)
|
||||
success = true;
|
||||
```
|
||||
To:
|
||||
```csharp
|
||||
if (packParts.Count > 0)
|
||||
success = true;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build OpenNest.Console**
|
||||
|
||||
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Console/Program.cs
|
||||
git commit -m "refactor: migrate Console Program to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Migrate OpenNest WinForms callsites
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Actions/ActionFillArea.cs`
|
||||
- Modify: `OpenNest/Controls/PlateView.cs`
|
||||
- Modify: `OpenNest/Forms/MainForm.cs`
|
||||
|
||||
- [ ] **Step 1: Migrate ActionFillArea.cs**
|
||||
|
||||
In `ActionFillArea.cs`, replace both `new NestEngine(plateView.Plate)` calls:
|
||||
- Line 50: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);`
|
||||
- Line 64: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);`
|
||||
|
||||
- [ ] **Step 2: Migrate PlateView.cs**
|
||||
|
||||
In `PlateView.cs`, replace:
|
||||
- Line 836: `var engine = new NestEngine(Plate);` → `var engine = NestEngineRegistry.Create(Plate);`
|
||||
|
||||
- [ ] **Step 3: Migrate MainForm.cs**
|
||||
|
||||
In `MainForm.cs`, replace all three `new NestEngine(plate)` calls:
|
||||
- Line 797: `var engine = new NestEngine(plate) { PlateNumber = plateCount };` → `var engine = NestEngineRegistry.Create(plate); engine.PlateNumber = plateCount;`
|
||||
- Line 829: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
- Line 965: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);`
|
||||
|
||||
- [ ] **Step 4: Fix MainForm PackArea call**
|
||||
|
||||
In `MainForm.cs`, the auto-nest pack phase (around line 829-832) uses the old `PackArea` signature. Replace:
|
||||
|
||||
```csharp
|
||||
var engine = new NestEngine(plate);
|
||||
var partsBefore = plate.Parts.Count;
|
||||
engine.PackArea(workArea, packItems);
|
||||
var packed = plate.Parts.Count - partsBefore;
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```csharp
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(packParts);
|
||||
var packed = packParts.Count;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add plugin loading at startup**
|
||||
|
||||
In `MainForm.cs`, find where post-processors are loaded at startup (look for `Posts` directory loading) and add engine plugin loading nearby. Add after the existing plugin loading:
|
||||
|
||||
```csharp
|
||||
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||
```
|
||||
|
||||
If there is no explicit post-processor loading call visible, add this to the `MainForm` constructor or `Load` event.
|
||||
|
||||
- [ ] **Step 6: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with no errors
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Actions/ActionFillArea.cs OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "refactor: migrate WinForms callsites to NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Verification and Cleanup
|
||||
|
||||
### Task 8: Verify no remaining NestEngine references
|
||||
|
||||
**Files:**
|
||||
- No changes expected — verification only
|
||||
|
||||
- [ ] **Step 1: Search for stale references**
|
||||
|
||||
Run: `grep -rn "new NestEngine(" --include="*.cs" .`
|
||||
Expected: Only `BruteForceRunner.cs` should have `new DefaultNestEngine(`. No `new NestEngine(` references should remain.
|
||||
|
||||
Also run: `grep -rn "class NestEngine[^B]" --include="*.cs" .`
|
||||
Expected: No matches (the old `class NestEngine` no longer exists).
|
||||
|
||||
- [ ] **Step 2: Build and run smoke test**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors, 0 warnings related to NestEngine
|
||||
|
||||
- [ ] **Step 3: Publish MCP server**
|
||||
|
||||
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
|
||||
Expected: Publish succeeded
|
||||
|
||||
- [ ] **Step 4: Commit if any fixes were needed**
|
||||
|
||||
If any issues were found and fixed in previous steps, commit them now.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update CLAUDE.md architecture documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Update architecture section**
|
||||
|
||||
Update the `### OpenNest.Engine` section in `CLAUDE.md` to document the new engine hierarchy:
|
||||
- `NestEngineBase` is the abstract base class
|
||||
- `DefaultNestEngine` is the current multi-phase engine (formerly `NestEngine`)
|
||||
- `NestEngineRegistry` manages available engines and the active selection
|
||||
- `NestEngineInfo` holds engine metadata
|
||||
- Plugin engines loaded from `Engines/` directory
|
||||
|
||||
Also update any references to `NestEngine` that should now say `DefaultNestEngine` or `NestEngineBase`.
|
||||
|
||||
- [ ] **Step 2: Build to verify no docs broke anything**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md for abstract nest engine architecture"
|
||||
```
|
||||
@@ -1,462 +0,0 @@
|
||||
# FillExact Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `FillExact` method to `NestEngine` that binary-searches for the smallest work area sub-region that fits an exact quantity of parts, then integrate it into AutoNest.
|
||||
|
||||
**Architecture:** `FillExact` wraps the existing `Fill(NestItem, Box, IProgress, CancellationToken)` method. It calls Fill repeatedly with progressively smaller test boxes (binary search on one dimension, both orientations), picks the tightest fit, then re-runs the winner with progress reporting. Callers swap `Fill` for `FillExact` — no other engine changes needed.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Console, OpenNest.Mcp
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-fill-exact-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Core Implementation
|
||||
|
||||
### Task 1: Add `BinarySearchFill` helper to NestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs` (add private method after the existing `Fill` overloads, around line 85)
|
||||
|
||||
- [ ] **Step 1: Add the BinarySearchFill method**
|
||||
|
||||
Add after the `Fill(NestItem, Box, IProgress, CancellationToken)` method (line 85):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Binary-searches for the smallest sub-area (one dimension fixed) that fits
|
||||
/// exactly item.Quantity parts. Returns the best parts list and the dimension
|
||||
/// value that achieved it.
|
||||
/// </summary>
|
||||
private (List<Part> parts, double usedDim) BinarySearchFill(
|
||||
NestItem item, Box workArea, bool shrinkWidth,
|
||||
CancellationToken token)
|
||||
{
|
||||
var quantity = item.Quantity;
|
||||
var partBox = item.Drawing.Program.BoundingBox();
|
||||
var partArea = item.Drawing.Area;
|
||||
|
||||
// Fixed and variable dimensions.
|
||||
var fixedDim = shrinkWidth ? workArea.Length : workArea.Width;
|
||||
var highDim = shrinkWidth ? workArea.Width : workArea.Length;
|
||||
|
||||
// Estimate starting point: target area at 50% utilization.
|
||||
var targetArea = partArea * quantity / 0.5;
|
||||
var minPartDim = shrinkWidth
|
||||
? partBox.Width + Plate.PartSpacing
|
||||
: partBox.Length + Plate.PartSpacing;
|
||||
var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim);
|
||||
|
||||
var low = estimatedDim;
|
||||
var high = highDim;
|
||||
|
||||
List<Part> bestParts = null;
|
||||
var bestDim = high;
|
||||
|
||||
for (var iter = 0; iter < 8; iter++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (high - low < Plate.PartSpacing)
|
||||
break;
|
||||
|
||||
var mid = (low + high) / 2.0;
|
||||
|
||||
var testBox = shrinkWidth
|
||||
? new Box(workArea.X, workArea.Y, mid, workArea.Length)
|
||||
: new Box(workArea.X, workArea.Y, workArea.Width, mid);
|
||||
|
||||
var result = Fill(item, testBox, null, token);
|
||||
|
||||
if (result.Count >= quantity)
|
||||
{
|
||||
bestParts = result.Count > quantity
|
||||
? result.Take(quantity).ToList()
|
||||
: result;
|
||||
bestDim = mid;
|
||||
high = mid;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return (bestParts, bestDim);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat(engine): add BinarySearchFill helper for exact-quantity search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `FillExact` public method to NestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngine.cs` (add public method after the existing `Fill` overloads, before `BinarySearchFill`)
|
||||
|
||||
- [ ] **Step 1: Add the FillExact method**
|
||||
|
||||
Add between the `Fill(NestItem, Box, IProgress, CancellationToken)` method and `BinarySearchFill`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts.
|
||||
/// Uses binary search on both orientations and picks the tightest fit.
|
||||
/// Falls through to standard Fill for unlimited (0) or single (1) quantities.
|
||||
/// </summary>
|
||||
public List<Part> FillExact(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
// Early exits: unlimited or single quantity — no benefit from area search.
|
||||
if (item.Quantity <= 1)
|
||||
return Fill(item, workArea, progress, token);
|
||||
|
||||
// Full fill to establish upper bound.
|
||||
var fullResult = Fill(item, workArea, progress, token);
|
||||
|
||||
if (fullResult.Count <= item.Quantity)
|
||||
return fullResult;
|
||||
|
||||
// Binary search: try shrinking each dimension.
|
||||
var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token);
|
||||
var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token);
|
||||
|
||||
// Pick winner by smallest test box area. Tie-break: prefer shrink-length.
|
||||
List<Part> winner;
|
||||
Box winnerBox;
|
||||
|
||||
var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue;
|
||||
var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue;
|
||||
|
||||
if (lengthParts != null && lengthArea <= widthArea)
|
||||
{
|
||||
winner = lengthParts;
|
||||
winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim);
|
||||
}
|
||||
else if (widthParts != null)
|
||||
{
|
||||
winner = widthParts;
|
||||
winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither search found the exact quantity — return full fill truncated.
|
||||
return fullResult.Take(item.Quantity).ToList();
|
||||
}
|
||||
|
||||
// Re-run the winner with progress so PhaseResults/WinnerPhase are correct
|
||||
// and the progress form shows the final result.
|
||||
var finalResult = Fill(item, winnerBox, progress, token);
|
||||
|
||||
if (finalResult.Count >= item.Quantity)
|
||||
return finalResult.Count > item.Quantity
|
||||
? finalResult.Take(item.Quantity).ToList()
|
||||
: finalResult;
|
||||
|
||||
// Fallback: return the binary search result if the re-run produced fewer.
|
||||
return winner;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngine.cs
|
||||
git commit -m "feat(engine): add FillExact method for exact-quantity nesting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Compactor class to Engine
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Compactor.cs`
|
||||
|
||||
- [ ] **Step 1: Create the Compactor class**
|
||||
|
||||
Create `OpenNest.Engine/Compactor.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a group of parts left and down to close gaps after placement.
|
||||
/// Uses the same directional-distance logic as PlateView.PushSelected
|
||||
/// but operates on Part objects directly.
|
||||
/// </summary>
|
||||
public static class Compactor
|
||||
{
|
||||
private const double ChordTolerance = 0.001;
|
||||
|
||||
/// <summary>
|
||||
/// Compacts movingParts toward the bottom-left of the plate work area.
|
||||
/// Everything already on the plate (excluding movingParts) is treated
|
||||
/// as stationary obstacles.
|
||||
/// </summary>
|
||||
public static void Compact(List<Part> movingParts, Plate plate)
|
||||
{
|
||||
if (movingParts == null || movingParts.Count == 0)
|
||||
return;
|
||||
|
||||
Push(movingParts, plate, PushDirection.Left);
|
||||
Push(movingParts, plate, PushDirection.Down);
|
||||
}
|
||||
|
||||
private static void Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||
{
|
||||
var stationaryParts = plate.Parts
|
||||
.Where(p => !movingParts.Contains(p))
|
||||
.ToList();
|
||||
|
||||
var stationaryBoxes = new Box[stationaryParts.Count];
|
||||
|
||||
for (var i = 0; i < stationaryParts.Count; i++)
|
||||
stationaryBoxes[i] = stationaryParts[i].BoundingBox;
|
||||
|
||||
var stationaryLines = new List<Line>[stationaryParts.Count];
|
||||
var opposite = Helper.OppositeDirection(direction);
|
||||
var halfSpacing = plate.PartSpacing / 2;
|
||||
var isHorizontal = Helper.IsHorizontalDirection(direction);
|
||||
var workArea = plate.WorkArea();
|
||||
|
||||
foreach (var moving in movingParts)
|
||||
{
|
||||
var distance = double.MaxValue;
|
||||
var movingBox = moving.BoundingBox;
|
||||
|
||||
// Plate edge distance.
|
||||
var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction);
|
||||
if (edgeDist > 0 && edgeDist < distance)
|
||||
distance = edgeDist;
|
||||
|
||||
List<Line> movingLines = null;
|
||||
|
||||
for (var i = 0; i < stationaryBoxes.Length; i++)
|
||||
{
|
||||
var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction);
|
||||
if (gap < 0 || gap >= distance)
|
||||
continue;
|
||||
|
||||
var perpOverlap = isHorizontal
|
||||
? movingBox.IsHorizontalTo(stationaryBoxes[i], out _)
|
||||
: movingBox.IsVerticalTo(stationaryBoxes[i], out _);
|
||||
|
||||
if (!perpOverlap)
|
||||
continue;
|
||||
|
||||
movingLines ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||
: Helper.GetPartLines(moving, direction, ChordTolerance);
|
||||
|
||||
stationaryLines[i] ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance)
|
||||
: Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance);
|
||||
|
||||
var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction);
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
}
|
||||
|
||||
if (distance < double.MaxValue && distance > 0)
|
||||
{
|
||||
var offset = Helper.DirectionToOffset(direction, distance);
|
||||
moving.Offset(offset);
|
||||
|
||||
// Update this part's bounding box in the stationary set for
|
||||
// subsequent moving parts to collide against correctly.
|
||||
// (Parts already pushed become obstacles for the next part.)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/Compactor.cs
|
||||
git commit -m "feat(engine): add Compactor for post-fill gravity compaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Integration
|
||||
|
||||
### Task 4: Integrate FillExact and Compactor into AutoNest (MainForm)
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.cs` (RunAutoNest_Click, around lines 797-815)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
In `RunAutoNest_Click`, change the Fill call and the block after it (around lines 799-815). Replace:
|
||||
|
||||
```csharp
|
||||
var parts = await Task.Run(() =>
|
||||
engine.Fill(item, workArea, progress, token));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
var parts = await Task.Run(() =>
|
||||
engine.FillExact(item, workArea, progress, token));
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` and before `ComputeRemainderStrip`, add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
activeForm.PlateView.Invalidate();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.sln --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat(ui): use FillExact + Compactor in AutoNest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integrate FillExact and Compactor into Console app
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Console/Program.cs` (around lines 346-360)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
Change the Fill call (around line 352) from:
|
||||
|
||||
```csharp
|
||||
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Console/Program.cs
|
||||
git commit -m "feat(console): use FillExact + Compactor in --autonest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integrate FillExact and Compactor into MCP server
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` (around lines 255-264)
|
||||
|
||||
- [ ] **Step 1: Replace Fill with FillExact and add Compactor call**
|
||||
|
||||
Change the Fill call (around line 256) from:
|
||||
|
||||
```csharp
|
||||
var parts = engine.Fill(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var parts = engine.FillExact(item, workArea, null, CancellationToken.None);
|
||||
```
|
||||
|
||||
Then after `plate.Parts.AddRange(parts);` add the compaction call:
|
||||
|
||||
```csharp
|
||||
plate.Parts.AddRange(parts);
|
||||
Compactor.Compact(parts, plate);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "feat(mcp): use FillExact in autonest_plate for tighter packing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Verification
|
||||
|
||||
### Task 7: End-to-end test via Console
|
||||
|
||||
- [ ] **Step 1: Run AutoNest with qty > 1 and verify tighter packing**
|
||||
|
||||
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --quantity 10 --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
|
||||
|
||||
Verify:
|
||||
- Completes without error
|
||||
- Parts placed count is reasonable (not 0, not wildly over-placed)
|
||||
- Utilization is reported
|
||||
|
||||
- [ ] **Step 2: Run with qty=1 to verify fallback path**
|
||||
|
||||
Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --no-save "C:\Users\AJ\Desktop\N0312-002.zip"`
|
||||
|
||||
Verify:
|
||||
- Completes quickly (qty=1 goes through Pack, no binary search)
|
||||
- Parts placed > 0
|
||||
|
||||
- [ ] **Step 3: Build full solution one final time**
|
||||
|
||||
Run: `dotnet build OpenNest.sln --nologo -v q`
|
||||
Expected: `Build succeeded. 0 Error(s)`
|
||||
@@ -1,350 +0,0 @@
|
||||
# Helper Class Decomposition
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes.
|
||||
|
||||
**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring.
|
||||
|
||||
**Tech Stack:** .NET 8, C# 12
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| New File | Namespace | Responsibility | Methods Moved |
|
||||
|----------|-----------|----------------|---------------|
|
||||
| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` |
|
||||
| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` |
|
||||
| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` |
|
||||
| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads |
|
||||
| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` |
|
||||
| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) |
|
||||
|
||||
**Files modified (call-site updates):**
|
||||
|
||||
| File | Methods Referenced |
|
||||
|------|--------------------|
|
||||
| `OpenNest.Core/Plate.cs` | `RoundUpToNearest` → `Rounding.RoundUpToNearest` |
|
||||
| `OpenNest.IO/DxfImporter.cs` | `Optimize` → `GeometryOptimizer.Optimize` |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | `Optimize` → `GeometryOptimizer.Optimize`, `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Drawing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Timing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Circle.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Line.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects` → `Intersect.Intersects` |
|
||||
| `OpenNest/LayoutPart.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` |
|
||||
| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` |
|
||||
| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection` → `SpatialQuery.*` |
|
||||
| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder
|
||||
|
||||
### Task 1: Extract Rounding to OpenNest.Math
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Math/Rounding.cs`
|
||||
- Modify: `OpenNest.Core/Plate.cs:415-416`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 14–45)
|
||||
|
||||
- [ ] **Step 1: Create `Rounding.cs`**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
public static class Rounding
|
||||
{
|
||||
public static double RoundDownToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundUpToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
|
||||
}
|
||||
|
||||
public static double RoundToNearest(double num, double factor)
|
||||
{
|
||||
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update call site in `Plate.cs`**
|
||||
|
||||
Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present.
|
||||
|
||||
- [ ] **Step 3: Remove three rounding methods from `Helper.cs`**
|
||||
|
||||
Delete lines 14–45 (the three methods and their XML doc comments).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Rounding from Helper to OpenNest.Math
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Extract GeometryOptimizer
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs`
|
||||
- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 47–237)
|
||||
|
||||
- [ ] **Step 1: Create `GeometryOptimizer.cs`**
|
||||
|
||||
Move these 6 methods (preserving exact code):
|
||||
- `Optimize(IList<Arc>)`
|
||||
- `Optimize(IList<Line>)`
|
||||
- `TryJoinLines`
|
||||
- `TryJoinArcs`
|
||||
- `GetCollinearLines` (private extension method)
|
||||
- `GetCoradialArs` (private extension method)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `DxfImporter.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`.
|
||||
- `Shape.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed.
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract GeometryOptimizer from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract ShapeBuilder
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs`
|
||||
- Modify: 11 files (see call-site table above for `GetShapes` callers)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 239–378)
|
||||
|
||||
- [ ] **Step 1: Create `ShapeBuilder.cs`**
|
||||
|
||||
Move these 2 methods:
|
||||
- `GetShapes(IEnumerable<Entity>)` — public
|
||||
- `GetConnected(Vector, IEnumerable<Entity>)` — internal
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update all call sites**
|
||||
|
||||
Replace `Helper.GetShapes` → `ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace.
|
||||
|
||||
Files to update:
|
||||
- `OpenNest.Core/Drawing.cs`
|
||||
- `OpenNest.Core/Timing.cs`
|
||||
- `OpenNest.Core/Converters/ConvertGeometry.cs`
|
||||
- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace)
|
||||
- `OpenNest/LayoutPart.cs`
|
||||
- `OpenNest/Actions/ActionSetSequence.cs`
|
||||
- `OpenNest.Gpu/PartBitmap.cs`
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs`
|
||||
- `OpenNest.Engine/RotationAnalysis.cs`
|
||||
- `OpenNest.Engine/BestFit/BestFitFinder.cs`
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract ShapeBuilder from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Intersect + PartGeometry
|
||||
|
||||
### Task 4: Extract Intersect
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/Intersect.cs`
|
||||
- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`)
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 380–742)
|
||||
|
||||
- [ ] **Step 1: Create `Intersect.cs`**
|
||||
|
||||
Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`.
|
||||
|
||||
All methods keep their existing access modifiers (`internal` for most, none are `public`).
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites in geometry types**
|
||||
|
||||
All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects` → `Intersect.Intersects` in:
|
||||
- `Arc.cs` (10 calls)
|
||||
- `Circle.cs` (10 calls)
|
||||
- `Line.cs` (8 calls)
|
||||
- `Shape.cs` (12 calls, including the internal offset usage at line 537)
|
||||
- `Polygon.cs` (10 calls)
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract Intersect from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Extract PartGeometry
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/PartGeometry.cs`
|
||||
- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 744–858)
|
||||
|
||||
- [ ] **Step 1: Create `PartGeometry.cs`**
|
||||
|
||||
Move these 5 methods:
|
||||
- `GetPartLines(Part, double)` — public
|
||||
- `GetPartLines(Part, PushDirection, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, double)` — public
|
||||
- `GetOffsetPartLines(Part, double, PushDirection, double)` — public
|
||||
- `GetDirectionalLines(Polygon, PushDirection)` — private
|
||||
|
||||
Namespace: `OpenNest`. Class: `public static class PartGeometry`.
|
||||
|
||||
Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines` → `PartGeometry.*`
|
||||
- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines` → `PartGeometry.GetOffsetPartLines`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract PartGeometry from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: SpatialQuery + Cleanup
|
||||
|
||||
### Task 6: Extract SpatialQuery
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/SpatialQuery.cs`
|
||||
- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs`
|
||||
- Delete from: `OpenNest.Core/Helper.cs` (lines 860–1462, all remaining methods)
|
||||
|
||||
- [ ] **Step 1: Create `SpatialQuery.cs`**
|
||||
|
||||
Move all remaining methods (14 total):
|
||||
- `RayEdgeDistance(Vector, Line, PushDirection)` — private
|
||||
- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]`
|
||||
- `DirectionalDistance(List<Line>, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance(List<Line>, double, double, List<Line>, PushDirection)` — public
|
||||
- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `FlattenLines(List<Line>)` — public
|
||||
- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public
|
||||
- `OppositeDirection(PushDirection)` — public
|
||||
- `IsHorizontalDirection(PushDirection)` — public
|
||||
- `EdgeDistance(Box, Box, PushDirection)` — public
|
||||
- `DirectionToOffset(PushDirection, double)` — public
|
||||
- `DirectionalGap(Box, Box, PushDirection)` — public
|
||||
- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods)
|
||||
- `GetLargestBoxVertically/Horizontally` — public (2 methods)
|
||||
|
||||
Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`.
|
||||
|
||||
Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`.
|
||||
|
||||
- [ ] **Step 2: Update call sites**
|
||||
|
||||
Replace `Helper.*` → `SpatialQuery.*` and add `using OpenNest.Geometry;` where needed:
|
||||
- `OpenNest.Engine/Compactor.cs` — `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset`
|
||||
- `OpenNest.Engine/FillLinear.cs` — `DirectionalDistance`, `OppositeDirection`
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — `FlattenLines`, `OppositeDirection`, `OneWayDistance`
|
||||
- `OpenNest/Actions/ActionClone.cs` — `GetLargestBoxVertically`, `GetLargestBoxHorizontally`
|
||||
- `OpenNest/Actions/ActionSelectArea.cs` — `GetLargestBoxHorizontally`, `GetLargestBoxVertically`
|
||||
|
||||
- [ ] **Step 3: Remove methods from `Helper.cs`**
|
||||
|
||||
At this point `Helper.cs` should be empty (just the class wrapper and usings).
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: extract SpatialQuery from Helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Delete Helper.cs
|
||||
|
||||
**Files:**
|
||||
- Delete: `OpenNest.Core/Helper.cs`
|
||||
|
||||
- [ ] **Step 1: Delete the empty `Helper.cs` file**
|
||||
|
||||
- [ ] **Step 2: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded with zero errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
refactor: remove empty Helper class
|
||||
```
|
||||
@@ -1,588 +0,0 @@
|
||||
# Strip Nester Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a strip-based multi-drawing nesting strategy as a `NestEngineBase` subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings.
|
||||
|
||||
**Architecture:** `StripNestEngine` extends `NestEngineBase`, uses `DefaultNestEngine` internally (composition) for individual fills. Registered in `NestEngineRegistry`. For single-item fills, delegates to `DefaultNestEngine`. For multi-drawing nesting, orchestrates the strip+remnant strategy. The MCP `autonest_plate` tool always runs `StripNestEngine` as a competitor alongside the current sequential approach, picking the denser result.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md`
|
||||
|
||||
**Depends on:** `docs/superpowers/plans/2026-03-15-abstract-nest-engine.md` (must be implemented first — provides `NestEngineBase`, `DefaultNestEngine`, `NestEngineRegistry`)
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Core StripNestEngine
|
||||
|
||||
### Task 1: Create StripDirection enum
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripDirection.cs`
|
||||
|
||||
- [ ] **Step 1: Create the enum file**
|
||||
|
||||
```csharp
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum StripDirection
|
||||
{
|
||||
Bottom,
|
||||
Left
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripDirection.cs
|
||||
git commit -m "feat: add StripDirection enum"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create StripNestResult internal class
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripNestResult.cs`
|
||||
|
||||
- [ ] **Step 1: Create the result class**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal class StripNestResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public Box StripBox { get; set; }
|
||||
public Box RemnantBox { get; set; }
|
||||
public FillScore Score { get; set; }
|
||||
public StripDirection Direction { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestResult.cs
|
||||
git commit -m "feat: add StripNestResult internal class"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create StripNestEngine — class skeleton with selection and estimation helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
This task creates the class extending `NestEngineBase`, with `Name`/`Description` overrides, the single-item `Fill` override that delegates to `DefaultNestEngine`, and the helper methods for strip item selection and dimension estimation. The main `Nest` method is added in the next task.
|
||||
|
||||
- [ ] **Step 1: Create StripNestEngine with skeleton and helpers**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class StripNestEngine : NestEngineBase
|
||||
{
|
||||
private const int MaxShrinkIterations = 20;
|
||||
|
||||
public StripNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Strip";
|
||||
|
||||
public override string Description => "Strip-based nesting for mixed-drawing layouts";
|
||||
|
||||
/// <summary>
|
||||
/// Single-item fill delegates to DefaultNestEngine.
|
||||
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
|
||||
/// </summary>
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the item that consumes the most plate area (bounding box area x quantity).
|
||||
/// Returns the index into the items list.
|
||||
/// </summary>
|
||||
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
|
||||
{
|
||||
var bestIndex = 0;
|
||||
var bestArea = 0.0;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var bbox = items[i].Drawing.Program.BoundingBox();
|
||||
var qty = items[i].Quantity > 0
|
||||
? items[i].Quantity
|
||||
: (int)(workArea.Area() / bbox.Area());
|
||||
var totalArea = bbox.Area() * qty;
|
||||
|
||||
if (totalArea > bestArea)
|
||||
{
|
||||
bestArea = totalArea;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the strip dimension (height for bottom, width for left) needed
|
||||
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
|
||||
/// This is only an estimate for the shrink loop starting point — the actual fill
|
||||
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
|
||||
/// </summary>
|
||||
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
var qty = item.Quantity > 0
|
||||
? item.Quantity
|
||||
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
|
||||
|
||||
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
|
||||
var perRow0 = (int)(stripLength / bbox.Width);
|
||||
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
|
||||
var dim0 = rows0 * bbox.Length;
|
||||
|
||||
// At 90 deg: rotated bounding box (Width and Length swap)
|
||||
var perRow90 = (int)(stripLength / bbox.Length);
|
||||
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
|
||||
var dim90 = rows90 * bbox.Width;
|
||||
|
||||
var estimate = System.Math.Min(dim0, dim90);
|
||||
|
||||
// Clamp to available dimension
|
||||
return System.Math.Min(estimate, maxDimension);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat: add StripNestEngine skeleton with Fill delegate and estimation helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add the Nest method and TryOrientation
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs`
|
||||
|
||||
This is the main multi-drawing algorithm: tries both orientations, fills strip + remnant, compares results. Uses `DefaultNestEngine` internally for all fill operations (composition pattern per the abstract engine spec).
|
||||
|
||||
Key detail: The remnant fill shrinks the remnant box after each item fill using `ComputeRemainderWithin` to prevent overlapping placements.
|
||||
|
||||
- [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods**
|
||||
|
||||
Add these methods to the `StripNestEngine` class, after the `EstimateStripDimension` method:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Multi-drawing strip nesting strategy.
|
||||
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
|
||||
/// in both bottom and left orientations, fills remnants with remaining drawings,
|
||||
/// and returns the denser result.
|
||||
/// </summary>
|
||||
public List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = Plate.WorkArea();
|
||||
|
||||
// Select which item gets the strip treatment.
|
||||
var stripIndex = SelectStripItemIndex(items, workArea);
|
||||
var stripItem = items[stripIndex];
|
||||
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
|
||||
|
||||
// Try both orientations.
|
||||
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token);
|
||||
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token);
|
||||
|
||||
// Pick the better result.
|
||||
if (bottomResult.Score >= leftResult.Score)
|
||||
return bottomResult.Parts;
|
||||
|
||||
return leftResult.Parts;
|
||||
}
|
||||
|
||||
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
|
||||
List<NestItem> remainderItems, Box workArea, CancellationToken token)
|
||||
{
|
||||
var result = new StripNestResult { Direction = direction };
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
return result;
|
||||
|
||||
// Estimate initial strip dimension.
|
||||
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
|
||||
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
|
||||
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
|
||||
|
||||
// Create the initial strip box.
|
||||
var stripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
|
||||
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
|
||||
|
||||
// Initial fill using DefaultNestEngine (composition, not inheritance).
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var stripParts = inner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
stripBox, null, token);
|
||||
|
||||
if (stripParts == null || stripParts.Count == 0)
|
||||
return result;
|
||||
|
||||
// Measure actual strip dimension from placed parts.
|
||||
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
|
||||
var actualDim = direction == StripDirection.Bottom
|
||||
? placedBox.Top - workArea.Y
|
||||
: placedBox.Right - workArea.X;
|
||||
|
||||
var bestParts = stripParts;
|
||||
var bestDim = actualDim;
|
||||
var targetCount = stripParts.Count;
|
||||
|
||||
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
|
||||
for (var i = 0; i < MaxShrinkIterations; i++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var trialDim = bestDim - Plate.PartSpacing;
|
||||
if (trialDim <= 0)
|
||||
break;
|
||||
|
||||
var trialBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
|
||||
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
|
||||
|
||||
var trialInner = new DefaultNestEngine(Plate);
|
||||
var trialParts = trialInner.Fill(
|
||||
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
|
||||
trialBox, null, token);
|
||||
|
||||
if (trialParts == null || trialParts.Count < targetCount)
|
||||
break;
|
||||
|
||||
// Same count in a tighter strip — keep going.
|
||||
bestParts = trialParts;
|
||||
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
|
||||
bestDim = direction == StripDirection.Bottom
|
||||
? trialPlacedBox.Top - workArea.Y
|
||||
: trialPlacedBox.Right - workArea.X;
|
||||
}
|
||||
|
||||
// Build remnant box with spacing gap.
|
||||
var spacing = Plate.PartSpacing;
|
||||
var remnantBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y + bestDim + spacing,
|
||||
workArea.Width, workArea.Length - bestDim - spacing)
|
||||
: new Box(workArea.X + bestDim + spacing, workArea.Y,
|
||||
workArea.Width - bestDim - spacing, workArea.Length);
|
||||
|
||||
// Collect all parts.
|
||||
var allParts = new List<Part>(bestParts);
|
||||
|
||||
// If strip item was only partially placed, add leftovers to remainder.
|
||||
var placed = bestParts.Count;
|
||||
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
|
||||
var effectiveRemainder = new List<NestItem>(remainderItems);
|
||||
|
||||
if (leftover > 0)
|
||||
{
|
||||
effectiveRemainder.Add(new NestItem
|
||||
{
|
||||
Drawing = stripItem.Drawing,
|
||||
Quantity = leftover
|
||||
});
|
||||
}
|
||||
|
||||
// Sort remainder by descending bounding box area x quantity.
|
||||
effectiveRemainder = effectiveRemainder
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Fill remnant with remainder items, shrinking the available area after each.
|
||||
if (remnantBox.Width > 0 && remnantBox.Length > 0)
|
||||
{
|
||||
var currentRemnant = remnantBox;
|
||||
|
||||
foreach (var item in effectiveRemainder)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0)
|
||||
break;
|
||||
|
||||
var remnantInner = new DefaultNestEngine(Plate);
|
||||
var remnantParts = remnantInner.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
currentRemnant, null, token);
|
||||
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(remnantParts);
|
||||
|
||||
// Shrink remnant to avoid overlap with next item.
|
||||
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
|
||||
currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Parts = allParts;
|
||||
result.StripBox = direction == StripDirection.Bottom
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
|
||||
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
|
||||
result.RemnantBox = remnantBox;
|
||||
result.Score = FillScore.Compute(allParts, workArea);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the largest usable remainder within a work area after a portion has been used.
|
||||
/// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above.
|
||||
/// </summary>
|
||||
private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
|
||||
{
|
||||
var hWidth = workArea.Right - usedBox.Right - spacing;
|
||||
var hStrip = hWidth > 0
|
||||
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
|
||||
: Box.Empty;
|
||||
|
||||
var vHeight = workArea.Top - usedBox.Top - spacing;
|
||||
var vStrip = vHeight > 0
|
||||
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
|
||||
: Box.Empty;
|
||||
|
||||
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "feat: add StripNestEngine.Nest with strip fill, shrink loop, and remnant fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Register StripNestEngine in NestEngineRegistry
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineRegistry.cs`
|
||||
|
||||
- [ ] **Step 1: Add Strip registration**
|
||||
|
||||
In `NestEngineRegistry.cs`, add the strip engine registration in the static constructor, after the Default registration:
|
||||
|
||||
```csharp
|
||||
Register("Strip",
|
||||
"Strip-based nesting for mixed-drawing layouts",
|
||||
plate => new StripNestEngine(plate));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineRegistry.cs
|
||||
git commit -m "feat: register StripNestEngine in NestEngineRegistry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: MCP Integration
|
||||
|
||||
### Task 6: Integrate StripNestEngine into autonest_plate MCP tool
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs`
|
||||
|
||||
Run the strip nester alongside the existing sequential approach. Both use side-effect-free fills (4-arg `Fill` returning `List<Part>`), then the winner's parts are added to the plate.
|
||||
|
||||
Note: After the abstract engine migration, callsites already use `NestEngineRegistry.Create(plate)`. The `autonest_plate` tool creates a `StripNestEngine` directly for the strip strategy competition (it's always tried, regardless of active engine selection).
|
||||
|
||||
- [ ] **Step 1: Refactor AutoNestPlate to run both strategies**
|
||||
|
||||
In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (the section after the items list is built) with a strategy competition.
|
||||
|
||||
Replace the fill/pack logic with:
|
||||
|
||||
```csharp
|
||||
// Strategy 1: Strip nesting
|
||||
var stripEngine = new StripNestEngine(plate);
|
||||
var stripResult = stripEngine.Nest(items, null, CancellationToken.None);
|
||||
var stripScore = FillScore.Compute(stripResult, plate.WorkArea());
|
||||
|
||||
// Strategy 2: Current sequential fill
|
||||
var seqResult = SequentialFill(plate, items);
|
||||
var seqScore = FillScore.Compute(seqResult, plate.WorkArea());
|
||||
|
||||
// Pick winner and apply to plate.
|
||||
var winner = stripScore >= seqScore ? stripResult : seqResult;
|
||||
var winnerName = stripScore >= seqScore ? "strip" : "sequential";
|
||||
plate.Parts.AddRange(winner);
|
||||
var totalPlaced = winner.Count;
|
||||
```
|
||||
|
||||
Update the output section:
|
||||
|
||||
```csharp
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}");
|
||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||
sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}");
|
||||
sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}");
|
||||
|
||||
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
|
||||
foreach (var group in groups)
|
||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
||||
|
||||
return sb.ToString();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the SequentialFill helper method**
|
||||
|
||||
Add this private method to `NestingTools`. It mirrors the existing sequential fill phase using side-effect-free fills.
|
||||
|
||||
```csharp
|
||||
private static List<Part> SequentialFill(Plate plate, List<NestItem> items)
|
||||
{
|
||||
var fillItems = items
|
||||
.Where(i => i.Quantity != 1)
|
||||
.OrderBy(i => i.Priority)
|
||||
.ThenByDescending(i => i.Drawing.Area)
|
||||
.ToList();
|
||||
|
||||
var workArea = plate.WorkArea();
|
||||
var allParts = new List<Part>();
|
||||
|
||||
foreach (var item in fillItems)
|
||||
{
|
||||
if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0)
|
||||
continue;
|
||||
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var parts = engine.Fill(
|
||||
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
|
||||
workArea, null, CancellationToken.None);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
allParts.AddRange(parts);
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing);
|
||||
}
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add required using statement**
|
||||
|
||||
Add `using System.Threading;` to the top of `NestingTools.cs` if not already present.
|
||||
|
||||
- [ ] **Step 4: Build the full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs
|
||||
git commit -m "feat: integrate StripNestEngine into autonest_plate MCP tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Publish and Test
|
||||
|
||||
### Task 7: Publish MCP server and test with real parts
|
||||
|
||||
**Files:**
|
||||
- No code changes — publish and manual testing
|
||||
|
||||
- [ ] **Step 1: Publish OpenNest.Mcp**
|
||||
|
||||
Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"`
|
||||
Expected: Build and publish succeeded
|
||||
|
||||
- [ ] **Step 2: Test with SULLYS parts**
|
||||
|
||||
Using the MCP tools, test the strip nester with the SULLYS-001 and SULLYS-002 parts:
|
||||
|
||||
1. Load the test nest file or import the DXF files
|
||||
2. Create a 60x120 plate
|
||||
3. Run `autonest_plate` with both drawings at qty 10
|
||||
4. Verify the output reports which strategy won (strip vs sequential)
|
||||
5. Verify the output shows scores for both strategies
|
||||
6. Check plate info for part placement and utilization
|
||||
|
||||
- [ ] **Step 3: Compare with current results**
|
||||
|
||||
Verify the strip nester produces a result matching or improving on the target layout from screenshot 190519 (all 20 parts on one 60x120 plate with organized strip arrangement).
|
||||
|
||||
- [ ] **Step 4: Commit any fixes**
|
||||
|
||||
If issues are found during testing, fix and commit with descriptive messages.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,570 +0,0 @@
|
||||
# Polylabel Part Label Positioning Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Position part ID labels at the visual center of each part using the polylabel (pole of inaccessibility) algorithm, so labels are readable and don't overlap adjacent parts.
|
||||
|
||||
**Architecture:** Add a `PolyLabel` static class in `OpenNest.Geometry` that finds the point inside a polygon farthest from all edges (including holes). `LayoutPart` caches this point in program-local coordinates and transforms it each frame for rendering.
|
||||
|
||||
**Tech Stack:** .NET 8, xUnit, WinForms (System.Drawing)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-polylabel-part-labels-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Polylabel Algorithm
|
||||
|
||||
### Task 1: PolyLabel — square polygon test + implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Core/Geometry/PolyLabel.cs`
|
||||
- Create: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for a square polygon**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Tests/PolyLabelTests.cs
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PolyLabelTests
|
||||
{
|
||||
private static Polygon Square(double size)
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(size, 0));
|
||||
p.Vertices.Add(new Vector(size, size));
|
||||
p.Vertices.Add(new Vector(0, size));
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Square_ReturnsCenterPoint()
|
||||
{
|
||||
var poly = Square(100);
|
||||
|
||||
var result = PolyLabel.Find(poly);
|
||||
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(50, result.Y, 1.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: FAIL — `PolyLabel` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement PolyLabel.Find**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Core/Geometry/PolyLabel.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class PolyLabel
|
||||
{
|
||||
public static Vector Find(Polygon outer, IList<Polygon> holes = null, double precision = 0.5)
|
||||
{
|
||||
if (outer.Vertices.Count < 3)
|
||||
return outer.Vertices.Count > 0
|
||||
? outer.Vertices[0]
|
||||
: new Vector();
|
||||
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
for (var i = 0; i < outer.Vertices.Count; i++)
|
||||
{
|
||||
var v = outer.Vertices[i];
|
||||
if (v.X < minX) minX = v.X;
|
||||
if (v.Y < minY) minY = v.Y;
|
||||
if (v.X > maxX) maxX = v.X;
|
||||
if (v.Y > maxY) maxY = v.Y;
|
||||
}
|
||||
|
||||
var width = maxX - minX;
|
||||
var height = maxY - minY;
|
||||
var cellSize = System.Math.Min(width, height);
|
||||
|
||||
if (cellSize == 0)
|
||||
return new Vector((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
|
||||
var halfCell = cellSize / 2;
|
||||
|
||||
var queue = new List<Cell>();
|
||||
|
||||
for (var x = minX; x < maxX; x += cellSize)
|
||||
for (var y = minY; y < maxY; y += cellSize)
|
||||
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
|
||||
|
||||
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
|
||||
|
||||
var bestCell = GetCentroidCell(outer, holes);
|
||||
|
||||
for (var i = 0; i < queue.Count; i++)
|
||||
if (queue[i].Dist > bestCell.Dist)
|
||||
{
|
||||
bestCell = queue[i];
|
||||
break;
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var cell = queue[0];
|
||||
queue.RemoveAt(0);
|
||||
|
||||
if (cell.Dist > bestCell.Dist)
|
||||
bestCell = cell;
|
||||
|
||||
if (cell.MaxDist - bestCell.Dist <= precision)
|
||||
continue;
|
||||
|
||||
halfCell = cell.HalfSize / 2;
|
||||
|
||||
var newCells = new[]
|
||||
{
|
||||
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
|
||||
};
|
||||
|
||||
for (var i = 0; i < newCells.Length; i++)
|
||||
{
|
||||
if (newCells[i].MaxDist > bestCell.Dist + precision)
|
||||
InsertSorted(queue, newCells[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return new Vector(bestCell.X, bestCell.Y);
|
||||
}
|
||||
|
||||
private static void InsertSorted(List<Cell> list, Cell cell)
|
||||
{
|
||||
var idx = 0;
|
||||
while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
|
||||
idx++;
|
||||
list.Insert(idx, cell);
|
||||
}
|
||||
|
||||
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var area = 0.0;
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
var verts = outer.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
var cross = a.X * b.Y - b.X * a.Y;
|
||||
cx += (a.X + b.X) * cross;
|
||||
cy += (a.Y + b.Y) * cross;
|
||||
area += cross;
|
||||
}
|
||||
|
||||
area *= 0.5;
|
||||
|
||||
if (System.Math.Abs(area) < 1e-10)
|
||||
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes);
|
||||
|
||||
cx /= (6 * area);
|
||||
cy /= (6 * area);
|
||||
|
||||
return new Cell(cx, cy, 0, outer, holes);
|
||||
}
|
||||
|
||||
private static double PointToPolygonDist(double x, double y, Polygon polygon)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var verts = polygon.Vertices;
|
||||
|
||||
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
|
||||
{
|
||||
var a = verts[i];
|
||||
var b = verts[j];
|
||||
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
|
||||
if (dx != 0 || dy != 0)
|
||||
{
|
||||
var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy);
|
||||
|
||||
if (t > 1)
|
||||
{
|
||||
a = b;
|
||||
}
|
||||
else if (t > 0)
|
||||
{
|
||||
a = new Vector(a.X + dx * t, a.Y + dy * t);
|
||||
}
|
||||
}
|
||||
|
||||
var segDx = x - a.X;
|
||||
var segDy = y - a.Y;
|
||||
var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy);
|
||||
|
||||
if (dist < minDist)
|
||||
minDist = dist;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private sealed class Cell
|
||||
{
|
||||
public readonly double X;
|
||||
public readonly double Y;
|
||||
public readonly double HalfSize;
|
||||
public readonly double Dist;
|
||||
public readonly double MaxDist;
|
||||
|
||||
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
HalfSize = halfSize;
|
||||
|
||||
var pt = new Vector(x, y);
|
||||
var inside = outer.ContainsPoint(pt);
|
||||
|
||||
if (inside && holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
if (holes[i].ContainsPoint(pt))
|
||||
{
|
||||
inside = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dist = PointToAllEdgesDist(x, y, outer, holes);
|
||||
|
||||
if (!inside)
|
||||
Dist = -Dist;
|
||||
|
||||
MaxDist = Dist + HalfSize * System.Math.Sqrt(2);
|
||||
}
|
||||
}
|
||||
|
||||
private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList<Polygon> holes)
|
||||
{
|
||||
var minDist = PointToPolygonDist(x, y, outer);
|
||||
|
||||
if (holes != null)
|
||||
{
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
var d = PointToPolygonDist(x, y, holes[i]);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests.Square_ReturnsCenterPoint`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/PolyLabel.cs OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "feat(geometry): add PolyLabel algorithm with square test"
|
||||
```
|
||||
|
||||
### Task 2: PolyLabel — additional shape tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Tests/PolyLabelTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add tests for L-shape, triangle, thin rectangle, C-shape, hole, and degenerate**
|
||||
|
||||
```csharp
|
||||
// Append to PolyLabelTests.cs
|
||||
|
||||
[Fact]
|
||||
public void Triangle_ReturnsIncenter()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(50, 86.6));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
// Incenter of equilateral triangle is at (50, ~28.9)
|
||||
Assert.Equal(50, result.X, 1.0);
|
||||
Assert.Equal(28.9, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LShape_ReturnsPointInBottomLobe()
|
||||
{
|
||||
// L-shape: 100x100 with 50x50 cut from top-right
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 50));
|
||||
p.Vertices.Add(new Vector(50, 50));
|
||||
p.Vertices.Add(new Vector(50, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// The bottom 100x50 lobe is the widest region
|
||||
Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThinRectangle_CenteredOnBothAxes()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(200, 0));
|
||||
p.Vertices.Add(new Vector(200, 10));
|
||||
p.Vertices.Add(new Vector(0, 10));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(100, result.X, 1.0);
|
||||
Assert.Equal(5, result.Y, 1.0);
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquareWithLargeHole_AvoidsHole()
|
||||
{
|
||||
var outer = Square(100);
|
||||
|
||||
var hole = new Polygon();
|
||||
hole.Vertices.Add(new Vector(20, 20));
|
||||
hole.Vertices.Add(new Vector(80, 20));
|
||||
hole.Vertices.Add(new Vector(80, 80));
|
||||
hole.Vertices.Add(new Vector(20, 80));
|
||||
|
||||
var result = PolyLabel.Find(outer, new[] { hole });
|
||||
|
||||
// Point should be inside outer but outside hole
|
||||
Assert.True(outer.ContainsPoint(result));
|
||||
Assert.False(hole.ContainsPoint(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CShape_ReturnsPointInLeftBar()
|
||||
{
|
||||
// C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(0, 0));
|
||||
p.Vertices.Add(new Vector(100, 0));
|
||||
p.Vertices.Add(new Vector(100, 20));
|
||||
p.Vertices.Add(new Vector(20, 20));
|
||||
p.Vertices.Add(new Vector(20, 80));
|
||||
p.Vertices.Add(new Vector(100, 80));
|
||||
p.Vertices.Add(new Vector(100, 100));
|
||||
p.Vertices.Add(new Vector(0, 100));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.True(p.ContainsPoint(result));
|
||||
// Label should be in the left vertical bar (x < 20), not at bbox center (50, 50)
|
||||
Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DegeneratePolygon_ReturnsFallback()
|
||||
{
|
||||
var p = new Polygon();
|
||||
p.Vertices.Add(new Vector(5, 5));
|
||||
|
||||
var result = PolyLabel.Find(p);
|
||||
|
||||
Assert.Equal(5, result.X, 0.01);
|
||||
Assert.Equal(5, result.Y, 0.01);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all PolyLabel tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter PolyLabelTests`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/PolyLabelTests.cs
|
||||
git commit -m "test(geometry): add PolyLabel tests for L, C, triangle, thin rect, hole"
|
||||
```
|
||||
|
||||
## Chunk 2: Label Rendering
|
||||
|
||||
### Task 3: Update LayoutPart label positioning
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/LayoutPart.cs`
|
||||
|
||||
- [ ] **Step 1: Add cached label point field and computation method**
|
||||
|
||||
Add a `Vector? _labelPoint` field and a method to compute it from the part's geometry. Uses `ShapeProfile` to identify the outer contour and holes.
|
||||
|
||||
```csharp
|
||||
// Add field near the top of the class (after the existing private fields):
|
||||
private Vector? _labelPoint;
|
||||
|
||||
// Add method:
|
||||
private Vector ComputeLabelPoint()
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(BasePart.Program);
|
||||
var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
|
||||
if (nonRapid.Count == 0)
|
||||
{
|
||||
var bbox = BasePart.Program.BoundingBox();
|
||||
return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2);
|
||||
}
|
||||
|
||||
var profile = new ShapeProfile(nonRapid);
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.1);
|
||||
|
||||
List<Polygon> holes = null;
|
||||
|
||||
if (profile.Cutouts.Count > 0)
|
||||
{
|
||||
holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
holes.Add(cutout.ToPolygonWithTolerance(0.1));
|
||||
}
|
||||
|
||||
return PolyLabel.Find(outer, holes);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Invalidate the cache when IsDirty is set**
|
||||
|
||||
Modify the `IsDirty` property to clear `_labelPoint`:
|
||||
|
||||
```csharp
|
||||
// Replace:
|
||||
internal bool IsDirty { get; set; }
|
||||
|
||||
// With:
|
||||
private bool _isDirty;
|
||||
internal bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set
|
||||
{
|
||||
_isDirty = value;
|
||||
if (value) _labelPoint = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add screen-space label point field and compute it in Update()**
|
||||
|
||||
Compute the polylabel in program-local coordinates (cached, expensive) and transform to screen space in `Update()` (cheap, runs on every zoom/pan).
|
||||
|
||||
```csharp
|
||||
// Add field:
|
||||
private PointF _labelScreenPoint;
|
||||
|
||||
// Replace existing Update():
|
||||
public void Update(DrawControl plateView)
|
||||
{
|
||||
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
|
||||
Path.Transform(plateView.Matrix);
|
||||
|
||||
_labelPoint ??= ComputeLabelPoint();
|
||||
var labelPt = new PointF(
|
||||
(float)(_labelPoint.Value.X + BasePart.Location.X),
|
||||
(float)(_labelPoint.Value.Y + BasePart.Location.Y));
|
||||
var pts = new[] { labelPt };
|
||||
plateView.Matrix.TransformPoints(pts);
|
||||
_labelScreenPoint = pts[0];
|
||||
|
||||
IsDirty = false;
|
||||
}
|
||||
```
|
||||
|
||||
Note: setting `IsDirty = false` at the end of `Update()` will NOT clear `_labelPoint` because the setter only clears when `value` is `true`.
|
||||
|
||||
- [ ] **Step 4: Update Draw(Graphics g, string id) to use the cached screen point**
|
||||
|
||||
```csharp
|
||||
// Replace the existing Draw(Graphics g, string id) method body.
|
||||
// Old code (lines 85-101 of LayoutPart.cs):
|
||||
// if (IsSelected) { ... } else { ... }
|
||||
// var pt = Path.PointCount > 0 ? Path.PathPoints[0] : PointF.Empty;
|
||||
// g.DrawString(id, programIdFont, Brushes.Black, pt.X, pt.Y);
|
||||
|
||||
// New code:
|
||||
public void Draw(Graphics g, string id)
|
||||
{
|
||||
if (IsSelected)
|
||||
{
|
||||
g.FillPath(selectedBrush, Path);
|
||||
g.DrawPath(selectedPen, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.FillPath(brush, Path);
|
||||
g.DrawPath(pen, Path);
|
||||
}
|
||||
|
||||
using var sf = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Center,
|
||||
LineAlignment = StringAlignment.Center
|
||||
};
|
||||
g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/LayoutPart.cs
|
||||
git commit -m "feat(ui): position part labels at polylabel center"
|
||||
```
|
||||
|
||||
### Task 4: Manual visual verification
|
||||
|
||||
- [ ] **Step 1: Run the application and verify labels**
|
||||
|
||||
Run the OpenNest application, load a nest with multiple parts, and verify:
|
||||
- Labels appear centered inside each part.
|
||||
- Labels don't overlap adjacent part edges.
|
||||
- Labels stay centered when zooming and panning.
|
||||
- Parts with holes have labels placed in the solid material, not in the hole.
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Final commit if any tweaks needed**
|
||||
@@ -1,998 +0,0 @@
|
||||
# Remnant Finder Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract remnant detection from the nesting engine into a standalone `RemnantFinder` class that finds all rectangular empty regions via edge projection, and visualize the active work area on the plate view.
|
||||
|
||||
**Architecture:** `RemnantFinder` is a mutable class in `OpenNest.Engine` that takes a work area + obstacle boxes and uses edge projection to find empty rectangles. The remainder phase is removed from `DefaultNestEngine`, making `Fill()` single-pass. `FillScore` drops remnant tracking. `PlateView` gains a dashed orange rectangle overlay for the active work area. `NestProgress` carries `ActiveWorkArea` so callers can show which region is currently being filled.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, xUnit, WinForms (GDI+)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-16-remnant-finder-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: RemnantFinder Core
|
||||
|
||||
### Task 1: RemnantFinder — failing tests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/RemnantFinderTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class RemnantFinderTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPlate_ReturnsWholeWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCorner_FindsLShapedRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find at least the right strip (60x100) and top strip (40x60)
|
||||
Assert.True(remnants.Count >= 2);
|
||||
|
||||
// Largest remnant should be the right strip
|
||||
var largest = remnants[0];
|
||||
Assert.Equal(60 * 100, largest.Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleObstacle_InCenter_FindsFourRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(30, 30, 40, 40));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find remnants on all four sides
|
||||
Assert.True(remnants.Count >= 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinDimension_FiltersSmallRemnants()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Obstacle leaves a 5-wide strip on the right
|
||||
finder.AddObstacle(new Box(0, 0, 95, 100));
|
||||
var all = finder.FindRemnants(0);
|
||||
var filtered = finder.FindRemnants(10);
|
||||
|
||||
Assert.True(all.Count > filtered.Count);
|
||||
foreach (var r in filtered)
|
||||
{
|
||||
Assert.True(r.Width >= 10);
|
||||
Assert.True(r.Length >= 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultsSortedByAreaDescending()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
for (var i = 1; i < remnants.Count; i++)
|
||||
Assert.True(remnants[i - 1].Area() >= remnants[i].Area());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddObstacle_UpdatesResults()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
var before = finder.FindRemnants();
|
||||
Assert.Single(before);
|
||||
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
var after = finder.FindRemnants();
|
||||
Assert.True(after.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearObstacles_ResetsToFullWorkArea()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 50, 50));
|
||||
finder.ClearObstacles();
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Single(remnants);
|
||||
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyCovered_ReturnsEmpty()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
finder.AddObstacle(new Box(0, 0, 100, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
Assert.Empty(remnants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleObstacles_FindsGapBetween()
|
||||
{
|
||||
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
||||
// Two obstacles with a 20-wide gap in the middle
|
||||
finder.AddObstacle(new Box(0, 0, 40, 100));
|
||||
finder.AddObstacle(new Box(60, 0, 40, 100));
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should find the 20x100 gap between the two obstacles
|
||||
var gap = remnants.FirstOrDefault(r =>
|
||||
r.Width >= 19.9 && r.Width <= 20.1 &&
|
||||
r.Length >= 99.9);
|
||||
Assert.NotNull(gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPlate_CreatesFinderWithPartsAsObstacles()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 20));
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
// Should have remnants around the 20x20 part
|
||||
Assert.True(remnants.Count >= 1);
|
||||
// Largest remnant area should be less than full plate work area
|
||||
Assert.True(remnants[0].Area() < plate.WorkArea().Area());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: FAIL — `RemnantFinder` class does not exist
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Tests/RemnantFinderTests.cs
|
||||
git commit -m "test: add RemnantFinder tests (red)"
|
||||
```
|
||||
|
||||
### Task 2: RemnantFinder — implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/RemnantFinder.cs`
|
||||
|
||||
- [ ] **Step 1: Implement RemnantFinder**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RemnantFinder
|
||||
{
|
||||
private readonly Box workArea;
|
||||
|
||||
public List<Box> Obstacles { get; } = new();
|
||||
|
||||
public RemnantFinder(Box workArea, List<Box> obstacles = null)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
|
||||
if (obstacles != null)
|
||||
Obstacles.AddRange(obstacles);
|
||||
}
|
||||
|
||||
public void AddObstacle(Box obstacle) => Obstacles.Add(obstacle);
|
||||
|
||||
public void AddObstacles(IEnumerable<Box> obstacles) => Obstacles.AddRange(obstacles);
|
||||
|
||||
public void ClearObstacles() => Obstacles.Clear();
|
||||
|
||||
public List<Box> FindRemnants(double minDimension = 0)
|
||||
{
|
||||
// Step 1-2: Collect unique X and Y coordinates
|
||||
var xs = new SortedSet<double> { workArea.Left, workArea.Right };
|
||||
var ys = new SortedSet<double> { workArea.Bottom, workArea.Top };
|
||||
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
var clipped = ClipToWorkArea(obs);
|
||||
if (clipped.Width <= 0 || clipped.Length <= 0)
|
||||
continue;
|
||||
|
||||
xs.Add(clipped.Left);
|
||||
xs.Add(clipped.Right);
|
||||
ys.Add(clipped.Bottom);
|
||||
ys.Add(clipped.Top);
|
||||
}
|
||||
|
||||
var xList = xs.ToList();
|
||||
var yList = ys.ToList();
|
||||
|
||||
// Step 3-4: Build grid cells and mark empty ones
|
||||
var cols = xList.Count - 1;
|
||||
var rows = yList.Count - 1;
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Box>();
|
||||
|
||||
var empty = new bool[rows, cols];
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
var cell = new Box(xList[c], yList[r],
|
||||
xList[c + 1] - xList[c], yList[r + 1] - yList[r]);
|
||||
|
||||
empty[r, c] = !OverlapsAnyObstacle(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Merge adjacent empty cells into larger rectangles
|
||||
var merged = MergeCells(empty, xList, yList, rows, cols);
|
||||
|
||||
// Step 6: Filter by minDimension
|
||||
var results = new List<Box>();
|
||||
|
||||
foreach (var box in merged)
|
||||
{
|
||||
if (box.Width >= minDimension && box.Length >= minDimension)
|
||||
results.Add(box);
|
||||
}
|
||||
|
||||
// Step 7: Sort by area descending
|
||||
results.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static RemnantFinder FromPlate(Plate plate)
|
||||
{
|
||||
var obstacles = new List<Box>(plate.Parts.Count);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
obstacles.Add(part.BoundingBox.Offset(plate.PartSpacing));
|
||||
|
||||
return new RemnantFinder(plate.WorkArea(), obstacles);
|
||||
}
|
||||
|
||||
private Box ClipToWorkArea(Box obs)
|
||||
{
|
||||
var left = System.Math.Max(obs.Left, workArea.Left);
|
||||
var bottom = System.Math.Max(obs.Bottom, workArea.Bottom);
|
||||
var right = System.Math.Min(obs.Right, workArea.Right);
|
||||
var top = System.Math.Min(obs.Top, workArea.Top);
|
||||
|
||||
if (right <= left || top <= bottom)
|
||||
return Box.Empty;
|
||||
|
||||
return new Box(left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
private bool OverlapsAnyObstacle(Box cell)
|
||||
{
|
||||
foreach (var obs in Obstacles)
|
||||
{
|
||||
var clipped = ClipToWorkArea(obs);
|
||||
|
||||
if (clipped.Width <= 0 || clipped.Length <= 0)
|
||||
continue;
|
||||
|
||||
if (cell.Left < clipped.Right &&
|
||||
cell.Right > clipped.Left &&
|
||||
cell.Bottom < clipped.Top &&
|
||||
cell.Top > clipped.Bottom)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Box> MergeCells(bool[,] empty, List<double> xList, List<double> yList, int rows, int cols)
|
||||
{
|
||||
var used = new bool[rows, cols];
|
||||
var results = new List<Box>();
|
||||
|
||||
for (var r = 0; r < rows; r++)
|
||||
{
|
||||
for (var c = 0; c < cols; c++)
|
||||
{
|
||||
if (!empty[r, c] || used[r, c])
|
||||
continue;
|
||||
|
||||
// Expand right as far as possible
|
||||
var maxC = c;
|
||||
while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1])
|
||||
maxC++;
|
||||
|
||||
// Expand down as far as possible
|
||||
var maxR = r;
|
||||
while (maxR + 1 < rows)
|
||||
{
|
||||
var rowOk = true;
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
{
|
||||
if (!empty[maxR + 1, cc] || used[maxR + 1, cc])
|
||||
{
|
||||
rowOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowOk) break;
|
||||
maxR++;
|
||||
}
|
||||
|
||||
// Mark cells as used
|
||||
for (var rr = r; rr <= maxR; rr++)
|
||||
for (var cc = c; cc <= maxC; cc++)
|
||||
used[rr, cc] = true;
|
||||
|
||||
var box = new Box(
|
||||
xList[c], yList[r],
|
||||
xList[maxC + 1] - xList[c],
|
||||
yList[maxR + 1] - yList[r]);
|
||||
|
||||
results.Add(box);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~RemnantFinderTests" -v minimal`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/RemnantFinder.cs
|
||||
git commit -m "feat: add RemnantFinder with edge projection algorithm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: FillScore Simplification and Remnant Cleanup
|
||||
|
||||
### Task 3: Simplify FillScore — remove remnant tracking
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/FillScore.cs`
|
||||
|
||||
- [ ] **Step 1: Remove remnant-related members from FillScore**
|
||||
|
||||
Remove `MinRemnantDimension`, `UsableRemnantArea`, `ComputeUsableRemnantArea()`. Simplify constructor and `Compute()`. Update `CompareTo` to compare count then density (no remnant area).
|
||||
|
||||
New `FillScore.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public readonly struct FillScore : System.IComparable<FillScore>
|
||||
{
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total part area / bounding box area of all placed parts.
|
||||
/// </summary>
|
||||
public double Density { get; }
|
||||
|
||||
public FillScore(int count, double density)
|
||||
{
|
||||
Count = count;
|
||||
Density = density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a fill score from placed parts and the work area they were placed in.
|
||||
/// </summary>
|
||||
public static FillScore Compute(List<Part> parts, Box workArea)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
return new FillScore(parts.Count, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lexicographic comparison: count, then density.
|
||||
/// </summary>
|
||||
public int CompareTo(FillScore other)
|
||||
{
|
||||
var c = Count.CompareTo(other.Count);
|
||||
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return Density.CompareTo(other.Density);
|
||||
}
|
||||
|
||||
public static bool operator >(FillScore a, FillScore b) => a.CompareTo(b) > 0;
|
||||
public static bool operator <(FillScore a, FillScore b) => a.CompareTo(b) < 0;
|
||||
public static bool operator >=(FillScore a, FillScore b) => a.CompareTo(b) >= 0;
|
||||
public static bool operator <=(FillScore a, FillScore b) => a.CompareTo(b) <= 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit (build will not pass yet — remaining UsableRemnantArea references fixed in Tasks 4-5)**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/FillScore.cs
|
||||
git commit -m "refactor: simplify FillScore to count + density, remove remnant tracking"
|
||||
```
|
||||
|
||||
### Task 4: Update DefaultNestEngine debug logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs:456-459`
|
||||
|
||||
- [ ] **Step 1: Update FillWithPairs debug log**
|
||||
|
||||
At line 456, change:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
```
|
||||
|
||||
Also update the file-based debug log at lines 457-459 — change `bestScore.UsableRemnantArea` references similarly. If the file log references `UsableRemnantArea`, remove that interpolation.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "fix: update FillWithPairs debug logging after FillScore simplification"
|
||||
```
|
||||
|
||||
### Task 5: Remove NestProgress.UsableRemnantArea and UI references
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:44`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:232`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:40`
|
||||
|
||||
- [ ] **Step 1: Remove UsableRemnantArea from NestProgress**
|
||||
|
||||
In `NestProgress.cs`, remove line 44:
|
||||
```csharp
|
||||
public double UsableRemnantArea { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove UsableRemnantArea from ReportProgress**
|
||||
|
||||
In `NestEngineBase.cs` at line 232, remove:
|
||||
```csharp
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove remnant display from NestProgressForm**
|
||||
|
||||
In `NestProgressForm.cs` at line 40, remove:
|
||||
```csharp
|
||||
remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in";
|
||||
```
|
||||
|
||||
Also remove the `remnantValue` label and its corresponding "Remnant:" label from the form's Designer file (or set them to display something else if desired). If simpler, just remove the line that sets the text — the label will remain but show its default empty text.
|
||||
|
||||
- [ ] **Step 4: Build to verify all UsableRemnantArea references are resolved**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds — all `UsableRemnantArea` references are now removed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove UsableRemnantArea from NestProgress and UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Remove Remainder Phase from Engine
|
||||
|
||||
### Task 6: Remove remainder phase from DefaultNestEngine
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
- [ ] **Step 1: Remove TryRemainderImprovement calls from Fill() overrides**
|
||||
|
||||
In the first `Fill()` override (line 31), remove lines 40-53 (the remainder improvement block after `FindBestFill`):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
var remainderSw = Stopwatch.StartNew();
|
||||
var improved = TryRemainderImprovement(item, workArea, best);
|
||||
// ... through to the closing brace
|
||||
}
|
||||
```
|
||||
|
||||
In the second `Fill()` override (line 118), remove lines 165-174 (the remainder improvement block inside the `if (groupParts.Count == 1)` block):
|
||||
```csharp
|
||||
// Remove this entire block:
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
if (IsBetterFill(improved, best, workArea))
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove TryRemainderImprovement, TryStripRefill, ClusterParts methods**
|
||||
|
||||
Remove the three private methods (lines 563-694):
|
||||
- `TryRemainderImprovement`
|
||||
- `TryStripRefill`
|
||||
- `ClusterParts`
|
||||
|
||||
- [ ] **Step 3: Update Description property**
|
||||
|
||||
Change:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/DefaultNestEngine.cs
|
||||
git commit -m "refactor: remove remainder phase from DefaultNestEngine"
|
||||
```
|
||||
|
||||
### Task 7: Remove NestPhase.Remainder and cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:11`
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:314`
|
||||
- Modify: `OpenNest\Forms\NestProgressForm.cs:100`
|
||||
|
||||
- [ ] **Step 1: Remove Remainder from NestPhase enum**
|
||||
|
||||
In `NestProgress.cs`, remove `Remainder` from the enum.
|
||||
|
||||
- [ ] **Step 2: Remove Remainder case from FormatPhaseName**
|
||||
|
||||
In `NestEngineBase.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Remainder";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove Remainder case from FormatPhase**
|
||||
|
||||
In `NestProgressForm.cs`, remove:
|
||||
```csharp
|
||||
case NestPhase.Remainder: return "Filling remainder...";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs OpenNest.Engine/NestEngineBase.cs OpenNest/Forms/NestProgressForm.cs
|
||||
git commit -m "refactor: remove NestPhase.Remainder enum value and switch cases"
|
||||
```
|
||||
|
||||
### Task 8: Remove ComputeRemainderWithin and update Nest()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs:92,120-133`
|
||||
|
||||
- [ ] **Step 1: Replace ComputeRemainderWithin usage in Nest()**
|
||||
|
||||
At line 91-92, change:
|
||||
```csharp
|
||||
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
|
||||
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var placedObstacles = parts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList();
|
||||
var finder = new RemnantFinder(workArea, placedObstacles);
|
||||
var remnants = finder.FindRemnants();
|
||||
if (remnants.Count == 0)
|
||||
break;
|
||||
workArea = remnants[0]; // Largest remnant
|
||||
```
|
||||
|
||||
Note: This is a behavioral improvement — the old code used a single merged bounding box and picked one strip. The new code finds per-part obstacles and discovers all gaps, using the largest. This may produce different (better) results for non-rectangular layouts.
|
||||
|
||||
- [ ] **Step 2: Remove ComputeRemainderWithin method**
|
||||
|
||||
Delete lines 120-133 (the `ComputeRemainderWithin` static method).
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "refactor: replace ComputeRemainderWithin with RemnantFinder in Nest()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Remove Old Remnant Code and Update Callers
|
||||
|
||||
### Task 9: Remove Plate.GetRemnants()
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Plate.cs:477-557`
|
||||
|
||||
- [ ] **Step 1: Remove GetRemnants method**
|
||||
|
||||
Delete the `GetRemnants()` method (lines 477-557, the XML doc comment through the closing brace).
|
||||
|
||||
- [ ] **Step 2: Build to check for remaining references**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Errors in `NestingTools.cs` and `InspectionTools.cs` (fixed in next task)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Plate.cs
|
||||
git commit -m "refactor: remove Plate.GetRemnants(), replaced by RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 10: Update MCP callers
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Mcp/Tools/NestingTools.cs:105`
|
||||
- Modify: `OpenNest.Mcp/Tools/InspectionTools.cs:31`
|
||||
|
||||
- [ ] **Step 1: Update NestingTools.FillRemnants**
|
||||
|
||||
At line 105, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update InspectionTools.GetPlateInfo**
|
||||
|
||||
At line 31, change:
|
||||
```csharp
|
||||
var remnants = plate.GetRemnants();
|
||||
```
|
||||
to:
|
||||
```csharp
|
||||
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/NestingTools.cs OpenNest.Mcp/Tools/InspectionTools.cs
|
||||
git commit -m "refactor: update MCP tools to use RemnantFinder"
|
||||
```
|
||||
|
||||
### Task 11: Remove StripNestResult.RemnantBox
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/StripNestResult.cs:10`
|
||||
- Modify: `OpenNest.Engine/StripNestEngine.cs:301`
|
||||
|
||||
- [ ] **Step 1: Remove RemnantBox property from StripNestResult**
|
||||
|
||||
In `StripNestResult.cs`, remove line 10:
|
||||
```csharp
|
||||
public Box RemnantBox { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove RemnantBox assignment in StripNestEngine**
|
||||
|
||||
In `StripNestEngine.cs` at line 301, remove:
|
||||
```csharp
|
||||
result.RemnantBox = remnantBox;
|
||||
```
|
||||
|
||||
Also check if the local `remnantBox` variable is now unused — if so, remove its declaration and computation too.
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/StripNestResult.cs OpenNest.Engine/StripNestEngine.cs
|
||||
git commit -m "refactor: remove StripNestResult.RemnantBox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: PlateView Active Work Area Visualization
|
||||
|
||||
### Task 12: Add ActiveWorkArea to NestProgress
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property to NestProgress**
|
||||
|
||||
`Box` is a reference type (class), so use `Box` directly (not `Box?`):
|
||||
|
||||
```csharp
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
```
|
||||
|
||||
`NestProgress.cs` already has `using OpenNest.Geometry;` via the `Box` usage in existing properties. If not, add it.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Engine`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestProgress.cs
|
||||
git commit -m "feat: add ActiveWorkArea property to NestProgress"
|
||||
```
|
||||
|
||||
### Task 13: Draw active work area on PlateView
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs`
|
||||
|
||||
- [ ] **Step 1: Add ActiveWorkArea property**
|
||||
|
||||
Add a field and property to `PlateView`:
|
||||
```csharp
|
||||
private Box activeWorkArea;
|
||||
|
||||
public Box ActiveWorkArea
|
||||
{
|
||||
get => activeWorkArea;
|
||||
set
|
||||
{
|
||||
activeWorkArea = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add DrawActiveWorkArea method**
|
||||
|
||||
Add a private method to draw the dashed orange rectangle, using the same coordinate transform pattern as `DrawBox` (line 591-601):
|
||||
|
||||
```csharp
|
||||
private void DrawActiveWorkArea(Graphics g)
|
||||
{
|
||||
if (activeWorkArea == null)
|
||||
return;
|
||||
|
||||
var rect = new RectangleF
|
||||
{
|
||||
Location = PointWorldToGraph(activeWorkArea.Location),
|
||||
Width = LengthWorldToGui(activeWorkArea.Width),
|
||||
Height = LengthWorldToGui(activeWorkArea.Length)
|
||||
};
|
||||
rect.Y -= rect.Height;
|
||||
|
||||
using var pen = new Pen(Color.Orange, 2f)
|
||||
{
|
||||
DashStyle = DashStyle.Dash
|
||||
};
|
||||
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call DrawActiveWorkArea in OnPaint**
|
||||
|
||||
In `OnPaint` (line 363-364), add the call after `DrawParts`:
|
||||
```csharp
|
||||
DrawPlate(e.Graphics);
|
||||
DrawParts(e.Graphics);
|
||||
DrawActiveWorkArea(e.Graphics);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs
|
||||
git commit -m "feat: draw active work area as dashed orange rectangle on PlateView"
|
||||
```
|
||||
|
||||
### Task 14: Wire ActiveWorkArea through progress callbacks
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest\Controls\PlateView.cs:828-829`
|
||||
- Modify: `OpenNest\Forms\MainForm.cs:760-761,895-896,955-956`
|
||||
|
||||
The `PlateView` and `MainForm` both have progress callbacks that already set `SetTemporaryParts`. Add `ActiveWorkArea` alongside those.
|
||||
|
||||
- [ ] **Step 1: Update PlateView.FillWithProgress callback**
|
||||
|
||||
At `PlateView.cs` line 828-829, the callback currently does:
|
||||
```csharp
|
||||
progressForm.UpdateProgress(p);
|
||||
SetTemporaryParts(p.BestParts);
|
||||
```
|
||||
|
||||
Add after `SetTemporaryParts`:
|
||||
```csharp
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MainForm progress callbacks**
|
||||
|
||||
There are three progress callback sites in `MainForm.cs`. At each one, after the `SetTemporaryParts` call, add:
|
||||
|
||||
At line 761 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 896 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
At line 956 (after `activeForm.PlateView.SetTemporaryParts(p.BestParts);`):
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Clear ActiveWorkArea when nesting completes**
|
||||
|
||||
In each nesting method's completion/cleanup path, clear the work area overlay. In `PlateView.cs` after the fill task completes (near `progressForm.ShowCompleted()`), add:
|
||||
```csharp
|
||||
ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
Similarly in each `MainForm` nesting method's completion path:
|
||||
```csharp
|
||||
activeForm.PlateView.ActiveWorkArea = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs
|
||||
git commit -m "feat: wire ActiveWorkArea from NestProgress to PlateView"
|
||||
```
|
||||
|
||||
### Task 15: Set ActiveWorkArea in Nest() method
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestEngineBase.cs` (the `Nest()` method updated in Task 8)
|
||||
|
||||
- [ ] **Step 1: Report ActiveWorkArea in Nest() progress**
|
||||
|
||||
In the `Nest()` method, after picking the largest remnant as the next work area (Task 8's change), set `ActiveWorkArea` on the progress report. Find the `ReportProgress` call inside or near the fill loop and ensure the progress object carries the current `workArea`.
|
||||
|
||||
The simplest approach: pass the work area through `ReportProgress`. In `NestEngineBase.ReportProgress` (the static helper), add `ActiveWorkArea = workArea` to the `NestProgress` initializer:
|
||||
|
||||
In `ReportProgress`, add to the `new NestProgress { ... }` block:
|
||||
```csharp
|
||||
ActiveWorkArea = workArea,
|
||||
```
|
||||
|
||||
This ensures every progress report includes the current work area being filled.
|
||||
|
||||
- [ ] **Step 2: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: Build succeeds, all tests pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/NestEngineBase.cs
|
||||
git commit -m "feat: report ActiveWorkArea in NestProgress from ReportProgress"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6: Final Verification
|
||||
|
||||
### Task 16: Full build and test
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: 0 errors, 0 warnings related to remnant code
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v minimal`
|
||||
Expected: All tests pass including new `RemnantFinderTests`
|
||||
|
||||
- [ ] **Step 3: Verify no stale references**
|
||||
|
||||
Run: `grep -rn "GetRemnants\|ComputeRemainderWithin\|TryRemainderImprovement\|MinRemnantDimension\|UsableRemnantArea" --include="*.cs" .`
|
||||
Expected: No matches in source files (only in docs/specs/plans)
|
||||
|
||||
- [ ] **Step 4: Final commit if any fixups needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final cleanup after remnant finder extraction"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,877 +0,0 @@
|
||||
# Pattern Tile Layout Window Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a tool window where the user selects two drawings, arranges them as a unit cell, and tiles the pattern across a configurable plate with an option to apply the result.
|
||||
|
||||
**Architecture:** A `PatternTileForm` dialog with a horizontal `SplitContainer` — left panel is a `PlateView` for unit cell editing (plate outline hidden via zero-size plate), right panel is a read-only `PlateView` for tile preview. A `PatternTiler` helper in Engine handles the tiling math (deliberate addition beyond the spec for separation of concerns — the spec says "no engine changes" but the tiler is pure logic with no side-effects). The form is opened from the Tools menu and returns a result to `EditNestForm` for placement.
|
||||
|
||||
**Tech Stack:** WinForms (.NET 8), existing `PlateView`/`DrawControl` controls, `Compactor.Push` (angle-based overload), `Part.CloneAtOffset`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-pattern-tile-layout-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PatternTiler — tiling algorithm in Engine
|
||||
|
||||
The pure logic component that takes a unit cell (list of parts) and tiles it across a plate work area. No UI dependency.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/PatternTiler.cs`
|
||||
- Test: `OpenNest.Tests/PatternTilerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for PatternTiler**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Tests/PatternTilerTests.cs
|
||||
using OpenNest;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class PatternTilerTests
|
||||
{
|
||||
private static Drawing MakeSquareDrawing(double size)
|
||||
{
|
||||
var pgm = new CNC.Program();
|
||||
pgm.Add(new CNC.LinearMove(size, 0));
|
||||
pgm.Add(new CNC.LinearMove(size, size));
|
||||
pgm.Add(new CNC.LinearMove(0, size));
|
||||
pgm.Add(new CNC.LinearMove(0, 0));
|
||||
return new Drawing("square", pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_SinglePart_FillsGrid()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
// Size(width=X, length=Y) — 30 wide, 20 tall
|
||||
var plateSize = new Size(30, 20);
|
||||
var partSpacing = 0.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// 3 columns (30/10) x 2 rows (20/10) = 6 parts
|
||||
Assert.Equal(6, result.Count);
|
||||
|
||||
// Verify all parts are within plate bounds
|
||||
foreach (var part in result)
|
||||
{
|
||||
Assert.True(part.BoundingBox.Right <= plateSize.Width + 0.001);
|
||||
Assert.True(part.BoundingBox.Top <= plateSize.Length + 0.001);
|
||||
Assert.True(part.BoundingBox.Left >= -0.001);
|
||||
Assert.True(part.BoundingBox.Bottom >= -0.001);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_TwoParts_TilesUnitCell()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var partA = Part.CreateAtOrigin(drawing);
|
||||
var partB = Part.CreateAtOrigin(drawing);
|
||||
partB.Offset(10, 0); // side by side, cell = 20x10
|
||||
|
||||
var cell = new List<Part> { partA, partB };
|
||||
var plateSize = new Size(40, 20);
|
||||
var partSpacing = 0.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// 2 columns (40/20) x 2 rows (20/10) = 4 cells x 2 parts = 8
|
||||
Assert.Equal(8, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_WithSpacing_ReducesCount()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
var plateSize = new Size(30, 20);
|
||||
var partSpacing = 2.0;
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||
|
||||
// cell width = 10 + 2 = 12, cols = floor(30/12) = 2
|
||||
// cell height = 10 + 2 = 12, rows = floor(20/12) = 1
|
||||
// 2 x 1 = 2 parts
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_EmptyCell_ReturnsEmpty()
|
||||
{
|
||||
var result = PatternTiler.Tile(new List<Part>(), new Size(100, 100), 0);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_NonSquarePlate_CorrectAxes()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(10);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
// Wide plate: 50 in X (Width), 10 in Y (Length) — should fit 5x1
|
||||
var plateSize = new Size(50, 10);
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
|
||||
// Verify parts span the X axis, not Y
|
||||
var maxRight = result.Max(p => p.BoundingBox.Right);
|
||||
var maxTop = result.Max(p => p.BoundingBox.Top);
|
||||
Assert.True(maxRight <= 50.001);
|
||||
Assert.True(maxTop <= 10.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_CellLargerThanPlate_ReturnsSingleCell()
|
||||
{
|
||||
var drawing = MakeSquareDrawing(50);
|
||||
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||
var plateSize = new Size(30, 30);
|
||||
|
||||
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||
|
||||
// Cell doesn't fit at all — 0 parts
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||
Expected: Build error — `PatternTiler` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement PatternTiler**
|
||||
|
||||
```csharp
|
||||
// OpenNest.Engine/PatternTiler.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public static class PatternTiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Tiles a unit cell across a plate, returning cloned parts.
|
||||
/// </summary>
|
||||
/// <param name="cell">The unit cell parts (positioned relative to each other).</param>
|
||||
/// <param name="plateSize">The plate size to tile across.</param>
|
||||
/// <param name="partSpacing">Spacing to add around each cell.</param>
|
||||
/// <returns>List of cloned parts filling the plate.</returns>
|
||||
public static List<Part> Tile(List<Part> cell, Size plateSize, double partSpacing)
|
||||
{
|
||||
if (cell == null || cell.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var cellBox = cell.GetBoundingBox();
|
||||
var halfSpacing = partSpacing / 2;
|
||||
|
||||
var cellWidth = cellBox.Width + partSpacing;
|
||||
var cellHeight = cellBox.Length + partSpacing;
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Size.Width = X-axis, Size.Length = Y-axis
|
||||
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
|
||||
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Offset to normalize cell origin to (halfSpacing, halfSpacing)
|
||||
var cellOrigin = cellBox.Location;
|
||||
var baseOffset = new Vector(halfSpacing - cellOrigin.X, halfSpacing - cellOrigin.Y);
|
||||
|
||||
var result = new List<Part>(cols * rows * cell.Count);
|
||||
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
|
||||
|
||||
foreach (var part in cell)
|
||||
{
|
||||
result.Add(part.CloneAtOffset(tileOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||
Expected: All 6 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Engine/PatternTiler.cs OpenNest.Tests/PatternTilerTests.cs
|
||||
git commit -m "feat(engine): add PatternTiler for unit cell tiling across plates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PatternTileForm — the dialog window
|
||||
|
||||
The WinForms dialog with split layout, drawing pickers, plate size controls, and the two `PlateView` panels. This task builds the form shell and layout — interaction logic comes in Task 3.
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest/Forms/PatternTileForm.cs`
|
||||
- Create: `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||
|
||||
**Key reference files:**
|
||||
- `OpenNest/Forms/BestFitViewerForm.cs` — similar standalone tool form pattern
|
||||
- `OpenNest/Controls/PlateView.cs` — the control used in both panels
|
||||
- `OpenNest/Forms/EditPlateForm.cs` — plate size input pattern
|
||||
|
||||
- [ ] **Step 1: Create PatternTileForm.Designer.cs**
|
||||
|
||||
```csharp
|
||||
// OpenNest/Forms/PatternTileForm.Designer.cs
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class PatternTileForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
components.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.topPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
this.lblDrawingA = new System.Windows.Forms.Label();
|
||||
this.cboDrawingA = new System.Windows.Forms.ComboBox();
|
||||
this.lblDrawingB = new System.Windows.Forms.Label();
|
||||
this.cboDrawingB = new System.Windows.Forms.ComboBox();
|
||||
this.lblPlateSize = new System.Windows.Forms.Label();
|
||||
this.txtPlateSize = new System.Windows.Forms.TextBox();
|
||||
this.lblPartSpacing = new System.Windows.Forms.Label();
|
||||
this.nudPartSpacing = new System.Windows.Forms.NumericUpDown();
|
||||
this.btnAutoArrange = new System.Windows.Forms.Button();
|
||||
this.btnApply = new System.Windows.Forms.Button();
|
||||
this.splitContainer = new System.Windows.Forms.SplitContainer();
|
||||
this.topPanel.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).BeginInit();
|
||||
this.splitContainer.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// topPanel — FlowLayoutPanel for correct left-to-right ordering
|
||||
//
|
||||
this.topPanel.Controls.Add(this.lblDrawingA);
|
||||
this.topPanel.Controls.Add(this.cboDrawingA);
|
||||
this.topPanel.Controls.Add(this.lblDrawingB);
|
||||
this.topPanel.Controls.Add(this.cboDrawingB);
|
||||
this.topPanel.Controls.Add(this.lblPlateSize);
|
||||
this.topPanel.Controls.Add(this.txtPlateSize);
|
||||
this.topPanel.Controls.Add(this.lblPartSpacing);
|
||||
this.topPanel.Controls.Add(this.nudPartSpacing);
|
||||
this.topPanel.Controls.Add(this.btnAutoArrange);
|
||||
this.topPanel.Controls.Add(this.btnApply);
|
||||
this.topPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.topPanel.Height = 36;
|
||||
this.topPanel.Padding = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||
this.topPanel.WrapContents = false;
|
||||
//
|
||||
// lblDrawingA
|
||||
//
|
||||
this.lblDrawingA.Text = "Drawing A:";
|
||||
this.lblDrawingA.AutoSize = true;
|
||||
this.lblDrawingA.Margin = new System.Windows.Forms.Padding(3, 5, 0, 0);
|
||||
//
|
||||
// cboDrawingA
|
||||
//
|
||||
this.cboDrawingA.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.cboDrawingA.Width = 130;
|
||||
//
|
||||
// lblDrawingB
|
||||
//
|
||||
this.lblDrawingB.Text = "Drawing B:";
|
||||
this.lblDrawingB.AutoSize = true;
|
||||
this.lblDrawingB.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// cboDrawingB
|
||||
//
|
||||
this.cboDrawingB.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.cboDrawingB.Width = 130;
|
||||
//
|
||||
// lblPlateSize
|
||||
//
|
||||
this.lblPlateSize.Text = "Plate Size:";
|
||||
this.lblPlateSize.AutoSize = true;
|
||||
this.lblPlateSize.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// txtPlateSize
|
||||
//
|
||||
this.txtPlateSize.Width = 80;
|
||||
//
|
||||
// lblPartSpacing
|
||||
//
|
||||
this.lblPartSpacing.Text = "Spacing:";
|
||||
this.lblPartSpacing.AutoSize = true;
|
||||
this.lblPartSpacing.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||
//
|
||||
// nudPartSpacing
|
||||
//
|
||||
this.nudPartSpacing.Width = 60;
|
||||
this.nudPartSpacing.DecimalPlaces = 2;
|
||||
this.nudPartSpacing.Increment = 0.25m;
|
||||
this.nudPartSpacing.Maximum = 100;
|
||||
this.nudPartSpacing.Minimum = 0;
|
||||
//
|
||||
// btnAutoArrange
|
||||
//
|
||||
this.btnAutoArrange.Text = "Auto-Arrange";
|
||||
this.btnAutoArrange.Width = 100;
|
||||
this.btnAutoArrange.Margin = new System.Windows.Forms.Padding(10, 0, 0, 0);
|
||||
//
|
||||
// btnApply
|
||||
//
|
||||
this.btnApply.Text = "Apply...";
|
||||
this.btnApply.Width = 80;
|
||||
//
|
||||
// splitContainer
|
||||
//
|
||||
this.splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.splitContainer.SplitterDistance = 350;
|
||||
//
|
||||
// PatternTileForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(900, 550);
|
||||
this.Controls.Add(this.splitContainer);
|
||||
this.Controls.Add(this.topPanel);
|
||||
this.MinimumSize = new System.Drawing.Size(700, 400);
|
||||
this.Name = "PatternTileForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Pattern Tile Layout";
|
||||
this.topPanel.ResumeLayout(false);
|
||||
this.topPanel.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).EndInit();
|
||||
this.splitContainer.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.FlowLayoutPanel topPanel;
|
||||
private System.Windows.Forms.Label lblDrawingA;
|
||||
private System.Windows.Forms.ComboBox cboDrawingA;
|
||||
private System.Windows.Forms.Label lblDrawingB;
|
||||
private System.Windows.Forms.ComboBox cboDrawingB;
|
||||
private System.Windows.Forms.Label lblPlateSize;
|
||||
private System.Windows.Forms.TextBox txtPlateSize;
|
||||
private System.Windows.Forms.Label lblPartSpacing;
|
||||
private System.Windows.Forms.NumericUpDown nudPartSpacing;
|
||||
private System.Windows.Forms.Button btnAutoArrange;
|
||||
private System.Windows.Forms.Button btnApply;
|
||||
private System.Windows.Forms.SplitContainer splitContainer;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create PatternTileForm.cs — form shell with PlateViews**
|
||||
|
||||
```csharp
|
||||
// OpenNest/Forms/PatternTileForm.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
public enum PatternTileTarget
|
||||
{
|
||||
CurrentPlate,
|
||||
NewPlate
|
||||
}
|
||||
|
||||
public class PatternTileResult
|
||||
{
|
||||
public List<Part> Parts { get; set; }
|
||||
public PatternTileTarget Target { get; set; }
|
||||
public Size PlateSize { get; set; }
|
||||
}
|
||||
|
||||
public partial class PatternTileForm : Form
|
||||
{
|
||||
private readonly Nest nest;
|
||||
private readonly PlateView cellView;
|
||||
private readonly PlateView previewView;
|
||||
|
||||
public PatternTileResult Result { get; private set; }
|
||||
|
||||
public PatternTileForm(Nest nest)
|
||||
{
|
||||
this.nest = nest;
|
||||
InitializeComponent();
|
||||
|
||||
// Unit cell editor — plate outline hidden via zero-size plate
|
||||
cellView = new PlateView();
|
||||
cellView.Plate.Size = new Size(0, 0);
|
||||
cellView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||
cellView.DrawOrigin = false;
|
||||
cellView.DrawBounds = false; // hide selection bounding box overlay
|
||||
cellView.Dock = DockStyle.Fill;
|
||||
splitContainer.Panel1.Controls.Add(cellView);
|
||||
|
||||
// Tile preview — plate visible, read-only
|
||||
previewView = new PlateView();
|
||||
previewView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||
previewView.AllowSelect = false;
|
||||
previewView.AllowDrop = false;
|
||||
previewView.DrawBounds = false;
|
||||
previewView.Dock = DockStyle.Fill;
|
||||
splitContainer.Panel2.Controls.Add(previewView);
|
||||
|
||||
// Populate drawing dropdowns
|
||||
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||
cboDrawingA.Items.Add("(none)");
|
||||
cboDrawingB.Items.Add("(none)");
|
||||
foreach (var d in drawings)
|
||||
{
|
||||
cboDrawingA.Items.Add(d);
|
||||
cboDrawingB.Items.Add(d);
|
||||
}
|
||||
cboDrawingA.SelectedIndex = 0;
|
||||
cboDrawingB.SelectedIndex = 0;
|
||||
|
||||
// Default plate size from nest defaults
|
||||
var defaults = nest.PlateDefaults;
|
||||
txtPlateSize.Text = defaults.Size.ToString();
|
||||
nudPartSpacing.Value = (decimal)defaults.PartSpacing;
|
||||
|
||||
// Wire events
|
||||
cboDrawingA.SelectedIndexChanged += OnDrawingChanged;
|
||||
cboDrawingB.SelectedIndexChanged += OnDrawingChanged;
|
||||
txtPlateSize.TextChanged += OnPlateSettingsChanged;
|
||||
nudPartSpacing.ValueChanged += OnPlateSettingsChanged;
|
||||
btnAutoArrange.Click += OnAutoArrangeClick;
|
||||
btnApply.Click += OnApplyClick;
|
||||
cellView.MouseUp += OnCellMouseUp;
|
||||
}
|
||||
|
||||
private Drawing SelectedDrawingA =>
|
||||
cboDrawingA.SelectedItem as Drawing;
|
||||
|
||||
private Drawing SelectedDrawingB =>
|
||||
cboDrawingB.SelectedItem as Drawing;
|
||||
|
||||
private double PartSpacing =>
|
||||
(double)nudPartSpacing.Value;
|
||||
|
||||
private bool TryGetPlateSize(out Size size)
|
||||
{
|
||||
return Size.TryParse(txtPlateSize.Text, out size);
|
||||
}
|
||||
|
||||
private void OnDrawingChanged(object sender, EventArgs e)
|
||||
{
|
||||
RebuildCell();
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void OnPlateSettingsChanged(object sender, EventArgs e)
|
||||
{
|
||||
UpdatePreviewPlateSize();
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void OnCellMouseUp(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button == MouseButtons.Left && cellView.Plate.Parts.Count == 2)
|
||||
{
|
||||
CompactCellParts();
|
||||
}
|
||||
|
||||
RebuildPreview();
|
||||
}
|
||||
|
||||
private void RebuildCell()
|
||||
{
|
||||
cellView.Plate.Parts.Clear();
|
||||
|
||||
var drawingA = SelectedDrawingA;
|
||||
var drawingB = SelectedDrawingB;
|
||||
|
||||
if (drawingA == null && drawingB == null)
|
||||
return;
|
||||
|
||||
if (drawingA != null)
|
||||
{
|
||||
var partA = Part.CreateAtOrigin(drawingA);
|
||||
cellView.Plate.Parts.Add(partA);
|
||||
}
|
||||
|
||||
if (drawingB != null)
|
||||
{
|
||||
var partB = Part.CreateAtOrigin(drawingB);
|
||||
|
||||
// Place B to the right of A (or at origin if A is null)
|
||||
if (drawingA != null && cellView.Plate.Parts.Count > 0)
|
||||
{
|
||||
var aBox = cellView.Plate.Parts[0].BoundingBox;
|
||||
partB.Offset(aBox.Right + PartSpacing, 0);
|
||||
}
|
||||
|
||||
cellView.Plate.Parts.Add(partB);
|
||||
}
|
||||
|
||||
cellView.ZoomToFit();
|
||||
}
|
||||
|
||||
private void CompactCellParts()
|
||||
{
|
||||
var parts = cellView.Plate.Parts.ToList();
|
||||
if (parts.Count < 2)
|
||||
return;
|
||||
|
||||
var combinedBox = parts.GetBoundingBox();
|
||||
var centroid = combinedBox.Center;
|
||||
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||
|
||||
for (var iteration = 0; iteration < 10; iteration++)
|
||||
{
|
||||
var totalMoved = 0.0;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var partCenter = part.BoundingBox.Center;
|
||||
var dx = centroid.X - partCenter.X;
|
||||
var dy = centroid.Y - partCenter.Y;
|
||||
var dist = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 0.01)
|
||||
continue;
|
||||
|
||||
var angle = System.Math.Atan2(dy, dx);
|
||||
var single = new List<Part> { part };
|
||||
var obstacles = parts.Where(p => p != part).ToList();
|
||||
|
||||
totalMoved += Compactor.Push(single, obstacles,
|
||||
syntheticWorkArea, PartSpacing, angle);
|
||||
}
|
||||
|
||||
if (totalMoved < 0.01)
|
||||
break;
|
||||
}
|
||||
|
||||
cellView.Refresh();
|
||||
}
|
||||
|
||||
private void UpdatePreviewPlateSize()
|
||||
{
|
||||
if (TryGetPlateSize(out var size))
|
||||
previewView.Plate.Size = size;
|
||||
}
|
||||
|
||||
private void RebuildPreview()
|
||||
{
|
||||
previewView.Plate.Parts.Clear();
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
previewView.Plate.Size = plateSize;
|
||||
previewView.Plate.PartSpacing = PartSpacing;
|
||||
|
||||
var cellParts = cellView.Plate.Parts.ToList();
|
||||
if (cellParts.Count == 0)
|
||||
return;
|
||||
|
||||
var tiled = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||
|
||||
foreach (var part in tiled)
|
||||
previewView.Plate.Parts.Add(part);
|
||||
|
||||
previewView.ZoomToFit();
|
||||
}
|
||||
|
||||
private void OnAutoArrangeClick(object sender, EventArgs e)
|
||||
{
|
||||
var drawingA = SelectedDrawingA;
|
||||
var drawingB = SelectedDrawingB;
|
||||
|
||||
if (drawingA == null || drawingB == null)
|
||||
return;
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
Cursor = Cursors.WaitCursor;
|
||||
try
|
||||
{
|
||||
var angles = new[] { 0.0, Math.Angle.ToRadians(90), Math.Angle.ToRadians(180), Math.Angle.ToRadians(270) };
|
||||
var bestCell = (List<Part>)null;
|
||||
var bestArea = double.MaxValue;
|
||||
|
||||
foreach (var angleA in angles)
|
||||
{
|
||||
foreach (var angleB in angles)
|
||||
{
|
||||
var partA = Part.CreateAtOrigin(drawingA, angleA);
|
||||
var partB = Part.CreateAtOrigin(drawingB, angleB);
|
||||
partB.Offset(partA.BoundingBox.Right + PartSpacing, 0);
|
||||
|
||||
var cell = new List<Part> { partA, partB };
|
||||
|
||||
// Compact toward center
|
||||
var box = cell.GetBoundingBox();
|
||||
var centroid = box.Center;
|
||||
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var moved = 0.0;
|
||||
foreach (var part in cell)
|
||||
{
|
||||
var pc = part.BoundingBox.Center;
|
||||
var dx = centroid.X - pc.X;
|
||||
var dy = centroid.Y - pc.Y;
|
||||
if (System.Math.Sqrt(dx * dx + dy * dy) < 0.01)
|
||||
continue;
|
||||
|
||||
var angle = System.Math.Atan2(dy, dx);
|
||||
var single = new List<Part> { part };
|
||||
var obstacles = cell.Where(p => p != part).ToList();
|
||||
moved += Compactor.Push(single, obstacles, syntheticWorkArea, PartSpacing, angle);
|
||||
}
|
||||
if (moved < 0.01) break;
|
||||
}
|
||||
|
||||
var finalBox = cell.GetBoundingBox();
|
||||
var area = finalBox.Width * finalBox.Length;
|
||||
|
||||
if (area < bestArea)
|
||||
{
|
||||
bestArea = area;
|
||||
bestCell = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCell != null)
|
||||
{
|
||||
cellView.Plate.Parts.Clear();
|
||||
foreach (var part in bestCell)
|
||||
cellView.Plate.Parts.Add(part);
|
||||
cellView.ZoomToFit();
|
||||
RebuildPreview();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Cursor = Cursors.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplyClick(object sender, EventArgs e)
|
||||
{
|
||||
if (previewView.Plate.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
if (!TryGetPlateSize(out var plateSize))
|
||||
return;
|
||||
|
||||
var choice = MessageBox.Show(
|
||||
"Apply pattern to current plate?\n\nYes = Current plate (clears existing parts)\nNo = New plate",
|
||||
"Apply Pattern",
|
||||
MessageBoxButtons.YesNoCancel,
|
||||
MessageBoxIcon.Question);
|
||||
|
||||
if (choice == DialogResult.Cancel)
|
||||
return;
|
||||
|
||||
// Rebuild a fresh set of tiled parts for the caller
|
||||
var cellParts = cellView.Plate.Parts.ToList();
|
||||
var tiledParts = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||
|
||||
Result = new PatternTileResult
|
||||
{
|
||||
Parts = tiledParts,
|
||||
Target = choice == DialogResult.Yes
|
||||
? PatternTileTarget.CurrentPlate
|
||||
: PatternTileTarget.NewPlate,
|
||||
PlateSize = plateSize
|
||||
};
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify compilation**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/PatternTileForm.cs OpenNest/Forms/PatternTileForm.Designer.cs
|
||||
git commit -m "feat(ui): add PatternTileForm dialog with unit cell editor and tile preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire up menu entry and apply logic in MainForm
|
||||
|
||||
Add a "Pattern Tile" menu item under Tools, wire it to open `PatternTileForm`, and handle the result by placing parts on the target plate.
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest/Forms/MainForm.Designer.cs` — add menu item
|
||||
- Modify: `OpenNest/Forms/MainForm.cs` — add click handler and apply logic
|
||||
|
||||
- [ ] **Step 1: Add menu item to MainForm.Designer.cs**
|
||||
|
||||
In the `InitializeComponent` method:
|
||||
|
||||
1. Add field declaration at end of class (near the other `mnuTools*` fields):
|
||||
```csharp
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
|
||||
```
|
||||
|
||||
2. In `InitializeComponent`, add initialization (near the other `mnuTools*` instantiations):
|
||||
```csharp
|
||||
this.mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
|
||||
```
|
||||
|
||||
3. Add to the `mnuTools.DropDownItems` array — insert `mnuToolsPatternTile` after `mnuToolsBestFitViewer`:
|
||||
```csharp
|
||||
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||
mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign,
|
||||
toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement,
|
||||
toolStripMenuItem15, mnuToolsOptions });
|
||||
```
|
||||
|
||||
4. Add configuration block:
|
||||
```csharp
|
||||
// mnuToolsPatternTile
|
||||
this.mnuToolsPatternTile.Name = "mnuToolsPatternTile";
|
||||
this.mnuToolsPatternTile.Size = new System.Drawing.Size(214, 22);
|
||||
this.mnuToolsPatternTile.Text = "Pattern Tile";
|
||||
this.mnuToolsPatternTile.Click += PatternTile_Click;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add click handler and apply logic to MainForm.cs**
|
||||
|
||||
Add in the `#region Tools Menu Events` section, after `BestFitViewer_Click`:
|
||||
|
||||
```csharp
|
||||
private void PatternTile_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
if (activeForm.Nest.Drawings.Count == 0)
|
||||
{
|
||||
MessageBox.Show("No drawings available.", "Pattern Tile",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
using (var form = new PatternTileForm(activeForm.Nest))
|
||||
{
|
||||
if (form.ShowDialog(this) != DialogResult.OK || form.Result == null)
|
||||
return;
|
||||
|
||||
var result = form.Result;
|
||||
|
||||
if (result.Target == PatternTileTarget.CurrentPlate)
|
||||
{
|
||||
activeForm.PlateView.Plate.Parts.Clear();
|
||||
|
||||
foreach (var part in result.Parts)
|
||||
activeForm.PlateView.Plate.Parts.Add(part);
|
||||
|
||||
activeForm.PlateView.ZoomToFit();
|
||||
}
|
||||
else
|
||||
{
|
||||
var plate = activeForm.Nest.CreatePlate();
|
||||
plate.Size = result.PlateSize;
|
||||
|
||||
foreach (var part in result.Parts)
|
||||
plate.Parts.Add(part);
|
||||
|
||||
activeForm.LoadLastPlate();
|
||||
}
|
||||
|
||||
activeForm.Nest.UpdateDrawingQuantities();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and manually test**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeds. Launch the app, create a nest, import some DXFs, then Tools > Pattern Tile opens the form.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
|
||||
git commit -m "feat(ui): wire Pattern Tile menu item and apply logic in MainForm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manual testing and polish
|
||||
|
||||
Final integration testing and any adjustments.
|
||||
|
||||
**Files:**
|
||||
- Possibly modify: `OpenNest/Forms/PatternTileForm.cs`, `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||
|
||||
- [ ] **Step 1: End-to-end test workflow**
|
||||
|
||||
1. Launch the app, create a new nest
|
||||
2. Import 2+ DXF drawings
|
||||
3. Open Tools > Pattern Tile
|
||||
4. Select Drawing A and Drawing B
|
||||
5. Verify parts appear in left panel, can be dragged
|
||||
6. Verify compaction on mouse release closes gaps
|
||||
7. Verify tile preview updates on the right
|
||||
8. Change plate size — verify preview updates
|
||||
9. Change spacing — verify preview updates
|
||||
10. Click Auto-Arrange — verify it picks a tight arrangement
|
||||
11. Click Apply > Yes (current plate) — verify parts placed
|
||||
12. Reopen, Apply > No (new plate) — verify new plate created with parts
|
||||
13. Test single drawing only (one dropdown set, other on "(none)")
|
||||
14. Test same drawing in both dropdowns
|
||||
|
||||
- [ ] **Step 2: Fix any issues found during testing**
|
||||
|
||||
Address any layout, interaction, or rendering issues discovered.
|
||||
|
||||
- [ ] **Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(ui): polish PatternTileForm after manual testing"
|
||||
```
|
||||
@@ -1,917 +0,0 @@
|
||||
# Pluggable Fill Strategies Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract the four hard-wired fill phases from `DefaultNestEngine.FindBestFill` into pluggable `IFillStrategy` implementations behind a pipeline orchestrator.
|
||||
|
||||
**Architecture:** Each fill phase (Pairs, RectBestFit, Extents, Linear) becomes a stateless `IFillStrategy` adapter around its existing filler class. A `FillContext` carries inputs and pipeline state. `FillStrategyRegistry` discovers strategies via reflection. `DefaultNestEngine.FindBestFill` is replaced by `RunPipeline` which iterates strategies in order.
|
||||
|
||||
**Tech Stack:** .NET 8, C#, xUnit (OpenNest.Tests)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-pluggable-fill-strategies-design.md`
|
||||
|
||||
**Deliberate behavioral change:** The phase execution order changes from Pairs/Linear/RectBestFit/Extents to Pairs/RectBestFit/Extents/Linear. Linear is moved last because it is the most expensive phase and rarely wins. The final result is equivalent (the pipeline always picks the globally best result), but intermediate progress reports during the fill will differ.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `NestPhase.Custom` enum value
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/NestProgress.cs:6-13`
|
||||
|
||||
- [ ] **Step 1: Add Custom to NestPhase enum**
|
||||
|
||||
In `OpenNest.Engine/NestProgress.cs`, add `Custom` after `Extents`:
|
||||
|
||||
```csharp
|
||||
public enum NestPhase
|
||||
{
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Extents,
|
||||
Custom
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Custom to FormatPhaseName in NestEngineBase**
|
||||
|
||||
In `OpenNest.Engine/NestEngineBase.cs`, add a case in `FormatPhaseName`:
|
||||
|
||||
```csharp
|
||||
case NestPhase.Custom: return "Custom";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 4: Run existing tests to verify no regression**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add NestPhase.Custom for plugin fill strategies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `IFillStrategy` and `FillContext`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/IFillStrategy.cs`
|
||||
- Create: `OpenNest.Engine/Strategies/FillContext.cs`
|
||||
|
||||
- [ ] **Step 1: Create the Strategies directory**
|
||||
|
||||
Verify `OpenNest.Engine/Strategies/` exists (create if needed).
|
||||
|
||||
- [ ] **Step 2: Write IFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/IFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IFillStrategy
|
||||
{
|
||||
string Name { get; }
|
||||
NestPhase Phase { get; }
|
||||
int Order { get; }
|
||||
List<Part> Fill(FillContext context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write FillContext.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillContext.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class FillContext
|
||||
{
|
||||
public NestItem Item { get; init; }
|
||||
public Box WorkArea { get; init; }
|
||||
public Plate Plate { get; init; }
|
||||
public int PlateNumber { get; init; }
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
|
||||
public List<Part> CurrentBest { get; set; }
|
||||
public FillScore CurrentBestScore { get; set; }
|
||||
public NestPhase WinnerPhase { get; set; }
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public Dictionary<string, object> SharedState { get; } = new();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add IFillStrategy interface and FillContext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `FillStrategyRegistry`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`
|
||||
|
||||
- [ ] **Step 1: Write FillStrategyRegistry.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class FillStrategyRegistry
|
||||
{
|
||||
private static readonly List<IFillStrategy> strategies = new();
|
||||
private static List<IFillStrategy> sorted;
|
||||
|
||||
static FillStrategyRegistry()
|
||||
{
|
||||
LoadFrom(typeof(FillStrategyRegistry).Assembly);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= strategies.OrderBy(s => s.Order).ToList();
|
||||
|
||||
public static void LoadFrom(Assembly assembly)
|
||||
{
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || type.IsInterface || !typeof(IFillStrategy).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(Type.EmptyTypes);
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Skipping {type.Name}: no parameterless constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var instance = (IFillStrategy)ctor.Invoke(null);
|
||||
|
||||
if (strategies.Any(s => s.Name.Equals(instance.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Duplicate strategy '{instance.Name}' skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
strategies.Add(instance);
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Registered: {instance.Name} (Order={instance.Order})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
LoadFrom(assembly);
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Loaded plugin assembly: {Path.GetFileName(dll)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FillStrategyRegistry] Failed to load {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify no errors**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded (no strategies registered yet — static constructor finds nothing)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add FillStrategyRegistry with reflection-based discovery
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `FillHelpers` from `DefaultNestEngine`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/FillHelpers.cs`
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs` (remove `BuildRotatedPattern` and `FillPattern`)
|
||||
- Modify: `OpenNest.Engine/PairFiller.cs` (update references)
|
||||
|
||||
- [ ] **Step 1: Create FillHelpers.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/FillHelpers.cs` with the two static methods moved from `DefaultNestEngine`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class FillHelpers
|
||||
{
|
||||
public static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||
{
|
||||
var pattern = new Pattern();
|
||||
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
||||
|
||||
foreach (var part in groupParts)
|
||||
{
|
||||
var clone = (Part)part.Clone();
|
||||
clone.UpdateBounds();
|
||||
|
||||
if (!angle.IsEqualTo(0))
|
||||
clone.Rotate(angle, center);
|
||||
|
||||
pattern.Parts.Add(clone);
|
||||
}
|
||||
|
||||
pattern.UpdateBounds();
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||
|
||||
Parallel.ForEach(angles, angle =>
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > 0)
|
||||
results.Add((h, FillScore.Compute(h, workArea)));
|
||||
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
if (v != null && v.Count > 0)
|
||||
results.Add((v, FillScore.Compute(v, workArea)));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var res in results)
|
||||
{
|
||||
if (best == null || res.Score > bestScore)
|
||||
{
|
||||
best = res.Parts;
|
||||
bestScore = res.Score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update DefaultNestEngine to delegate to FillHelpers**
|
||||
|
||||
In `OpenNest.Engine/DefaultNestEngine.cs`:
|
||||
- Change `BuildRotatedPattern` and `FillPattern` to forward to `FillHelpers`:
|
||||
|
||||
```csharp
|
||||
internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||
=> FillHelpers.BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
=> FillHelpers.FillPattern(engine, groupParts, angles, workArea);
|
||||
```
|
||||
|
||||
This preserves the existing `internal static` API so `PairFiller` and `Fill(List<Part> groupParts, ...)` don't break.
|
||||
|
||||
- [ ] **Step 3: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||
Expected: Build succeeded, all tests pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
refactor(engine): extract FillHelpers from DefaultNestEngine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create `PairsFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/PairsFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write PairsFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/PairsFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.BestFit;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PairsFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Pairs";
|
||||
public NestPhase Phase => NestPhase.Pairs;
|
||||
public int Order => 100;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
|
||||
// Cache hit — PairFiller already called GetOrCompute internally.
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
context.Item.Drawing, context.Plate.Size.Length,
|
||||
context.Plate.Size.Width, context.Plate.PartSpacing);
|
||||
context.SharedState["BestFits"] = bestFits;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded (strategy auto-registered by `FillStrategyRegistry`)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add PairsFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create `RectBestFitStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write RectBestFitStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.RectanglePacking;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class RectBestFitStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "RectBestFit";
|
||||
public NestPhase Phase => NestPhase.RectBestFit;
|
||||
public int Order => 200;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
|
||||
|
||||
var engine = new FillBestFit(bin);
|
||||
engine.Fill(binItem);
|
||||
|
||||
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add RectBestFitStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Create `ExtentsFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write ExtentsFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class ExtentsFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Extents";
|
||||
public NestPhase Phase => NestPhase.Extents;
|
||||
public int Order => 300;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
||||
|
||||
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||
? (double)rot
|
||||
: RotationAnalysis.FindBestRotation(context.Item);
|
||||
|
||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
var bestFits = context.SharedState.TryGetValue("BestFits", out var cached)
|
||||
? (List<BestFitResult>)cached
|
||||
: null;
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
var result = filler.Fill(context.Item.Drawing, angle,
|
||||
context.PlateNumber, context.Token, context.Progress, bestFits);
|
||||
if (result != null && result.Count > 0)
|
||||
{
|
||||
var score = FillScore.Compute(result, context.WorkArea);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
best = result;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add ExtentsFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Create `LinearFillStrategy`
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Engine/Strategies/LinearFillStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Write LinearFillStrategy.cs**
|
||||
|
||||
Create `OpenNest.Engine/Strategies/LinearFillStrategy.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class LinearFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Linear";
|
||||
public NestPhase Phase => NestPhase.Linear;
|
||||
public int Order => 400;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var angles = context.SharedState.TryGetValue("AngleCandidates", out var cached)
|
||||
? (List<double>)cached
|
||||
: new List<double> { 0, Angle.HalfPI };
|
||||
|
||||
var workArea = context.WorkArea;
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
for (var ai = 0; ai < angles.Count; ai++)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
|
||||
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 angleDeg = Angle.ToDegrees(angle);
|
||||
|
||||
if (h != null && h.Count > 0)
|
||||
{
|
||||
var scoreH = FillScore.Compute(h, workArea);
|
||||
context.AngleResults.Add(new AngleResult
|
||||
{
|
||||
AngleDeg = angleDeg,
|
||||
Direction = NestDirection.Horizontal,
|
||||
PartCount = h.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;
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||
context.PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(engine): add LinearFillStrategy adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire `RunPipeline` into `DefaultNestEngine`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||
|
||||
This is the key refactoring step. Replace `FindBestFill` with `RunPipeline` and delete dead code.
|
||||
|
||||
- [ ] **Step 1: Replace FindBestFill with RunPipeline**
|
||||
|
||||
Delete the entire `FindBestFill` method (the large `private List<Part> FindBestFill(...)` method). Replace with:
|
||||
|
||||
```csharp
|
||||
private void RunPipeline(FillContext context)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
|
||||
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||
context.SharedState["AngleCandidates"] = angles;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = strategy.Fill(context);
|
||||
sw.Stop();
|
||||
|
||||
var phaseResult = new PhaseResult(
|
||||
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds);
|
||||
context.PhaseResults.Add(phaseResult);
|
||||
|
||||
// Keep engine's PhaseResults in sync so BuildProgressSummary() works
|
||||
// during progress reporting.
|
||||
PhaseResults.Add(phaseResult);
|
||||
|
||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||
result, context.WorkArea, BuildProgressSummary());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||
}
|
||||
|
||||
angleBuilder.RecordProductive(context.AngleResults);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update Fill(NestItem, ...) to use RunPipeline**
|
||||
|
||||
Replace the body of the `Fill(NestItem item, Box workArea, ...)` override:
|
||||
|
||||
```csharp
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
PhaseResults.Clear();
|
||||
AngleResults.Clear();
|
||||
|
||||
var context = new FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = Plate,
|
||||
PlateNumber = PlateNumber,
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
};
|
||||
|
||||
RunPipeline(context);
|
||||
|
||||
// PhaseResults already synced during RunPipeline.
|
||||
AngleResults.AddRange(context.AngleResults);
|
||||
WinnerPhase = context.WinnerPhase;
|
||||
|
||||
var best = context.CurrentBest ?? new List<Part>();
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = best.Take(item.Quantity).ToList();
|
||||
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
||||
|
||||
return best;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete FillRectangleBestFit**
|
||||
|
||||
Remove the private `FillRectangleBestFit` method entirely. It is now inside `RectBestFitStrategy`.
|
||||
|
||||
Note: `Fill(List<Part> groupParts, ...)` also calls `FillRectangleBestFit` at line 125. Inline the logic there:
|
||||
|
||||
```csharp
|
||||
var binItem = BinConverter.ToItem(nestItem, Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
||||
var rectEngine = new FillBestFit(bin);
|
||||
rectEngine.Fill(binItem);
|
||||
var rectResult = BinConverter.ToParts(bin, new List<NestItem> { nestItem });
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete QuickFillCount**
|
||||
|
||||
Remove the `QuickFillCount` method entirely (dead code, zero callers).
|
||||
|
||||
- [ ] **Step 5: Build and run tests**
|
||||
|
||||
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||
Expected: Build succeeded, **all existing tests pass** — this is the critical regression gate.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
refactor(engine): replace FindBestFill with strategy pipeline
|
||||
|
||||
DefaultNestEngine.Fill(NestItem, ...) now delegates to RunPipeline
|
||||
which iterates FillStrategyRegistry.Strategies in order.
|
||||
|
||||
Removed: FindBestFill, FillRectangleBestFit, QuickFillCount.
|
||||
Kept: AngleCandidateBuilder, ForceFullAngleSweep, group-fill overload.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Add pipeline-specific tests
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`
|
||||
- Create: `OpenNest.Tests/Strategies/FillPipelineTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write FillStrategyRegistryTests.cs**
|
||||
|
||||
Create `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`:
|
||||
|
||||
```csharp
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
|
||||
public class FillStrategyRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Registry_DiscoversBuiltInStrategies()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
|
||||
Assert.True(strategies.Count >= 4, $"Expected at least 4 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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_StrategiesAreOrderedByOrder()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
|
||||
for (var i = 1; i < strategies.Count; i++)
|
||||
Assert.True(strategies[i].Order >= strategies[i - 1].Order,
|
||||
$"Strategy '{strategies[i].Name}' (Order={strategies[i].Order}) should not precede '{strategies[i - 1].Name}' (Order={strategies[i - 1].Order})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_LinearIsLast()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
var last = strategies[strategies.Count - 1];
|
||||
|
||||
Assert.Equal("Linear", last.Name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write FillPipelineTests.cs**
|
||||
|
||||
Create `OpenNest.Tests/Strategies/FillPipelineTests.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
|
||||
public class FillPipelineTests
|
||||
{
|
||||
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 Pipeline_PopulatesPhaseResults()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
|
||||
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(engine.PhaseResults.Count >= 4,
|
||||
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_SetsWinnerPhase()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(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);
|
||||
// WinnerPhase should be set to one of the built-in phases
|
||||
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||
engine.WinnerPhase == NestPhase.Linear ||
|
||||
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||
engine.WinnerPhase == NestPhase.Extents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pipeline_RespectsCancellation()
|
||||
{
|
||||
var plate = new Plate(120, 60);
|
||||
var engine = new DefaultNestEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Pre-cancelled token should return empty or partial results without throwing
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, cts.Token);
|
||||
|
||||
// Should not throw — graceful degradation
|
||||
Assert.NotNull(parts);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all tests**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests`
|
||||
Expected: All tests pass (old and new)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
test(engine): add FillStrategyRegistry and pipeline tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests -v normal`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Build entire solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, no warnings from Engine project
|
||||
|
||||
- [ ] **Step 3: Verify EngineRefactorSmokeTests still pass**
|
||||
|
||||
Run: `dotnet test OpenNest.Tests --filter EngineRefactorSmokeTests`
|
||||
Expected: All 5 smoke tests pass (DefaultEngine_FillNestItem, DefaultEngine_FillGroupParts, DefaultEngine_ForceFullAngleSweep, StripEngine_Nest, BruteForceRunner_StillWorks)
|
||||
|
||||
- [ ] **Step 4: Verify file layout matches spec**
|
||||
|
||||
Confirm these files exist under `OpenNest.Engine/Strategies/`:
|
||||
- `IFillStrategy.cs`
|
||||
- `FillContext.cs`
|
||||
- `FillStrategyRegistry.cs`
|
||||
- `FillHelpers.cs`
|
||||
- `PairsFillStrategy.cs`
|
||||
- `RectBestFitStrategy.cs`
|
||||
- `ExtentsFillStrategy.cs`
|
||||
- `LinearFillStrategy.cs`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user