using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Math;
namespace OpenNest.Geometry
{
///
/// Region offsetting through Clipper2, for CPU-side preparation only: work done
/// once per drawing, rotation or spacing whose output is cached and fed to hot
/// loops. Per-pair tests () stay hand-rolled so they can
/// be ported to a GPU kernel.
///
public static class ClipperBridge
{
///
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
///
public const int Precision = 4;
private const double MiterLimit = 2.0;
private const double ConservativeJoinFactor = 0.25;
private const double ValidationJoinFactor = 0.1;
///
/// Converts a polygon to a Clipper path, dropping the closing vertex and
/// orienting it positive (CCW) or negative (CW).
///
public static PathD ToPath(Polygon polygon, bool positive)
{
var path = ToPath(polygon, new Vector());
if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
path.Reverse();
return path;
}
///
/// Converts a polygon to a Clipper path with an optional offset, dropping the
/// closing vertex and keeping the polygon's own winding.
///
public static PathD ToPath(Polygon polygon, Vector offset)
{
var verts = polygon.Vertices;
var n = verts.Count;
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
var path = new PathD(n);
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
return path;
}
///
/// Converts a Clipper path to a closed polygon with updated bounds.
///
public static Polygon ToPolygon(PathD path)
{
var polygon = new Polygon();
foreach (var pt in path)
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
///
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
///
public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
{
var region = new PathsD(profile.Cutouts.Count + 1);
AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
// A cutout is flattened the opposite way: circumscribing it would shrink the
// material around it, so inscribe instead to keep the region conservative.
foreach (var cutout in profile.Cutouts)
AddShape(region, cutout, tolerance, !circumscribe, positive: false);
return region;
}
///
/// Offsets a part region outward by : the perimeter
/// grows and the cutouts shrink. Features narrower than twice the distance
/// collapse, and cutouts that close up disappear. Joins are round, with chords
/// no more than from the true arc.
///
///
/// When true, the result never under-estimates the offset: perimeter arcs are
/// flattened outside the true curve, cutout arcs inside it, and the inflation is
/// padded by the round-join chord error and Clipper's rounding.
///
public static OffsetRegion Offset(
ShapeProfile profile,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = ToRegion(profile, tolerance, circumscribe);
return Offset(region, distance, tolerance, circumscribe);
}
///
/// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that
/// curls back on itself (a C shape with a narrow mouth) can gain holes.
///
public static OffsetRegion OffsetPerimeter(
Shape perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var polygon = Flatten(perimeter, tolerance, circumscribe);
return OffsetPerimeter(polygon, distance, tolerance, circumscribe);
}
///
/// Offsets a closed polygon outward, whatever its winding.
///
public static OffsetRegion OffsetPerimeter(
Polygon perimeter,
double distance,
double tolerance,
bool circumscribe = false
)
{
var region = new PathsD(1);
AddPolygon(region, perimeter, positive: true);
return Offset(region, distance, tolerance, circumscribe);
}
///
/// Offsets an already-flattened region (outers positive, holes negative).
/// A distance of zero only unions the region, with no conservative padding.
///
public static OffsetRegion Offset(
PathsD region,
double distance,
double tolerance,
bool circumscribe = false
)
{
// Round joins put their vertices on the true arc, so each chord sits inside
// it by up to the join tolerance. In conservative mode, joins use a finer
// tolerance and the inflation is padded by it (plus Clipper's rounding).
var delta = distance;
var joinTolerance = tolerance;
if (circumscribe && distance > 0)
{
joinTolerance = tolerance * ConservativeJoinFactor;
delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision);
}
return Inflate(region, delta, joinTolerance);
}
///
/// Offset for checking a finished layout against its spacing. Arcs are flattened
/// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed),
/// but round joins use a tenth of the tolerance and nothing is padded, so a layout
/// exactly at the spacing passes. The only under-estimate is the join chord error
/// at convex corners, at most a tenth of .
///
public static OffsetRegion OffsetForValidation(
ShapeProfile profile,
double distance,
double tolerance
)
{
var region = ToRegion(profile, tolerance, circumscribe: true);
return Inflate(region, distance, tolerance * ValidationJoinFactor);
}
private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance)
{
var inflated =
delta <= 0
? Union(region)
: Clipper.InflatePaths(
region,
delta,
JoinType.Round,
EndType.Polygon,
MiterLimit,
Precision,
joinTolerance
);
var result = new OffsetRegion(new List(), new List());
foreach (var path in inflated)
{
if (path.Count < 3)
continue;
if (Clipper.IsPositive(path))
result.Outers.Add(ToPolygon(path));
else
result.Holes.Add(ToPolygon(path));
}
return result;
}
///
/// Miter-offsets a closed polygon by (positive grows it,
/// negative shrinks it). Returns the largest resulting polygon (CCW), or null
/// when the polygon collapses.
///
public static Polygon OffsetMiter(Polygon polygon, double delta)
{
var path = ToPath(polygon, positive: true);
if (path.Count < 3)
return null;
var inflated = Clipper.InflatePaths(
new PathsD { path },
delta,
JoinType.Miter,
EndType.Polygon,
MiterLimit,
Precision
);
PathD largest = null;
var largestArea = 0.0;
foreach (var candidate in inflated)
{
var area = Clipper.Area(candidate);
if (area > largestArea)
{
largest = candidate;
largestArea = area;
}
}
return largest == null ? null : ToPolygon(largest);
}
///
/// Flattens a closed shape to a polygon whose chords stay within
/// of every arc. Inscribed, the vertices lie on the
/// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit
/// on tangent intersections, so the polygon never falls inside the curve and never
/// pokes past the straight edges an arc meets.
///
public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe)
{
var polygon = new Polygon();
foreach (var entity in shape.Entities)
{
switch (entity)
{
case Line line:
polygon.Vertices.Add(line.StartPoint);
polygon.Vertices.Add(line.EndPoint);
break;
case Arc arc:
AddArc(polygon.Vertices, arc, tolerance, circumscribe);
break;
case Circle circle:
AddCircle(polygon.Vertices, circle, tolerance, circumscribe);
break;
}
}
polygon.Close();
polygon.Cleanup();
polygon.UpdateBounds();
return polygon;
}
private static void AddArc(List points, Arc arc, double tolerance, bool circumscribe)
{
if (!circumscribe)
{
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)));
return;
}
var sweep = arc.SweepAngle();
var segments = CircumscribedSegments(arc.Radius, sweep, tolerance);
var step = (arc.IsReversed ? -sweep : sweep) / segments;
var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2);
points.Add(arc.StartPoint());
for (var i = 0; i < segments; i++)
{
var angle = arc.StartAngle + step * (i + 0.5);
points.Add(
new Vector(
arc.Center.X + r * System.Math.Cos(angle),
arc.Center.Y + r * System.Math.Sin(angle)
)
);
}
points.Add(arc.EndPoint());
}
private static void AddCircle(
List points,
Circle circle,
double tolerance,
bool circumscribe
)
{
if (!circumscribe)
{
points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance)));
return;
}
var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance);
var step = Angle.TwoPI / segments;
var r = circle.Radius / System.Math.Cos(step / 2);
for (var i = 0; i < segments; i++)
{
points.Add(
new Vector(
circle.Center.X + r * System.Math.Cos(step * i),
circle.Center.Y + r * System.Math.Sin(step * i)
)
);
}
}
///
/// Segments for a circumscribed arc: a tangent-intersection vertex sits
/// radius / cos(step / 2) from the center, so keep that within the tolerance, and
/// keep each step at 90 degrees or less so the tangents meet close to the arc.
///
private static int CircumscribedSegments(double radius, double sweep, double tolerance)
{
var maxHalfStep = System.Math.Acos(radius / (radius + tolerance));
var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep));
var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI);
return System.Math.Max(1, System.Math.Max(segments, quarters));
}
private static PathsD Union(PathsD region)
{
var clipper = new ClipperD(Precision);
clipper.AddSubject(region);
var solution = new PathsD();
clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
return solution;
}
private static void AddShape(
PathsD region,
Shape shape,
double tolerance,
bool circumscribe,
bool positive
)
{
AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive);
}
private static void AddPolygon(PathsD region, Polygon polygon, bool positive)
{
if (polygon.Vertices.Count < 3)
return;
var path = ToPath(polygon, positive);
if (path.Count >= 3)
region.Add(path);
}
}
///
/// Result of :
/// outer boundaries (CCW) and holes (CW), as closed polygons.
///
public sealed record OffsetRegion(List Outers, List Holes)
{
///
/// The outer boundary with the largest area, or null when the region is empty.
///
public Polygon LargestOuter()
{
Polygon best = null;
var bestArea = 0.0;
foreach (var outer in Outers)
{
var area = outer.Area();
if (best == null || area > bestArea)
{
best = outer;
bestArea = area;
}
}
return best;
}
}
}