Files
OpenNest/OpenNest.Engine/BestFit/PairEvaluator.cs
T
ajandClaude Sonnet 5 0512f3f84a fix(engine): check BestFit overlap in the same frame BuildParts places
PairEvaluator checked overlap on the raw candidate geometry before
applying the pair's OptimalRotation, but BestFitResult.BuildParts (and
everything downstream) rotates both parts by -OptimalRotation before
placing them. Re-tessellating a rotated arc at the overlap chord
tolerance samples different chord points than rotating an
already-tessellated polygon, so a few tangent-corner candidates came
out overlap-free in the raw frame but overlapping once actually
placed.

Move the landscape-normalization step before the overlap check and
rotate part1/part2 the same way BuildParts does before tessellating
and running Collision.HasOverlap, so Keep agrees with the geometry
that's actually placed. Fixes
OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap (was
failing 3/1082 candidates).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:57:40 -04:00

192 lines
7.3 KiB
C#

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.BestFit
{
public class PairEvaluator : IPairEvaluator
{
private const double ChordTolerance = 0.01;
/// <summary>
/// Tighter chord tolerance for the overlap check only. Rounded-corner arcs
/// polygonized at the coarser <see cref="ChordTolerance"/> can "cut the corner"
/// enough to hide a genuine but tiny sliver overlap between two candidates —
/// this needs to match the precision Part.Intersects uses elsewhere so BestFit's
/// Keep decision agrees with the same overlap check callers rely on downstream.
/// </summary>
private const double OverlapChordTolerance = 0.001;
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
if (candidates.Count == 0)
return new List<BestFitResult>();
// Build a perimeter-only drawing once — all candidates share the same drawing.
// This avoids cloning the full program (with all cutouts) for every candidate.
var perimeterDrawing = CreatePerimeterDrawing(candidates[0].Drawing);
var resultBag = new ConcurrentBag<BestFitResult>();
Parallel.ForEach(
candidates,
c =>
{
resultBag.Add(Evaluate(c, perimeterDrawing));
}
);
return resultBag.ToList();
}
public BestFitResult Evaluate(PairCandidate candidate)
{
var perimeterDrawing = CreatePerimeterDrawing(candidate.Drawing);
return Evaluate(candidate, perimeterDrawing);
}
private BestFitResult Evaluate(PairCandidate candidate, Drawing perimeterDrawing)
{
var part1 = Part.CreateAtOrigin(perimeterDrawing);
var part2 = Part.CreateAtOrigin(perimeterDrawing, candidate.Part2Rotation);
part2.Location = candidate.Part2Offset;
part2.UpdateBounds();
// Convex hull vertices from perimeter polygons only
var allPoints = GetPartVertices(part1);
allPoints.AddRange(GetPartVertices(part2));
// Find optimal bounding rectangle via rotating calipers
double bestArea,
bestWidth,
bestHeight,
bestRotation;
List<double> hullAngles = null;
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;
hullAngles = RotationAnalysis.GetHullEdgeAngles(hull);
}
else
{
var combinedBox = (
(IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }
).GetBoundingBox();
bestArea = combinedBox.Area();
bestWidth = combinedBox.Width;
bestHeight = combinedBox.Length;
bestRotation = 0;
hullAngles = new List<double> { 0 };
}
var trueArea = candidate.Drawing.Area * 2;
// Normalize to landscape (width >= height) for consistent display. Do this before
// the overlap check so bestRotation already matches the final OptimalRotation that
// BuildParts will apply.
if (bestHeight > bestWidth)
{
var tmp = bestWidth;
bestWidth = bestHeight;
bestHeight = tmp;
bestRotation += Angle.HalfPI;
}
// Overlap check — perimeter vs perimeter, in the same final orientation BuildParts
// uses downstream. Uses Collision.HasOverlap (full polygon clip) rather than
// Shape.Intersects (edge-crossing only), which misses containment-style overlaps
// where one perimeter's boundary never crosses the other's. Checking pre-rotation
// geometry here (rather than rotating part1/part2 first, matching BuildParts) would
// tessellate arcs at a different orientation than the geometry actually gets placed
// with, letting tangent-corner slivers slip through in one frame but not the other.
if (!bestRotation.IsEqualTo(0))
{
var pairBounds = (
(IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }
).GetBoundingBox();
var center = pairBounds.Center;
part1.Rotate(-bestRotation, center);
part2.Rotate(-bestRotation, center);
}
var shape1 = GetPerimeterShape(part1);
var shape2 = GetPerimeterShape(part2);
var overlaps =
shape1 != null
&& shape2 != null
&& Collision.HasOverlap(
shape1.ToPolygonWithTolerance(OverlapChordTolerance),
shape2.ToPolygonWithTolerance(OverlapChordTolerance)
);
return new BestFitResult
{
Candidate = candidate,
RotatedArea = bestArea,
BoundingWidth = bestWidth,
BoundingHeight = bestHeight,
OptimalRotation = bestRotation,
TrueArea = trueArea,
HullAngles = hullAngles,
Keep = !overlaps,
Reason = overlaps ? "Overlap detected" : "Valid",
};
}
private static Drawing CreatePerimeterDrawing(Drawing source)
{
var entities = ConvertProgram
.ToGeometry(source.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var profile = new ShapeProfile(entities);
var program = ConvertGeometry.ToProgram(profile.Perimeter);
return new Drawing(source.Name, program);
}
private static Shape GetPerimeterShape(Part part)
{
var entities = ConvertProgram
.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return null;
shapes[0].Offset(part.Location);
return shapes[0];
}
private static List<Vector> GetPartVertices(Part part)
{
var entities = ConvertProgram
.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
polygon.Offset(part.Location);
points.AddRange(polygon.Vertices);
}
return points;
}
}
}