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;
+ }
+}
+