From ca1902e075008927139ef549fae6a26e6e1f1595 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 16:35:53 -0400 Subject: [PATCH] fix(bestfit): stop at internal arc tangency --- AGENTS.md | 1 + OpenNest.Core/Geometry/SpatialQuery.cs | 189 ++++++++------- .../BestFit/CpuDistanceComputer.cs | 50 +--- .../BestFit/NativeUClearanceTests.cs | 122 ++++++++++ .../Geometry/CurveContactDistanceTests.cs | 223 ++++++++++++++++++ README.md | 2 + docs/geometry/pair-spacing.md | 57 +++++ 7 files changed, 519 insertions(+), 125 deletions(-) create mode 100644 OpenNest.Tests/BestFit/NativeUClearanceTests.cs create mode 100644 OpenNest.Tests/Geometry/CurveContactDistanceTests.cs create mode 100644 docs/geometry/pair-spacing.md diff --git a/AGENTS.md b/AGENTS.md index 0b6b9f2..3106f28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,7 @@ Always keep `README.md` and `AGENTS.md` up to date when making changes that affe - Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. - **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, inflated by the spacing less `NestTolerances.SpacingSlack` (0.0005), so a layout exactly at the spacing passes even after rotation and coordinate rounding leave it ~1e-4 short. `NestJobPlacementValidator` applies the same slack to its edge-distance check. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. `FillLinear` prepares each distinct `Program` (reference identity) once per public `Fill`/`FillRow` call and translates clones; never share that cache across calls or threads. Both `HasOverlappingParts` loops use one `PartOverlapChecker` per call (same keying; it also caches each part's triangles; parts and programs must not change while it is in use). Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - **Marks are not material**: scribe/etch moves are marked on the surface, never cut through, so they are left out of nesting. `SpecialLayers.IsMaterial(layer)` (excludes `Rapid` and `Scribe`) is the filter for every consumer that builds part material from a program: drawing area, canonical angle, part collision, `PartGeometry`, plate perimeters, best-fit/pair evaluation, rotation analysis, the GPU evaluators, and both validators (`NestJobPlacementValidator`, benchmark `NestValidator`). Cutting time, on-screen display, splitting, and post-processors still see marks. Older `.nest` files (e.g. `tools/PepNestExport` output) saved etch as cut moves while their source entities kept the `SCRIBE` layer; `NestReader` runs `ScribeLayerRepair` on load to move matching program moves back to `Scribe`. +- **Native curve contact**: CPU best-fit and shared directional queries use `SpatialQuery.CurveTangencyDistance` for sum- and difference-radius tangency, checking both forward roots and both arc spans. It supplements vertex/line phases, not a complete collision/clearance validator. See [pair-spacing checks](docs/geometry/pair-spacing.md) for the regression and remaining limits. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring. - **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change. diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs index dbb412f..0ad7ad7 100644 --- a/OpenNest.Core/Geometry/SpatialQuery.cs +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -40,45 +40,45 @@ namespace OpenNest.Geometry { case PushDirection.Left: case PushDirection.Right: - { - var dy = p2y - p1y; - if (System.Math.Abs(dy) < Tolerance.Epsilon) + { + var dy = p2y - p1y; + if (System.Math.Abs(dy) < Tolerance.Epsilon) + return double.MaxValue; + + var t = (vy - p1y) / dy; + if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) + return double.MaxValue; + + var ix = p1x + t * (p2x - p1x); + var dist = direction == PushDirection.Left ? vx - ix : ix - vx; + + if (dist > Tolerance.Epsilon) + return dist; + if (dist >= -Tolerance.Epsilon) + return 0; return double.MaxValue; - - var t = (vy - p1y) / dy; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var ix = p1x + t * (p2x - p1x); - var dist = direction == PushDirection.Left ? vx - ix : ix - vx; - - if (dist > Tolerance.Epsilon) - return dist; - if (dist >= -Tolerance.Epsilon) - return 0; - return double.MaxValue; - } + } case PushDirection.Down: case PushDirection.Up: - { - var dx = p2x - p1x; - if (System.Math.Abs(dx) < Tolerance.Epsilon) + { + var dx = p2x - p1x; + if (System.Math.Abs(dx) < Tolerance.Epsilon) + return double.MaxValue; + + var t = (vx - p1x) / dx; + if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) + return double.MaxValue; + + var iy = p1y + t * (p2y - p1y); + var dist = direction == PushDirection.Down ? vy - iy : iy - vy; + + if (dist > Tolerance.Epsilon) + return dist; + if (dist >= -Tolerance.Epsilon) + return 0; return double.MaxValue; - - var t = (vx - p1x) / dx; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var iy = p1y + t * (p2y - p1y); - var dist = direction == PushDirection.Down ? vy - iy : iy - vy; - - if (dist > Tolerance.Epsilon) - return dist; - if (dist >= -Tolerance.Epsilon) - return 0; - return double.MaxValue; - } + } default: return double.MaxValue; @@ -247,6 +247,78 @@ namespace OpenNest.Geometry return double.MaxValue; } + /// + /// Returns the first external or internal tangency along a unit direction, or + /// double.MaxValue if none exists. Centers must be in the same world frame; + /// radii must be nonnegative. An optional arc constrains the contact angle + /// (null means a full circle); only its angular range is used. + /// Endpoint contacts and coincident equal-radius curves remain the caller's + /// vertex-to-entity responsibility, so this is not a complete collision test. + /// + public static double CurveTangencyDistance( + double movingCx, + double movingCy, + double movingRadius, + Arc movingArc, + double stationaryCx, + double stationaryCy, + double stationaryRadius, + Arc stationaryArc, + double dirX, + double dirY + ) + { + var best = double.MaxValue; + for (var kind = 0; kind < 2; kind++) + { + var internalContact = kind == 1; + var radius = internalContact + ? System.Math.Abs(movingRadius - stationaryRadius) + : movingRadius + stationaryRadius; + + // Equal-radius internal contact has coincident centers, not a unique + // tangent point. Endpoints detect any overlap of those angular spans. + if (radius == 0) + continue; + + if (!SolveRayCircle( + movingCx, movingCy, stationaryCx, stationaryCy, radius, + dirX, dirY, out var t1, out var t2)) + continue; + + // The nearer center-circle root can be outside an arc while the farther + // root is its first contact. Check the actual tangent point at BOTH roots. + for (var root = 0; root < 2; root++) + { + var t = root == 0 ? t1 : t2; + if (t < -Tolerance.Epsilon || t >= best) + continue; + + var toX = stationaryCx - (movingCx + t * dirX); + var toY = stationaryCy - (movingCy + t * dirY); + var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1; + var stationarySign = internalContact ? movingSign : -1; + if (!ContainsContactAngle(movingArc, movingRadius, movingSign * toX, movingSign * toY) + || !ContainsContactAngle(stationaryArc, stationaryRadius, stationarySign * toX, stationarySign * toY)) + continue; + + best = t > Tolerance.Epsilon ? t : 0; + if (best == 0) + return 0; + } + } + return best; + } + + private static bool ContainsContactAngle(Arc arc, double radius, double x, double y) + { + // A zero-radius curve is a point: its angular range has no geometric meaning. + if (arc == null || radius == 0) + return true; + var angle = Angle.NormalizeRad(System.Math.Atan2(y, x)); + return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed); + } + /// /// Computes the minimum translation distance along a push direction before /// any edge of movingLines contacts any edge of stationaryLines. @@ -717,12 +789,7 @@ namespace OpenNest.Geometry if (minDist <= 0) return 0; - // Phase 4: Curve-to-curve direct distance. - // The vertex-to-entity approach misses the closest contact between two - // curved entities (circles/arcs) because only a few cardinal vertices are - // sampled. The true closest contact along the push direction is found by - // treating it as a ray from one center to an expanded circle at the other - // center (radius = r1 + r2). + // Phase 4: Native curve tangency, including a convex corner inside a concave arc. for (var i = 0; i < movingEntities.Count; i++) { var me = movingEntities[i]; @@ -735,48 +802,12 @@ namespace OpenNest.Geometry if (!TryGetCurveParams(se, out var scx, out var scy, out var sr)) continue; - var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY); - + var d = CurveTangencyDistance( + mcx, mcy, mr, me as Arc, + scx, scy, sr, se as Arc, dirX, dirY); if (d >= minDist) continue; - // For arcs, verify the contact point falls within both arcs' angular ranges. - if (me is Arc || se is Arc) - { - var mx = mcx + d * dirX; - var my = mcy + d * dirY; - var toCx = scx - mx; - var toCy = scy - my; - - if (me is Arc mArc) - { - var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx)); - if ( - !Angle.IsBetweenRad( - angle, - mArc.StartAngle, - mArc.EndAngle, - mArc.IsReversed - ) - ) - continue; - } - - if (se 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) return 0; diff --git a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs index 7003daf..21339fb 100644 --- a/OpenNest.Engine/BestFit/CpuDistanceComputer.cs +++ b/OpenNest.Engine/BestFit/CpuDistanceComputer.cs @@ -253,55 +253,13 @@ namespace OpenNest.Engine.BestFit 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 - ); + var d = SpatialQuery.CurveTangencyDistance( + mcx, mcy, mc.Radius, mc.Entity as Arc, + sc.Cx, sc.Cy, sc.Radius, sc.Entity as Arc, dirX, dirY); - if (d >= minDist || d == double.MaxValue) + if (d >= minDist) 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) { diff --git a/OpenNest.Tests/BestFit/NativeUClearanceTests.cs b/OpenNest.Tests/BestFit/NativeUClearanceTests.cs new file mode 100644 index 0000000..edc33a3 --- /dev/null +++ b/OpenNest.Tests/BestFit/NativeUClearanceTests.cs @@ -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] = (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 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; + } +} diff --git a/OpenNest.Tests/Geometry/CurveContactDistanceTests.cs b/OpenNest.Tests/Geometry/CurveContactDistanceTests.cs new file mode 100644 index 0000000..4675c4f --- /dev/null +++ b/OpenNest.Tests/Geometry/CurveContactDistanceTests.cs @@ -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 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 { new Circle(5, 1, 0.5) }; + var stationary = new List { 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 { 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 moving, List 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 + { + 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; + } +} diff --git a/README.md b/README.md index db1fc45..8fdcb77 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Shared coding-agent guidance lives in [AGENTS.md](AGENTS.md). [CLAUDE.md](CLAUDE 4. Fill — the engine arranges parts 5. Optionally add cut-off lines, then save `.nest`, export DXF, or post-process to G-code +Review part spacing before cutting, especially for interlocking pairs. See [pair-spacing checks and current limitations](docs/geometry/pair-spacing.md). + ## Command-Line Interface ```bash diff --git a/docs/geometry/pair-spacing.md b/docs/geometry/pair-spacing.md new file mode 100644 index 0000000..512d0bf --- /dev/null +++ b/docs/geometry/pair-spacing.md @@ -0,0 +1,57 @@ +# Best-fit pair spacing: native arc contact + +## Corrected case + +CPU best-fit slides and shared directional-distance queries now check both external and internal curve tangency. A convex offset corner inside a concave slot contacts at the difference of the radii, not their sum. Both forward ray/circle roots must be checked: the nearer root can be outside an arc's angular span while the farther root is the first actual contact. Tangent-point directions differ for internal contact, including when the moving curve is the larger one. + +`SpatialQuery.CurveTangencyDistance` shares this calculation between the two callers. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed. + +### Reproduced U-shaped part + +The closed native outline is 2.5 by 3 inches, with a 1.5-inch outer semicircle, a 0.875-inch inner semicircle, and square-ended tips. `NativeUFixture` constructs it without external files. The independently imported source was `End sheet lift lug.DXF`, SHA-256 `b7802988dd56216fbc49b1cf8afdabd5df26e96da61763e3c8c2d9105ff19d83`; the input is unchanged and is not required by the tests. + +At 0.25-inch requested spacing each outline is inflated by 0.125. The failing contact is between a 0.125 convex corner and a 0.75 concave arc. With the stationary slot centered at (1.5, 1.5) and moving corner at (x, 2), contact occurs at x = 1.125, from center distance 0.625. Sliding left from x = 5.5 therefore stops after 4.375, instead of the old endpoint-only 4.414578098794425. Independent raw-outline clearance at this contact is exactly 0.25. + +Fresh-process import/cache/materialization probes used a 24-by-24 sheet, one-inch edge spacing (22-by-22 usable area), and 0.25-inch part spacing: + +| Path | Before | Corrected | +| --- | --- | --- | +| Smallest-envelope kept pair, minimum raw clearance | 0.22548054744486495 | 0.24999929486961545 | +| `PairFiller`, 70 parts, minimum raw clearance | 0.22548054744486434 | 0.24999929486961417 | +| `PlateFillService.FillItem("Default")`, 70 parts, minimum raw clearance | 0.2254805474448648 | 0.24999929486961447 | + +Measurements use raw material outlines tessellated at 1e-6 chord tolerance and independent Shapely boundary distances, not the slide solver or its offset contours. The roughly 7e-7 shortfall is within tessellation error. These specific pair/grid polygons were valid; no geometry repair was applied. The corrected top pair passes the existing validator. The full grids do **not** pass it; see below. + +## Remaining limitations — not a complete spacing fix + +- Other angled candidates remain physically too close. Of 568 kept candidates in the corrected real-DXF probe, 49 had boundary distance below 0.25 minus 2e-6, including 48 below the existing 0.2495 spacing-slack threshold. The worst measured boundary gap was about 0.205618. Their cause is not established by this repair; CPU projection filtering and missing arc/line interior contact are separate suspects. +- The existing offset-based validator still reports 28 spacing violations in each corrected 70-part grid despite the independent fine-outline measurement above. Do not loosen validator tolerance or declare these reports resolved without isolating the discrepancy. This report verifies physical outline clearance for that fixture, not validator acceptance of the whole grid. +- Across the complete candidate probe, 42 tessellated polygons were invalid. Their boundary distances were recorded without silently repairing them, but intersection-area verdicts were omitted for those invalid polygons. This caveat does not apply to the top pair or the two reported grids. +- Kept-pair acceptance still checks raw overlap, not requested clearance. Interactive fill/group paths can trust an invalid seed. This repair does not add a final spacing gate or repair existing user placements. +- Serialized best-fit results have no algorithm-version invalidation. Loading previously saved candidates can retain old poses. The measurements above use newly imported drawings/fresh computation, not restored caches. Reimport/recompute when evaluating this fix; existing nests and DXFs are not rewritten. +- A zero-demand `PlateFillService.Nest` probe returns no parts and is **not** evidence of a valid whole-job nest. The nonempty cases verified here are pair generation, pair fill, interactive fill, and the quantity-two shortcut. +- GPU behavior and Windows desktop execution were not verified on this Linux host. + +Next hardening should isolate the remaining angular contact defect with a first-contact regression, then address clearance-aware seed acceptance/cache compatibility and the grid-validator disagreement as separate changes. Do not conflate those with the unrelated unchanged-row validation-reuse performance work. + +## Regression verification + +```bash +dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ + --filter 'FullyQualifiedName~CurveContactDistanceTests|FullyQualifiedName~NativeUClearanceTests' +``` + +The 36 targeted cases cover both callers, swapped curves, reflected/translated/rotated directions, both roots, arc-span rejection, external-circle contact, zero spacing, point/equal-radius degeneracies, the real-shaped closed U fixture, top cached-pair source materialization, and nonempty interactive fill with quantity zero/two. The test clearance oracle uses fine raw line segments and independent point/segment distances, plus overlap checks; it does not use native offset curves or the slide solver. The complete-grid validator result is logged, not represented as passing. + +Baseline `bc5fd86996da3abb98bb3b1da5f81de82692bf5e` failed 18 of the original 22 geometry regression cases and all three integration cases before the repair. Final scoped formatter and `--verify-no-changes` runs succeeded with `EnableWindowsTargeting=true`; all suites below passed on the repaired tree: + +| Suite | Passed | Skipped | Failed | +| --- | ---: | ---: | ---: | +| Main Release | 1655 | 21 | 0 | +| Main Debug | 1685 | 21 | 0 | +| Engine Release | 300 | 0 | 0 | +| IO Release | 41 | 0 | 0 | + +The detached worktree includes the local fixture configuration. The 21 main-suite skips are reported rather than counted as passes. No Windows runtime result is implied. + +Local evidence: `/home/aj/extracted/2026-09-27/u-part-spacing/` (original diagnosis, before/fixed raw layouts, input hashes, RED tests) and its `resumed/` directory (fresh DXF probe, independent measurements, full-suite TRX/logs and review). The synthetic regression sources are the portable reproduction; local evidence paths are supplementary, not a build dependency.