fix(bestfit): stop at internal arc tangency
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Tests.Geometry;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests.BestFit;
|
||||
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
public class NativeUClearanceTests
|
||||
{
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public NativeUClearanceTests(ITestOutputHelper output) => this.output = output;
|
||||
|
||||
[Fact]
|
||||
public void BestFitCache_TopKeptPairPreservesQuarterInchClearance()
|
||||
{
|
||||
var drawing = NativeUFixture.CreateDrawing();
|
||||
var bestFits = BestFitCache.GetOrCompute(drawing, 24, 24, 0.25);
|
||||
var top = bestFits.Where(r => r.Keep).OrderBy(r => r.RotatedArea).First();
|
||||
var parts = top.BuildSourceParts(drawing);
|
||||
Assert.Equal(2, parts.Count);
|
||||
output.WriteLine($"Kept={bestFits.Count(r => r.Keep)}; top rotation={top.Candidate.Part2Rotation:R}; area={top.RotatedArea:R}");
|
||||
AssertClearance(parts, 0.25);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public void FillItem_NativeUHasRequiredClearance(int quantity)
|
||||
{
|
||||
var drawing = NativeUFixture.CreateDrawing();
|
||||
var plate = new Plate(new Size(24, 24))
|
||||
{
|
||||
PartSpacing = 0.25,
|
||||
EdgeSpacing = new Spacing(1, 1),
|
||||
};
|
||||
var parts = PlateFillService.FillItem("Default", plate,
|
||||
new NestItem { Drawing = drawing, Quantity = quantity }, plate.WorkArea(),
|
||||
null, CancellationToken.None);
|
||||
|
||||
Assert.NotEmpty(parts);
|
||||
if (quantity == 2)
|
||||
Assert.Equal(2, parts.Count);
|
||||
else
|
||||
Assert.True(parts.Count > 2);
|
||||
Assert.Empty(plate.Parts);
|
||||
Assert.All(parts, part =>
|
||||
{
|
||||
Assert.Same(drawing, part.BaseDrawing);
|
||||
var workArea = plate.WorkArea();
|
||||
Assert.InRange(part.BoundingBox.Left, workArea.Left - 1e-9, workArea.Right + 1e-9);
|
||||
Assert.InRange(part.BoundingBox.Right, workArea.Left - 1e-9, workArea.Right + 1e-9);
|
||||
Assert.InRange(part.BoundingBox.Bottom, workArea.Bottom - 1e-9, workArea.Top + 1e-9);
|
||||
Assert.InRange(part.BoundingBox.Top, workArea.Bottom - 1e-9, workArea.Top + 1e-9);
|
||||
});
|
||||
output.WriteLine($"Quantity={quantity}; returned={parts.Count}");
|
||||
var violations = NestLayoutCheck.Validate(new() { (plate, parts) },
|
||||
new Dictionary<Drawing, (string Name, int Quantity)> { [drawing] = (drawing.Name, parts.Count) });
|
||||
output.WriteLine($"Validator violations={violations.Count}");
|
||||
AssertClearance(parts, plate.PartSpacing);
|
||||
// The complete grid currently triggers offset-validator reports even though its
|
||||
// independently measured raw boundaries clear. Do not change validator tolerance
|
||||
// here: the fine-boundary oracle above checks EVERY nearby pair in either mode.
|
||||
if (quantity == 2)
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
private void AssertClearance(List<Part> parts, double spacing)
|
||||
{
|
||||
// Fine raw outlines, independently measured: neither offset curves nor the slide solver.
|
||||
var outlines = parts.Select(p => PartGeometry.GetPartLines(p, 1e-6)).ToList();
|
||||
var squared = double.MaxValue;
|
||||
var closest = (-1, -1);
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
if (BoxGapSquared(parts[i].BoundingBox, parts[j].BoundingBox) > spacing * spacing)
|
||||
continue;
|
||||
Assert.False(parts[i].Intersects(parts[j], out _));
|
||||
foreach (var a in outlines[i])
|
||||
foreach (var b in outlines[j])
|
||||
{
|
||||
if (BoxGapSquared(a.BoundingBox, b.BoundingBox) >= squared)
|
||||
continue;
|
||||
var distanceSquared = System.Math.Min(
|
||||
System.Math.Min(PointSegmentSquared(a.StartPoint, b), PointSegmentSquared(a.EndPoint, b)),
|
||||
System.Math.Min(PointSegmentSquared(b.StartPoint, a), PointSegmentSquared(b.EndPoint, a)));
|
||||
if (distanceSquared < squared)
|
||||
{
|
||||
squared = distanceSquared;
|
||||
closest = (i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
var distance = System.Math.Sqrt(squared);
|
||||
output.WriteLine($"Minimum raw boundary gap={distance:R}; pair={closest}");
|
||||
Assert.True(distance >= spacing - 2e-6, $"Raw boundary gap {distance:R} is below {spacing:R} (chord tolerance 1e-6), pair {closest}.");
|
||||
}
|
||||
|
||||
private static double BoxGapSquared(Box a, Box b)
|
||||
{
|
||||
var x = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
|
||||
var y = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
|
||||
return x * x + y * y;
|
||||
}
|
||||
|
||||
private static double PointSegmentSquared(Vector p, Line line)
|
||||
{
|
||||
var dx = line.EndPoint.X - line.StartPoint.X;
|
||||
var dy = line.EndPoint.Y - line.StartPoint.Y;
|
||||
var lengthSquared = dx * dx + dy * dy;
|
||||
var t = lengthSquared == 0 ? 0 : System.Math.Clamp(
|
||||
((p.X - line.StartPoint.X) * dx + (p.Y - line.StartPoint.Y) * dy) / lengthSquared, 0, 1);
|
||||
var x = p.X - line.StartPoint.X - t * dx;
|
||||
var y = p.Y - line.StartPoint.Y - t * dy;
|
||||
return x * x + y * y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class CurveContactDistanceTests
|
||||
{
|
||||
public static IEnumerable<object[]> InternalContactCases()
|
||||
{
|
||||
foreach (var cpu in new[] { false, true })
|
||||
foreach (var swap in new[] { false, true })
|
||||
foreach (var transform in new[] { 0, 1, 2, 3 })
|
||||
yield return new object[] { cpu, swap, transform };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InternalContactCases))]
|
||||
public void InternalContact_RejectsNearRootAndStopsAtFarRoot(bool cpu, bool swap, int transform)
|
||||
{
|
||||
var moving = new Arc(5.5, 2, 0.125, System.Math.PI, System.Math.PI / 2, true);
|
||||
var stationary = new Arc(1.5, 1.5, 0.75, System.Math.PI / 2, 3 * System.Math.PI / 2);
|
||||
var direction = new Vector(-1, 0);
|
||||
if (swap)
|
||||
{
|
||||
(moving, stationary) = (stationary, moving);
|
||||
direction = new Vector(1, 0);
|
||||
}
|
||||
|
||||
// Preserve the same contact under reflection, non-cardinal rotation and translation.
|
||||
if (transform == 1)
|
||||
{
|
||||
moving = ReflectX(moving);
|
||||
stationary = ReflectX(stationary);
|
||||
direction = new Vector(-direction.X, direction.Y);
|
||||
}
|
||||
var rotation = transform == 2 ? 0.37 : transform == 3 ? System.Math.PI / 2 : 0;
|
||||
moving.Rotate(rotation);
|
||||
stationary.Rotate(rotation);
|
||||
direction = direction.Rotate(rotation);
|
||||
if (transform != 0)
|
||||
{
|
||||
moving.Offset(17, -23);
|
||||
stationary.Offset(17, -23);
|
||||
}
|
||||
|
||||
// |.75 - .125| = .625. The roots are 3.625 and 4.375;
|
||||
// only the latter has its tangent point in BOTH arcs' spans.
|
||||
var distance = Distance(cpu, new() { moving }, new() { stationary }, direction);
|
||||
Assert.Equal(4.375, distance, 9);
|
||||
Assert.Equal(4.375, Tangency(moving, stationary, direction), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void Tangency_RejectsContactOutsideEitherArc(bool restrictMoving)
|
||||
{
|
||||
var moving = new Arc(5.5, 2, 0.125, Angle.ToRadians(90), Angle.ToRadians(180));
|
||||
var stationary = new Arc(1.5, 1.5, 0.75, Angle.ToRadians(90), Angle.ToRadians(270));
|
||||
if (restrictMoving)
|
||||
{
|
||||
moving.StartAngle = Angle.ToRadians(10);
|
||||
moving.EndAngle = Angle.ToRadians(20);
|
||||
}
|
||||
else
|
||||
{
|
||||
stationary.StartAngle = Angle.ToRadians(200);
|
||||
stationary.EndAngle = Angle.ToRadians(250);
|
||||
}
|
||||
Assert.Equal(double.MaxValue, Tangency(moving, stationary, new Vector(-1, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tangency_InternalNearRootIsAcceptedWhenBothSpansContainIt()
|
||||
{
|
||||
var moving = new Arc(5.5, 2, 0.125, Angle.ToRadians(30), Angle.ToRadians(70));
|
||||
var stationary = new Arc(1.5, 1.5, 0.75, Angle.ToRadians(30), Angle.ToRadians(70));
|
||||
Assert.Equal(3.625, Tangency(moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.5, 0.375)]
|
||||
[InlineData(1.125, 0)]
|
||||
public void Tangency_InternalStartsInsideOrTouching(double movingX, double expected)
|
||||
{
|
||||
var moving = new Arc(movingX, 2, 0.125, System.Math.PI, System.Math.PI / 2, true);
|
||||
var stationary = new Arc(1.5, 1.5, 0.75, System.Math.PI / 2, 3 * System.Math.PI / 2);
|
||||
Assert.Equal(expected, Tangency(moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tangency_ExternalFarRootIsCheckedAfterNearRootIsOutsideSpans()
|
||||
{
|
||||
var moving = new Arc(5, 1, 0.5, Angle.ToRadians(300), Angle.ToRadians(350));
|
||||
var stationary = new Arc(0, 0, 1.5, Angle.ToRadians(120), Angle.ToRadians(160));
|
||||
Assert.Equal(5 + System.Math.Sqrt(3), Tangency(moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void Tangency_CoincidentCentersDoNotInventAnInternalTangent(double radius)
|
||||
{
|
||||
var moving = new Arc(5, 0, radius, Angle.ToRadians(90), Angle.ToRadians(100));
|
||||
var stationary = new Arc(0, 0, radius, Angle.ToRadians(90), Angle.ToRadians(100));
|
||||
// Equal radii have no isolated internal tangent. The caller's vertex phases
|
||||
// handle coincident arcs (covered separately through both public paths).
|
||||
Assert.Equal(double.MaxValue, Tangency(moving, stationary, new Vector(-1, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tangency_ZeroRadiusCurveIsAPointRegardlessOfItsArcAngles()
|
||||
{
|
||||
var moving = new Arc(5, 1, 0, 0, 0);
|
||||
var stationary = new Arc(0, 0, 1.5, 0, System.Math.PI);
|
||||
Assert.Equal(5 - System.Math.Sqrt(1.25), Tangency(moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0.25)]
|
||||
[InlineData(true, 0.25)]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 0)]
|
||||
public void ClosedU_NativeOffsetStopsAtFirstContact(bool cpu, double spacing)
|
||||
{
|
||||
var drawing = NativeUFixture.CreateDrawing();
|
||||
var stationary = PartGeometry.GetOffsetPerimeterEntities(drawing.Program, spacing / 2);
|
||||
var moving = stationary.CloneAll();
|
||||
foreach (var entity in moving)
|
||||
entity.Rotate(System.Math.PI, new Vector(1.25, 1.5));
|
||||
|
||||
var distance = double.MaxValue;
|
||||
if (cpu)
|
||||
distance = new CpuDistanceComputer().ComputeDistances(stationary, moving,
|
||||
new[] { new SlideOffset(5.5, -1, -1, 0) })[0];
|
||||
else
|
||||
{
|
||||
foreach (var entity in moving)
|
||||
entity.Offset(5.5, -1);
|
||||
distance = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(-1, 0));
|
||||
}
|
||||
var contactRadius = 0.875 - spacing;
|
||||
var expected = 4 + System.Math.Sqrt(contactRadius * contactRadius - 0.5 * 0.5);
|
||||
Assert.Equal(expected, distance, 9);
|
||||
|
||||
// Independent raw tip-to-slot clearance; no offset/distance kernel in the oracle.
|
||||
var finalX = 5.5 - distance;
|
||||
var clearance = 0.875 - System.Math.Sqrt((finalX - 1.5) * (finalX - 1.5) + 0.5 * 0.5);
|
||||
Assert.Equal(spacing, clearance, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ExternalCircleContact_RemainsExact(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Circle(5, 1, 0.5) };
|
||||
var stationary = new List<Entity> { new Circle(0, 0, 1.5) };
|
||||
var distance = Distance(cpu, moving, stationary, new Vector(-1, 0));
|
||||
Assert.Equal(5 - System.Math.Sqrt(3), distance, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void EqualRadiusCoincidentArcs_ReturnZeroWithoutNaN(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Arc(0, 0, 1, 0, System.Math.PI) };
|
||||
var stationary = moving.CloneAll();
|
||||
Assert.Equal(0, Distance(cpu, moving, stationary, new Vector(-1, 0)));
|
||||
}
|
||||
|
||||
private static double Tangency(Arc moving, Arc stationary, Vector direction) =>
|
||||
SpatialQuery.CurveTangencyDistance(
|
||||
moving.Center.X, moving.Center.Y, moving.Radius, moving,
|
||||
stationary.Center.X, stationary.Center.Y, stationary.Radius, stationary,
|
||||
direction.X, direction.Y);
|
||||
|
||||
private static Arc ReflectX(Arc arc) => new(
|
||||
-arc.Center.X, arc.Center.Y, arc.Radius,
|
||||
Angle.NormalizeRad(System.Math.PI - arc.StartAngle),
|
||||
Angle.NormalizeRad(System.Math.PI - arc.EndAngle), !arc.IsReversed);
|
||||
|
||||
private static double Distance(bool cpu, List<Entity> moving, List<Entity> stationary, Vector direction) =>
|
||||
cpu
|
||||
? new CpuDistanceComputer().ComputeDistances(stationary, moving,
|
||||
new[] { new SlideOffset(0, 0, direction.X, direction.Y) })[0]
|
||||
: SpatialQuery.DirectionalDistance(moving, stationary, direction);
|
||||
}
|
||||
|
||||
internal static class NativeUFixture
|
||||
{
|
||||
internal static Drawing CreateDrawing()
|
||||
{
|
||||
// Complete closed native outline: a semicircular back and two square-ended tips.
|
||||
var entities = new List<Entity>
|
||||
{
|
||||
new Line(2.5, 0.625, 2.5, 0),
|
||||
new Line(2.5, 0, 1.5, 0),
|
||||
new Arc(1.5, 1.5, 1.5, 3 * System.Math.PI / 2, System.Math.PI / 2, true),
|
||||
new Line(1.5, 3, 2.5, 3),
|
||||
new Line(2.5, 3, 2.5, 2.375),
|
||||
new Line(2.5, 2.375, 1.5, 2.375),
|
||||
new Arc(1.5, 1.5, 0.875, System.Math.PI / 2, 3 * System.Math.PI / 2),
|
||||
new Line(1.5, 0.625, 2.5, 0.625),
|
||||
};
|
||||
var shape = Assert.Single(ShapeBuilder.GetShapes(entities));
|
||||
Assert.True(shape.IsClosed());
|
||||
var drawing = new Drawing("Native U", ConvertGeometry.ToProgram(shape));
|
||||
var profile = new ShapeProfile(ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
|
||||
Assert.True(profile.Perimeter.IsClosed());
|
||||
Assert.Empty(profile.Cutouts);
|
||||
var bounds = drawing.Program.BoundingBox();
|
||||
Assert.Equal(2.5, bounds.Length, 9);
|
||||
Assert.Equal(3, bounds.Width, 9);
|
||||
var exactArea = System.Math.PI * (1.5 * 1.5 - 0.875 * 0.875) / 2 + 2 * 0.625;
|
||||
Assert.InRange(profile.Perimeter.ToPolygonWithTolerance(1e-6).Area(), exactArea - 1e-5, exactArea + 1e-5);
|
||||
return drawing;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user