Files
OpenNest/OpenNest.Engine/Strategies/FillHelpers.cs
T
aj 4842b80ceb fix(fill): pick FillPattern winners in angle order, not thread order
FillPattern gathered per-angle results in a ConcurrentBag and kept the first best it
enumerated, so an exact tie between angles went to whichever worker finished first:
identical inputs could return different, equally scored layouts. Results now sit in
slots indexed by angle, V before H within an angle (the order the single-angle bag
already produced and the existing tie tests pin), and ties keep the earliest slot.
Single-angle behaviour is unchanged.

Regression: a never-prefer comparer over one 0-degree and fifteen 180-degree angles
must keep the 0-degree layout in 50 of 50 calls; it failed on the old code.
2026-10-04 19:50:30 -04:00

205 lines
6.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Strategies
{
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();
// Keep the recompute: at angle 0, accumulated source translations can
// leave a different cached box than Program.BoundingBox() + Location.
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,
IFillComparer comparer = null
)
{
// Slots in angle order, V before H within an angle: ties keep the earliest slot
// however the workers finish.
var results = new (List<Part> Parts, FillScore Score)[angles.Count * 2];
Parallel.For(
0,
angles.Count,
i =>
{
var pattern = BuildRotatedPattern(groupParts, angles[i]);
if (pattern.Parts.Count == 0)
return;
var h = engine.Fill(pattern, NestDirection.Horizontal);
if (h != null && h.Count > 0)
results[2 * i + 1] = (h, comparer == null ? FillScore.Compute(h, workArea) : default);
var v = engine.Fill(pattern, NestDirection.Vertical);
if (v != null && v.Count > 0)
results[2 * i] = (v, comparer == null ? FillScore.Compute(v, workArea) : default);
}
);
List<Part> best = null;
var bestScore = default(FillScore);
foreach (var res in results)
{
if (res.Parts == null)
continue;
if (comparer != null)
{
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>();
}
/// <summary>
/// Sweeps a list of angles, calling fillAtAngle for each, and returns
/// the best result according to the context's comparer. Handles
/// cancellation and progress reporting via context.ReportProgress.
/// </summary>
public static List<Part> BestOverAngles(
FillContext context,
IReadOnlyList<double> angles,
Func<double, List<Part>> fillAtAngle,
string phaseLabel
)
{
var workArea = context.WorkArea;
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
List<Part> best = null;
for (var i = 0; i < angles.Count; i++)
{
context.Token.ThrowIfCancellationRequested();
var angle = angles[i];
var result = fillAtAngle(angle);
var angleDeg = Angle.ToDegrees(angle);
if (result != null && result.Count > 0)
{
if (best == null || comparer.IsBetter(result, best, workArea))
best = result;
}
context.ReportProgress(
best,
$"{phaseLabel}: {i + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts"
);
}
return best ?? new List<Part>();
}
/// <summary>
/// Checks if any pair of parts geometrically overlap. Uses bounding box
/// pre-filtering for performance, then falls back to shape intersection.
/// </summary>
internal static bool HasOverlappingParts(List<Part> parts)
{
var checker = new PartOverlapChecker();
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX =
System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left);
var overlapY =
System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (checker.Overlaps(parts[i], parts[j]))
return true;
}
}
return false;
}
}
}