From 3dca25c60167f29b74655ca9bcd3c0a723d14eed Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 8 Apr 2026 00:15:35 -0400 Subject: [PATCH] fix: improve circle nesting with curve-to-curve distance and min copy spacing Add Phase 3 curve-to-curve direct distance in CpuDistanceComputer to catch contacts that vertex sampling misses between curved entities. Enforce minimum copy distance in FillLinear to prevent bounding box overlap when circumscribed polygon boundaries overshoot true arcs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BestFit/CpuDistanceComputer.cs | 75 ++++++++++ OpenNest.Engine/Fill/FillLinear.cs | 9 +- OpenNest.Tests/Fill/FillLinearCircleTests.cs | 130 ++++++++++++++++++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Tests/Fill/FillLinearCircleTests.cs 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}"); + } + } + } +}