Move fill algorithms to OpenNest.Engine.Fill namespace: FillLinear, FillExtents, PairFiller, ShrinkFiller, Compactor, RemnantFiller, RemnantFinder, FillScore, Pattern, PatternTiler, PartBoundary, RotationAnalysis, AngleCandidateBuilder, and AccumulatingProgress. Move strategy layer to OpenNest.Engine.Strategies namespace: IFillStrategy, FillContext, FillStrategyRegistry, FillHelpers, and all built-in strategy implementations. Add using directives to all consuming files across Engine, UI, MCP, and Tests projects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
397 lines
14 KiB
C#
397 lines
14 KiB
C#
using OpenNest.Geometry;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace OpenNest.Engine.Fill
|
|
{
|
|
/// <summary>
|
|
/// A remnant box with a priority tier.
|
|
/// 0 = within the used envelope (best), 1 = extends past one edge, 2 = fully outside.
|
|
/// </summary>
|
|
public struct TieredRemnant
|
|
{
|
|
public Box Box;
|
|
public int Priority;
|
|
|
|
public TieredRemnant(Box box, int priority)
|
|
{
|
|
Box = box;
|
|
Priority = priority;
|
|
}
|
|
}
|
|
|
|
public class RemnantFinder
|
|
{
|
|
private readonly Box workArea;
|
|
|
|
public List<Box> Obstacles { get; } = new();
|
|
|
|
private struct CellGrid
|
|
{
|
|
public bool[,] Empty;
|
|
public List<double> XCoords;
|
|
public List<double> YCoords;
|
|
public int Rows;
|
|
public int Cols;
|
|
}
|
|
|
|
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)
|
|
{
|
|
var grid = BuildGrid();
|
|
|
|
if (grid.Rows <= 0 || grid.Cols <= 0)
|
|
return new List<Box>();
|
|
|
|
var merged = MergeCells(grid);
|
|
var sized = FilterBySize(merged, minDimension);
|
|
var unique = RemoveDominated(sized);
|
|
SortByEdgeProximity(unique);
|
|
return unique;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds remnants and splits them into priority tiers based on the
|
|
/// bounding box of all placed parts (the "used envelope").
|
|
/// Priority 0: fully within the used envelope — compact, preferred.
|
|
/// Priority 1: extends past one edge of the envelope.
|
|
/// Priority 2: fully outside the envelope — last resort.
|
|
/// </summary>
|
|
public List<TieredRemnant> FindTieredRemnants(double minDimension = 0)
|
|
{
|
|
var remnants = FindRemnants(minDimension);
|
|
|
|
if (Obstacles.Count == 0 || remnants.Count == 0)
|
|
return remnants.Select(r => new TieredRemnant(r, 0)).ToList();
|
|
|
|
var envelope = ComputeEnvelope();
|
|
var results = new List<TieredRemnant>();
|
|
|
|
foreach (var remnant in remnants)
|
|
{
|
|
var before = results.Count;
|
|
SplitAtEnvelope(remnant, envelope, minDimension, results);
|
|
|
|
// If all splits fell below minDim, keep the original unsplit.
|
|
if (results.Count == before)
|
|
results.Add(new TieredRemnant(remnant, 1));
|
|
}
|
|
|
|
results.Sort((a, b) =>
|
|
{
|
|
if (a.Priority != b.Priority)
|
|
return a.Priority.CompareTo(b.Priority);
|
|
return b.Box.Area().CompareTo(a.Box.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 CellGrid BuildGrid()
|
|
{
|
|
var clipped = ClipObstacles();
|
|
|
|
var xs = new SortedSet<double> { workArea.Left, workArea.Right };
|
|
var ys = new SortedSet<double> { workArea.Bottom, workArea.Top };
|
|
|
|
foreach (var obs in clipped)
|
|
{
|
|
xs.Add(obs.Left);
|
|
xs.Add(obs.Right);
|
|
ys.Add(obs.Bottom);
|
|
ys.Add(obs.Top);
|
|
}
|
|
|
|
var grid = new CellGrid
|
|
{
|
|
XCoords = xs.ToList(),
|
|
YCoords = ys.ToList(),
|
|
};
|
|
|
|
grid.Cols = grid.XCoords.Count - 1;
|
|
grid.Rows = grid.YCoords.Count - 1;
|
|
|
|
if (grid.Cols <= 0 || grid.Rows <= 0)
|
|
{
|
|
grid.Empty = new bool[0, 0];
|
|
return grid;
|
|
}
|
|
|
|
grid.Empty = new bool[grid.Rows, grid.Cols];
|
|
|
|
for (var r = 0; r < grid.Rows; r++)
|
|
{
|
|
for (var c = 0; c < grid.Cols; c++)
|
|
{
|
|
var cell = new Box(grid.XCoords[c], grid.YCoords[r],
|
|
grid.XCoords[c + 1] - grid.XCoords[c],
|
|
grid.YCoords[r + 1] - grid.YCoords[r]);
|
|
|
|
grid.Empty[r, c] = !OverlapsAny(cell, clipped);
|
|
}
|
|
}
|
|
|
|
return grid;
|
|
}
|
|
|
|
private List<Box> ClipObstacles()
|
|
{
|
|
var clipped = new List<Box>(Obstacles.Count);
|
|
|
|
foreach (var obs in Obstacles)
|
|
{
|
|
var c = ClipToWorkArea(obs);
|
|
if (c.Width > 0 && c.Length > 0)
|
|
clipped.Add(c);
|
|
}
|
|
|
|
return clipped;
|
|
}
|
|
|
|
private static bool OverlapsAny(Box cell, List<Box> obstacles)
|
|
{
|
|
foreach (var obs in obstacles)
|
|
{
|
|
if (cell.Left < obs.Right && cell.Right > obs.Left &&
|
|
cell.Bottom < obs.Top && cell.Top > obs.Bottom)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static List<Box> FilterBySize(List<Box> boxes, double minDimension)
|
|
{
|
|
if (minDimension <= 0)
|
|
return boxes;
|
|
|
|
var result = new List<Box>();
|
|
|
|
foreach (var box in boxes)
|
|
{
|
|
if (box.Width >= minDimension && box.Length >= minDimension)
|
|
result.Add(box);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<Box> RemoveDominated(List<Box> boxes)
|
|
{
|
|
boxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
|
|
var results = new List<Box>();
|
|
|
|
foreach (var box in boxes)
|
|
{
|
|
var dominated = false;
|
|
|
|
foreach (var larger in results)
|
|
{
|
|
if (IsContainedIn(box, larger))
|
|
{
|
|
dominated = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!dominated)
|
|
results.Add(box);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static bool IsContainedIn(Box inner, Box outer)
|
|
{
|
|
var eps = Math.Tolerance.Epsilon;
|
|
return inner.Left >= outer.Left - eps &&
|
|
inner.Right <= outer.Right + eps &&
|
|
inner.Bottom >= outer.Bottom - eps &&
|
|
inner.Top <= outer.Top + eps;
|
|
}
|
|
|
|
private void SortByEdgeProximity(List<Box> boxes)
|
|
{
|
|
boxes.Sort((a, b) =>
|
|
{
|
|
var aEdge = TouchesEdge(a) ? 1 : 0;
|
|
var bEdge = TouchesEdge(b) ? 1 : 0;
|
|
|
|
if (aEdge != bEdge)
|
|
return bEdge.CompareTo(aEdge);
|
|
|
|
return b.Area().CompareTo(a.Area());
|
|
});
|
|
}
|
|
|
|
private bool TouchesEdge(Box box)
|
|
{
|
|
return box.Left <= workArea.Left + Math.Tolerance.Epsilon
|
|
|| box.Right >= workArea.Right - Math.Tolerance.Epsilon
|
|
|| box.Bottom <= workArea.Bottom + Math.Tolerance.Epsilon
|
|
|| box.Top >= workArea.Top - Math.Tolerance.Epsilon;
|
|
}
|
|
|
|
private Box ComputeEnvelope()
|
|
{
|
|
var envLeft = double.MaxValue;
|
|
var envBottom = double.MaxValue;
|
|
var envRight = double.MinValue;
|
|
var envTop = double.MinValue;
|
|
|
|
foreach (var obs in Obstacles)
|
|
{
|
|
if (obs.Left < envLeft) envLeft = obs.Left;
|
|
if (obs.Bottom < envBottom) envBottom = obs.Bottom;
|
|
if (obs.Right > envRight) envRight = obs.Right;
|
|
if (obs.Top > envTop) envTop = obs.Top;
|
|
}
|
|
|
|
return new Box(envLeft, envBottom, envRight - envLeft, envTop - envBottom);
|
|
}
|
|
|
|
private static void SplitAtEnvelope(Box remnant, Box envelope, double minDim, List<TieredRemnant> results)
|
|
{
|
|
var eps = Math.Tolerance.Epsilon;
|
|
|
|
// Fully within the envelope.
|
|
if (remnant.Left >= envelope.Left - eps && remnant.Right <= envelope.Right + eps &&
|
|
remnant.Bottom >= envelope.Bottom - eps && remnant.Top <= envelope.Top + eps)
|
|
{
|
|
results.Add(new TieredRemnant(remnant, 0));
|
|
return;
|
|
}
|
|
|
|
// Fully outside the envelope (no overlap).
|
|
if (remnant.Left >= envelope.Right - eps || remnant.Right <= envelope.Left + eps ||
|
|
remnant.Bottom >= envelope.Top - eps || remnant.Top <= envelope.Bottom + eps)
|
|
{
|
|
results.Add(new TieredRemnant(remnant, 2));
|
|
return;
|
|
}
|
|
|
|
// Partially overlapping — split at envelope edges.
|
|
var innerLeft = System.Math.Max(remnant.Left, envelope.Left);
|
|
var innerBottom = System.Math.Max(remnant.Bottom, envelope.Bottom);
|
|
var innerRight = System.Math.Min(remnant.Right, envelope.Right);
|
|
var innerTop = System.Math.Min(remnant.Top, envelope.Top);
|
|
|
|
// Inner portion (priority 0).
|
|
TryAdd(results, innerLeft, innerBottom, innerRight - innerLeft, innerTop - innerBottom, 0, minDim);
|
|
|
|
// Edge extensions (priority 1).
|
|
if (remnant.Right > envelope.Right + eps)
|
|
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Length, 1, minDim);
|
|
|
|
if (remnant.Left < envelope.Left - eps)
|
|
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Length, 1, minDim);
|
|
|
|
if (remnant.Top > envelope.Top + eps)
|
|
TryAdd(results, innerLeft, envelope.Top, innerRight - innerLeft, remnant.Top - envelope.Top, 1, minDim);
|
|
|
|
if (remnant.Bottom < envelope.Bottom - eps)
|
|
TryAdd(results, innerLeft, remnant.Bottom, innerRight - innerLeft, envelope.Bottom - remnant.Bottom, 1, minDim);
|
|
|
|
// Corner extensions (priority 2).
|
|
if (remnant.Right > envelope.Right + eps && remnant.Top > envelope.Top + eps)
|
|
TryAdd(results, envelope.Right, envelope.Top, remnant.Right - envelope.Right, remnant.Top - envelope.Top, 2, minDim);
|
|
|
|
if (remnant.Right > envelope.Right + eps && remnant.Bottom < envelope.Bottom - eps)
|
|
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, envelope.Bottom - remnant.Bottom, 2, minDim);
|
|
|
|
if (remnant.Left < envelope.Left - eps && remnant.Top > envelope.Top + eps)
|
|
TryAdd(results, remnant.Left, envelope.Top, envelope.Left - remnant.Left, remnant.Top - envelope.Top, 2, minDim);
|
|
|
|
if (remnant.Left < envelope.Left - eps && remnant.Bottom < envelope.Bottom - eps)
|
|
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, envelope.Bottom - remnant.Bottom, 2, minDim);
|
|
}
|
|
|
|
private static void TryAdd(List<TieredRemnant> results, double x, double y, double w, double h, int priority, double minDim)
|
|
{
|
|
if (w >= minDim && h >= minDim)
|
|
results.Add(new TieredRemnant(new Box(x, y, w, h), priority));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds maximal empty rectangles using the histogram method.
|
|
/// For each row, builds a height histogram of consecutive empty cells
|
|
/// above, then extracts the largest rectangles from the histogram.
|
|
/// </summary>
|
|
private static List<Box> MergeCells(CellGrid grid)
|
|
{
|
|
var height = new int[grid.Rows, grid.Cols];
|
|
|
|
for (var c = 0; c < grid.Cols; c++)
|
|
{
|
|
for (var r = 0; r < grid.Rows; r++)
|
|
height[r, c] = grid.Empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0;
|
|
}
|
|
|
|
var candidates = new List<Box>();
|
|
|
|
for (var r = 0; r < grid.Rows; r++)
|
|
{
|
|
var stack = new Stack<(int startCol, int h)>();
|
|
|
|
for (var c = 0; c <= grid.Cols; c++)
|
|
{
|
|
var h = c < grid.Cols ? height[r, c] : 0;
|
|
var startCol = c;
|
|
|
|
while (stack.Count > 0 && stack.Peek().h > h)
|
|
{
|
|
var top = stack.Pop();
|
|
startCol = top.startCol;
|
|
|
|
candidates.Add(new Box(
|
|
grid.XCoords[top.startCol], grid.YCoords[r - top.h + 1],
|
|
grid.XCoords[c] - grid.XCoords[top.startCol],
|
|
grid.YCoords[r + 1] - grid.YCoords[r - top.h + 1]));
|
|
}
|
|
|
|
if (h > 0)
|
|
stack.Push((startCol, h));
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
}
|
|
}
|