From 4161e6d1c7bb03b5cbb60b4294a4544643f3ce28 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 10:08:36 -0400 Subject: [PATCH] feat(core): add a concave no-fit polygon to NoFitPolygon Core only had a convex NFP, so Opus55 and Gpt6Astra each built concave NFPs from Clipper's Minkowski sum, and only Opus55 added the terms that cover one part lying inside or swallowing the other - Gpt6Astra instead filled every positive path and lost real interlocks. NoFitPolygon.Compute ports Opus55's construction (boundary sweep united with A + p0 and -B + a0; convex pairs use the linear edge merge). It works on filled perimeters only; hole-aware clearance stays with collision testing. Tests port Opus55's NFP tests and add a notch fit and a seeded property check against Collision.HasOverlap. Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/NoFitPolygon.cs | 63 +++++++++ OpenNest.Tests/Geometry/NoFitPolygonTests.cs | 131 +++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 OpenNest.Tests/Geometry/NoFitPolygonTests.cs diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs index aebdad8..680aa23 100644 --- a/OpenNest.Core/Geometry/NoFitPolygon.cs +++ b/OpenNest.Core/Geometry/NoFitPolygon.cs @@ -1,3 +1,4 @@ +using Clipper2Lib; using System.Collections.Generic; using OpenNest.Math; @@ -10,6 +11,68 @@ namespace OpenNest.Geometry /// public static class NoFitPolygon { + /// + /// Computes forbidden translations of moving around stationary. Interior means + /// overlap and boundary means touch, subject to Clipper rounding at precision. + /// Inputs are simple filled perimeters, with either winding and optional closing + /// vertices. Cutouts are not supported: use Collision for hole-aware decisions. + /// The moving reference point is the origin, not its first vertex. Cache this + /// CPU preparation result. Rings with fewer than three vertices produce no region. + /// + public static PathsD Compute(PathD stationary, PathD moving, int precision = ClipperBridge.Precision) + { + var a = Normalize(stationary); + var b = Normalize(moving); + if (a.Count < 3 || b.Count < 3) + return new PathsD(); + + if (IsConvex(a) && IsConvex(b)) + return new PathsD { ClipperBridge.ToPath(ComputeConvex( + ClipperBridge.ToPolygon(a), ClipperBridge.ToPolygon(b)), true) }; + + var negB = new PathD(b.Count); + foreach (var point in b) + negB.Add(new PointD(-point.x, -point.y)); + // The boundary sweep alone misses both kinds of containment. + var sweep = Minkowski.Sum(negB, a, true, precision); + sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y)); + sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y)); + return Clipper.Union(sweep, new PathsD(), FillRule.NonZero, precision); + } + + /// + /// Computes forbidden origin translations for two filled, lines-only perimeters. + /// Cutouts are not supported; use Collision for hole-aware decisions. + /// + public static PathsD Compute(Polygon stationary, Polygon moving) => + Compute(ClipperBridge.ToPath(stationary, true), ClipperBridge.ToPath(moving, true)); + + private static PathD Normalize(PathD source) + { + var path = new PathD(); + foreach (var point in source) + if (path.Count == 0 || path[path.Count - 1].x != point.x || path[path.Count - 1].y != point.y) + path.Add(point); + if (path.Count > 1 && path[0].x == path[path.Count - 1].x && path[0].y == path[path.Count - 1].y) + path.RemoveAt(path.Count - 1); + if (!Clipper.IsPositive(path)) + path.Reverse(); + return path; + } + + private static bool IsConvex(PathD path) + { + for (var i = 0; i < path.Count; i++) + { + var a = path[i]; + var b = path[(i + 1) % path.Count]; + var c = path[(i + 2) % path.Count]; + if ((b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) < 0) + return false; + } + return true; + } + /// /// Computes the NFP between a convex stationary polygon A and a convex orbiting /// polygon B: the Minkowski sum of A and -B (B reflected through its reference point). diff --git a/OpenNest.Tests/Geometry/NoFitPolygonTests.cs b/OpenNest.Tests/Geometry/NoFitPolygonTests.cs new file mode 100644 index 0000000..56d7238 --- /dev/null +++ b/OpenNest.Tests/Geometry/NoFitPolygonTests.cs @@ -0,0 +1,131 @@ +using Clipper2Lib; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class NoFitPolygonTests +{ + [Theory] + [InlineData(0, 0, true)] + [InlineData(-5, -5, true)] + [InlineData(2, 0.5, true)] + [InlineData(4, 0, false)] + [InlineData(0, -21, false)] + [InlineData(-21, 0, false)] + public void PortedContainmentCases(double x, double y, bool forbidden) + { + var a = Ring((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)); + Assert.Equal(forbidden, Inside(NoFitPolygon.Compute(a, Square(20)), x, y)); + } + + [Fact] + public void SquareFitsInNotch() + { + var a = Ring((0, 0), (5, 0), (5, 1), (1, 1), (1, 5), (0, 5)); + Assert.False(Inside(NoFitPolygon.Compute(a, Square(2)), 2, 2)); + Assert.True(Inside(NoFitPolygon.Compute(a, Square(2)), 0.5, 2)); + } + + [Fact] + public void PerimeterOnlyNfpCannotRepresentPartInHole() + { + var outer = Square(10); + var hole = Move(Square(6), 2, 2); + var moving = Square(1); + Assert.False(Collision.HasOverlap(outer, Move(moving, 4, 4), new List { hole })); + // The API explicitly fills its single perimeter. Hole-aware callers must use Collision. + Assert.True(Inside(NoFitPolygon.Compute(outer, moving), 4, 4)); + } + + [Fact] + public void ConvexWindingClosureAndOriginAreNormalized() + { + var a = ClipperBridge.ToPath(Move(Square(3), 7, 4), true); + var b = ClipperBridge.ToPath(Move(Square(2), 1, 2), false); + b.Add(b[0]); + var result = NoFitPolygon.Compute(a, b); + Assert.Equal(25, System.Math.Abs(Clipper.Area(result)), 8); + Assert.True(Inside(result, 6, 2)); + Assert.False(Inside(result, 10, 2)); + } + + [Fact] + public void SeededConcavePairsAgreeAwayFromBoundary() + { + var random = new Random(760125); + var decisions = 0; + for (var pair = 0; pair < 100; pair++) + { + var a = Star(random); + var b = Star(random); + var nfp = NoFitPolygon.Compute(a, b); + for (var sample = 0; sample < 100; sample++) + { + var x = random.NextDouble() * 20 - 10; + var y = random.NextDouble() * 20 - 10; + // Exclude a 0.01 boundary band: tiny contact wedges can have area below + // Collision's 1e-5 area floor even beyond Clipper's 1e-4 grid. + if (NearBoundary(nfp, x, y, 0.01)) + continue; + Assert.True(Collision.HasOverlap(a, Move(b, x, y)) == Inside(nfp, x, y), $"pair={pair} sample={sample} x={x:R} y={y:R}"); + decisions++; + } + } + Assert.True(decisions > 9800); + } + + internal static Polygon Star(Random random) + { + var points = new (double, double)[8]; + for (var i = 0; i < points.Length; i++) + { + var angle = i * System.Math.PI / 4; + var radius = (i % 2 == 0 ? 3 : 1) * (0.8 + random.NextDouble() * 0.4); + points[i] = (radius * System.Math.Cos(angle), radius * System.Math.Sin(angle)); + } + return Ring(points); + } + + internal static Polygon Square(double size) => Ring((0, 0), (size, 0), (size, size), (0, size)); + + internal static Polygon Ring(params (double X, double Y)[] points) + { + var polygon = new Polygon(); + foreach (var (x, y) in points) + polygon.Vertices.Add(new Vector(x, y)); + polygon.Close(); + polygon.UpdateBounds(); + return polygon; + } + + internal static Polygon Move(Polygon polygon, double x, double y) => + ClipperBridge.ToPolygon(Clipper.TranslatePath(ClipperBridge.ToPath(polygon, new Vector()), x, y)); + + private static bool Inside(PathsD region, double x, double y) + { + var winding = 0; + foreach (var path in region) + if (Clipper.PointInPolygon(new PointD(x, y), path) == PointInPolygonResult.IsInside) + winding += Clipper.IsPositive(path) ? 1 : -1; + return winding != 0; + } + + private static bool NearBoundary(PathsD paths, double x, double y, double tolerance) + { + foreach (var path in paths) + for (var i = 0; i < path.Count; i++) + { + var a = path[i]; + var b = path[(i + 1) % path.Count]; + var dx = b.x - a.x; + var dy = b.y - a.y; + var t = System.Math.Clamp(((x - a.x) * dx + (y - a.y) * dy) / (dx * dx + dy * dy), 0, 1); + var ex = x - a.x - t * dx; + var ey = y - a.y - t * dy; + if (ex * ex + ey * ey < tolerance * tolerance) + return true; + } + return false; + } +} +