diff --git a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs index 2c89770..5fcab6d 100644 --- a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs +++ b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs @@ -104,6 +104,9 @@ namespace OpenNest.Engine.BestFit var allMovingVerts = ExtractVerticesFromEntities(movingEntities); var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities); + var movingCurves = ExtractCurveParams(movingEntities); + var stationaryCurves = ExtractCurveParams(stationaryEntities); + var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>(); foreach (var offset in offsets) @@ -165,12 +168,84 @@ namespace OpenNest.Engine.BestFit } } + // Phase 3: Curve-to-curve direct distance. + // Vertex sampling misses the true contact between two curved entities + // when the approach angle doesn't align with a sampled vertex. + for (var m = 0; m < movingCurves.Length; m++) + { + var mc = movingCurves[m]; + var mcx = mc.Cx + offset.Dx; + var mcy = mc.Cy + offset.Dy; + + for (var s = 0; s < stationaryCurves.Length; s++) + { + var sc = stationaryCurves[s]; + var d = SpatialQuery.RayCircleDistance( + mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY); + + if (d >= minDist || d == double.MaxValue) + continue; + + if (mc.Entity is Arc || sc.Entity is Arc) + { + var mx = mcx + d * dirX; + var my = mcy + d * dirY; + var toCx = sc.Cx - mx; + var toCy = sc.Cy - my; + + if (mc.Entity is Arc mArc) + { + var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx)); + if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed)) + continue; + } + + if (sc.Entity is Arc sArc) + { + var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx)); + if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed)) + continue; + } + } + + minDist = d; + if (d <= 0) { results[i] = 0; return; } + } + } + results[i] = minDist; }); return results; } + private readonly struct CurveParams + { + public readonly Entity Entity; + public readonly double Cx, Cy, Radius; + + public CurveParams(Entity entity, double cx, double cy, double radius) + { + Entity = entity; + Cx = cx; + Cy = cy; + Radius = radius; + } + } + + private static CurveParams[] ExtractCurveParams(List entities) + { + var curves = new List(); + for (var i = 0; i < entities.Count; i++) + { + if (entities[i] is Circle circle) + curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius)); + else if (entities[i] is Arc arc) + curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius)); + } + return curves.ToArray(); + } + private static double RayEntityDistance( double vx, double vy, Entity entity, double entityOffsetX, double entityOffsetY, diff --git a/OpenNest.Engine/Fill/FillLinear.cs b/OpenNest.Engine/Fill/FillLinear.cs index 1368d26..5471123 100644 --- a/OpenNest.Engine/Fill/FillLinear.cs +++ b/OpenNest.Engine/Fill/FillLinear.cs @@ -119,10 +119,11 @@ namespace OpenNest.Engine.Fill var maxCopyDistance = FindMaxPairDistance( patternA.Parts, boundaries, offset, pushDir, opposite, startOffset); - if (maxCopyDistance < Tolerance.Epsilon) - return bboxDim + PartSpacing; - - return maxCopyDistance; + // The copy distance must be at least bboxDim + PartSpacing to prevent + // bounding box overlap. Cross-pair slides can underestimate when the + // circumscribed polygon boundary overshoots the true arc, creating + // spurious contacts between diagonal parts in adjacent copies. + return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing); } /// diff --git a/OpenNest.Tests/Fill/FillLinearCircleTests.cs b/OpenNest.Tests/Fill/FillLinearCircleTests.cs new file mode 100644 index 0000000..d3e6773 --- /dev/null +++ b/OpenNest.Tests/Fill/FillLinearCircleTests.cs @@ -0,0 +1,130 @@ +using OpenNest; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; +using Xunit; +using Xunit.Abstractions; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Tests.Fill +{ + public class FillLinearCircleTests + { + private readonly ITestOutputHelper _output; + + public FillLinearCircleTests(ITestOutputHelper output) => _output = output; + + private static Drawing MakeCircleDrawing(double radius) + { + var pgm = new Program(); + var startPt = new Vector(radius * 2, radius); // rightmost point + pgm.Codes.Add(new RapidMove(startPt)); + pgm.Codes.Add(new ArcMove(startPt, new Vector(radius, radius), RotationType.CCW)); + return new Drawing("circle", pgm); + } + + private static Drawing MakeRingDrawing(double outerRadius, double innerRadius) + { + var pgm = new Program(); + // Outer circle (CCW) + var outerStart = new Vector(outerRadius * 2, outerRadius); + pgm.Codes.Add(new RapidMove(outerStart)); + pgm.Codes.Add(new ArcMove(outerStart, new Vector(outerRadius, outerRadius), RotationType.CCW)); + // Inner circle (CW = hole) + var innerStart = new Vector(outerRadius + innerRadius, outerRadius); + pgm.Codes.Add(new RapidMove(innerStart)); + pgm.Codes.Add(new ArcMove(innerStart, new Vector(outerRadius, outerRadius), RotationType.CW)); + return new Drawing("ring", pgm); + } + + [Theory] + [InlineData(2.0, 0.125)] // 4" diameter circle, 1/8" spacing + [InlineData(1.0, 0.125)] // 2" diameter circle + [InlineData(3.0, 0.0625)] // 6" diameter circle, 1/16" spacing + [InlineData(0.5, 0.25)] // 1" diameter circle, 1/4" spacing + public void CircleFill_OffsetBoundaries_DoNotOverlap(double radius, double spacing) + { + var drawing = MakeCircleDrawing(radius); + var workArea = new Box(0, 0, 48, 48); + var engine = new FillLinear(workArea, spacing); + var parts = engine.Fill(drawing, 0, NestDirection.Horizontal); + + _output.WriteLine($"Circle R={radius}, spacing={spacing}: {parts.Count} parts"); + + AssertNoOffsetOverlap(parts, spacing, radius * 2); + } + + [Theory] + [InlineData(2.0, 1.5, 0.125)] // Ring: outer R=2, inner R=1.5 + [InlineData(1.5, 1.0, 0.125)] // Ring: outer R=1.5, inner R=1.0 + public void RingFill_OffsetBoundaries_DoNotOverlap(double outerR, double innerR, double spacing) + { + var drawing = MakeRingDrawing(outerR, innerR); + var workArea = new Box(0, 0, 48, 48); + var engine = new FillLinear(workArea, spacing); + var parts = engine.Fill(drawing, 0, NestDirection.Horizontal); + + _output.WriteLine($"Ring outerR={outerR}, innerR={innerR}, spacing={spacing}: {parts.Count} parts"); + + AssertNoOffsetOverlap(parts, spacing, outerR * 2); + } + + private void AssertNoOffsetOverlap(List parts, double spacing, double expectedDiameter) + { + if (parts.Count < 2) + { + _output.WriteLine(" Only 1 part placed, skipping overlap check"); + return; + } + + var halfSpacing = spacing / 2; + var radius = expectedDiameter / 2; + var minGap = double.MaxValue; + var violationCount = 0; + + // For circular parts, the center is at Location + (radius, radius). + for (var i = 0; i < parts.Count; i++) + { + var ci = parts[i].Location + new Vector(radius, radius); + + for (var j = i + 1; j < parts.Count; j++) + { + var cj = parts[j].Location + new Vector(radius, radius); + var centerDist = ci.DistanceTo(cj); + + // Gap between raw circle perimeters + var rawGap = centerDist - expectedDiameter; + + // Gap between offset circle perimeters (halfSpacing each side) + var offsetGap = centerDist - expectedDiameter - spacing; + + if (rawGap < minGap) + minGap = rawGap; + + if (rawGap < spacing - Tolerance.Epsilon) + { + violationCount++; + if (violationCount <= 5) + { + _output.WriteLine($" SPACING VIOLATION parts[{i}] vs parts[{j}]: " + + $"centerDist={centerDist:F6}, rawGap={rawGap:F6}, offsetGap={offsetGap:F6}, " + + $"expected>={spacing:F4}"); + } + } + } + } + + _output.WriteLine($" Min gap={minGap:F6}, expected>={spacing:F4}, violations={violationCount}"); + + if (violationCount > 0) + { + var maxDeficit = spacing - minGap; + _output.WriteLine($" Max deficit={maxDeficit:F6}"); + Assert.Fail($"{violationCount} pairs violate spacing: min gap={minGap:F6}, expected>={spacing}, deficit={maxDeficit:F6}"); + } + } + } +}