feat(geometry): add ClipperBridge for region offsetting
Offsetting entity by entity leaves spikes and inverted loops wherever a feature is narrower than the spacing, and RemoveSelfIntersections only catches proper crossings. ClipperBridge flattens a ShapeProfile into one region (perimeter positive, cutouts negative) and inflates it in a single Clipper pass with round joins, so narrow features collapse and holes that close up disappear. Conservative mode circumscribes perimeter arcs, inscribes cutout arcs and pads the inflation by the chord tolerance, so the result never under-estimates the spacing. It replaces the circumscribed-polygon guarantee the BestFit/PartBoundary callers rely on. Clipper stays confined to CPU preparation whose output is cached; the per-pair Collision path remains hand-rolled for GPU portability. LayoutPart's display offset now goes through the bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Clipper2Lib;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="Collision"/>) stay hand-rolled so they can
|
||||||
|
/// be ported to a GPU kernel.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipperBridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
|
||||||
|
/// </summary>
|
||||||
|
public const int Precision = 4;
|
||||||
|
|
||||||
|
private const double MiterLimit = 2.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a polygon to a Clipper path, dropping the closing vertex and
|
||||||
|
/// orienting it positive (CCW) or negative (CW).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a polygon to a Clipper path with an optional offset, dropping the
|
||||||
|
/// closing vertex and keeping the polygon's own winding.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Clipper path to a closed polygon with updated bounds.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets a part region outward by <paramref name="distance"/>: 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 <paramref name="tolerance"/> from the true arc.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="circumscribe">
|
||||||
|
/// When true, the result never under-estimates the offset: arcs are flattened
|
||||||
|
/// outside the true curve and the inflation is padded by the chord tolerance
|
||||||
|
/// and Clipper's rounding.
|
||||||
|
/// </param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offsets an already-flattened region (outers positive, holes negative).
|
||||||
|
/// </summary>
|
||||||
|
public static OffsetRegion Offset(
|
||||||
|
PathsD region,
|
||||||
|
double distance,
|
||||||
|
double tolerance,
|
||||||
|
bool circumscribe = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var delta = distance;
|
||||||
|
|
||||||
|
if (circumscribe)
|
||||||
|
delta += tolerance + 0.5 * System.Math.Pow(10, -Precision);
|
||||||
|
|
||||||
|
var inflated =
|
||||||
|
delta == 0
|
||||||
|
? Union(region)
|
||||||
|
: Clipper.InflatePaths(
|
||||||
|
region,
|
||||||
|
delta,
|
||||||
|
JoinType.Round,
|
||||||
|
EndType.Polygon,
|
||||||
|
MiterLimit,
|
||||||
|
Precision,
|
||||||
|
tolerance
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = new OffsetRegion(new List<Polygon>(), new List<Polygon>());
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var polygon = shape.ToPolygonWithTolerance(tolerance, circumscribe);
|
||||||
|
|
||||||
|
if (polygon.Vertices.Count < 4)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var path = ToPath(polygon, positive);
|
||||||
|
|
||||||
|
if (path.Count >= 3)
|
||||||
|
region.Add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
|
||||||
|
/// outer boundaries (CCW) and holes (CW), as closed polygons.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The outer boundary with the largest area, or null when the region is empty.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class ClipperBridgeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Offset_NotchNarrowerThanTwiceSpacing_ClosesNotch()
|
||||||
|
{
|
||||||
|
// 10x10 square with a 0.3-wide, 3-deep slot down from the top edge.
|
||||||
|
var profile = Profile(
|
||||||
|
Poly(
|
||||||
|
(0, 0),
|
||||||
|
(10, 0),
|
||||||
|
(10, 10),
|
||||||
|
(5.15, 10),
|
||||||
|
(5.15, 7),
|
||||||
|
(4.85, 7),
|
||||||
|
(4.85, 10),
|
||||||
|
(0, 10)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = ClipperBridge.Offset(profile, 0.25, 0.001);
|
||||||
|
|
||||||
|
var outer = Assert.Single(result.Outers);
|
||||||
|
Assert.Empty(result.Holes);
|
||||||
|
|
||||||
|
// The slot fills in. Only a shallow dent is left where the round joins of the
|
||||||
|
// two mouth corners meet: 10 + sqrt(0.25^2 - 0.15^2) = 10.2.
|
||||||
|
Assert.DoesNotContain(outer.Vertices, v => v.X > 4.85 && v.X < 5.15 && v.Y < 10.199);
|
||||||
|
|
||||||
|
var fullSquare = 10 * 10 + 4 * 10 * 0.25 + System.Math.PI * 0.25 * 0.25;
|
||||||
|
Assert.InRange(outer.Area(), fullSquare - 0.01, fullSquare);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offset_HoleSmallerThanTwiceSpacing_DropsHole()
|
||||||
|
{
|
||||||
|
var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), Circle(5, 5, 0.2));
|
||||||
|
|
||||||
|
var result = ClipperBridge.Offset(profile, 0.25, 0.001);
|
||||||
|
|
||||||
|
Assert.Single(result.Outers);
|
||||||
|
Assert.Empty(result.Holes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offset_HoleWithThinNeck_SplitsIntoTwoHoles()
|
||||||
|
{
|
||||||
|
// Two 2x2 pockets joined by a 2-long, 0.3-wide channel.
|
||||||
|
var hole = Poly(
|
||||||
|
(2, 4),
|
||||||
|
(4, 4),
|
||||||
|
(4, 4.85),
|
||||||
|
(6, 4.85),
|
||||||
|
(6, 4),
|
||||||
|
(8, 4),
|
||||||
|
(8, 6),
|
||||||
|
(6, 6),
|
||||||
|
(6, 5.15),
|
||||||
|
(4, 5.15),
|
||||||
|
(4, 6),
|
||||||
|
(2, 6)
|
||||||
|
);
|
||||||
|
var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), hole);
|
||||||
|
|
||||||
|
var result = ClipperBridge.Offset(profile, 0.25, 0.001);
|
||||||
|
|
||||||
|
Assert.Single(result.Outers);
|
||||||
|
Assert.Equal(2, result.Holes.Count);
|
||||||
|
|
||||||
|
// Each pocket shrinks to 1.5x1.5, plus a small lobe toward the channel mouth
|
||||||
|
// where the round joins of the channel corners meet.
|
||||||
|
Assert.All(result.Holes, h => Assert.InRange(h.Area(), 2.25, 2.26));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offset_WindingOfInputDoesNotMatter()
|
||||||
|
{
|
||||||
|
var ccw = Poly((0, 0), (10, 0), (10, 10), (0, 10));
|
||||||
|
var cw = Poly((0, 0), (0, 10), (10, 10), (10, 0));
|
||||||
|
var hole = Circle(5, 5, 2);
|
||||||
|
|
||||||
|
var a = ClipperBridge.Offset(Profile(ccw, hole), 0.25, 0.001);
|
||||||
|
var b = ClipperBridge.Offset(Profile(cw, hole), 0.25, 0.001);
|
||||||
|
|
||||||
|
Assert.Equal(a.Outers.Count, b.Outers.Count);
|
||||||
|
Assert.Equal(a.Holes.Count, b.Holes.Count);
|
||||||
|
Assert.Equal(a.Outers[0].Area(), b.Outers[0].Area(), 6);
|
||||||
|
Assert.Equal(a.Holes[0].Area(), b.Holes[0].Area(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offset_Circumscribe_NeverUnderestimatesDistance()
|
||||||
|
{
|
||||||
|
const double spacing = 0.25;
|
||||||
|
var profile = Profile(Circle(0, 0, 5), Circle(0, 0, 3));
|
||||||
|
|
||||||
|
var result = ClipperBridge.Offset(profile, spacing, 0.05, circumscribe: true);
|
||||||
|
|
||||||
|
var outer = Assert.Single(result.Outers);
|
||||||
|
var hole = Assert.Single(result.Holes);
|
||||||
|
|
||||||
|
for (var i = 0; i < 360; i++)
|
||||||
|
{
|
||||||
|
var a = i * System.Math.PI / 180;
|
||||||
|
var onPerimeter = new Vector(5 * System.Math.Cos(a), 5 * System.Math.Sin(a));
|
||||||
|
var onCutout = new Vector(3 * System.Math.Cos(a), 3 * System.Math.Sin(a));
|
||||||
|
|
||||||
|
Assert.True(outer.ContainsPoint(onPerimeter));
|
||||||
|
Assert.True(
|
||||||
|
outer.ClosestPointTo(onPerimeter).DistanceTo(onPerimeter) >= spacing,
|
||||||
|
$"Perimeter sample at {i} deg is closer than the spacing."
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(hole.ContainsPoint(onCutout));
|
||||||
|
Assert.True(
|
||||||
|
hole.ClosestPointTo(onCutout).DistanceTo(onCutout) >= spacing,
|
||||||
|
$"Cutout sample at {i} deg is closer than the spacing."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offset_PepNotchedPart_HasNoSpikes()
|
||||||
|
{
|
||||||
|
// 1.nest (PEP P260417-06): rounded-square hole, perimeter with 0.0598-wide
|
||||||
|
// notches and 0.015 fillets, all narrower than twice the 0.25 spacing.
|
||||||
|
var program = ReadProgram(PepNotchedPart);
|
||||||
|
var entities = ConvertProgram.ToGeometry(program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var result = ClipperBridge.Offset(new ShapeProfile(entities), 0.25, 0.001);
|
||||||
|
|
||||||
|
var outer = Assert.Single(result.Outers);
|
||||||
|
Assert.Single(result.Holes);
|
||||||
|
|
||||||
|
var verts = outer.Vertices;
|
||||||
|
var n = verts.Count - 1;
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 2; j < n; j++)
|
||||||
|
{
|
||||||
|
if (i == 0 && j == n - 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Assert.False(
|
||||||
|
SegmentsCross(verts[i], verts[i + 1], verts[j], verts[j + 1]),
|
||||||
|
$"Edges {i} and {j} cross."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var prev = verts[(i + n - 1) % n];
|
||||||
|
var cur = verts[i];
|
||||||
|
var next = verts[(i + 1) % n];
|
||||||
|
|
||||||
|
var inDir = Unit(cur - prev);
|
||||||
|
var outDir = Unit(next - cur);
|
||||||
|
var dot = inDir.X * outDir.X + inDir.Y * outDir.Y;
|
||||||
|
|
||||||
|
Assert.True(dot > -0.99, $"Spike at vertex {i} ({cur.X:F4}, {cur.Y:F4}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SegmentsCross(Vector a, Vector b, Vector c, Vector d)
|
||||||
|
{
|
||||||
|
static double Cross(Vector o, Vector p, Vector q) =>
|
||||||
|
(p.X - o.X) * (q.Y - o.Y) - (p.Y - o.Y) * (q.X - o.X);
|
||||||
|
|
||||||
|
return Cross(c, d, a) * Cross(c, d, b) < 0 && Cross(a, b, c) * Cross(a, b, d) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector Unit(Vector v)
|
||||||
|
{
|
||||||
|
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||||
|
return new Vector(v.X / len, v.Y / len);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Shape Poly(params (double X, double Y)[] pts)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
|
||||||
|
for (var i = 0; i < pts.Length; i++)
|
||||||
|
{
|
||||||
|
var a = pts[i];
|
||||||
|
var b = pts[(i + 1) % pts.Length];
|
||||||
|
shape.Entities.Add(new Line(a.X, a.Y, b.X, b.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Shape Circle(double x, double y, double r)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Circle(x, y, r));
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShapeProfile Profile(params Shape[] shapes) =>
|
||||||
|
new(shapes.SelectMany(s => s.Entities).ToList());
|
||||||
|
|
||||||
|
private static Program ReadProgram(string gcode)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
|
||||||
|
return new ProgramReader(stream).Read();
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string PepNotchedPart = """
|
||||||
|
G91
|
||||||
|
G00X-8.003411Y12.354904
|
||||||
|
G01X0Y5.03125
|
||||||
|
G03X-2.3125Y2.3125I-2.3125J0
|
||||||
|
G01X-10.0625Y0
|
||||||
|
G03X-2.3125Y-2.3125I0J-2.3125
|
||||||
|
G01X0Y-10.0625
|
||||||
|
G03X2.3125Y-2.3125I2.3125J0
|
||||||
|
G01X10.0625Y0
|
||||||
|
G03X2.3125Y2.3125I0J2.3125
|
||||||
|
G01X0Y5.03125
|
||||||
|
G00X10.200865Y-12.347161
|
||||||
|
G01X-2.182454Y0
|
||||||
|
G03X-0.015Y-0.015I0J-0.015
|
||||||
|
G01X0Y-1.457646
|
||||||
|
G02X-0.015Y-0.015I-0.015J0
|
||||||
|
G01X-30.664322Y0
|
||||||
|
G02X-0.015Y0.015I0J0.015
|
||||||
|
G01X0Y1.1725
|
||||||
|
G01X0.072967Y0.149903
|
||||||
|
G02X0.013487Y0.008435I0.013487J-0.006565
|
||||||
|
G01X0.620707Y0
|
||||||
|
G03X0.015Y0.015I0J0.015
|
||||||
|
G01X0Y0.396469
|
||||||
|
G03X-0.015Y0.015I-0.015J0
|
||||||
|
G01X-0.4225Y0
|
||||||
|
G01X0Y0.095339
|
||||||
|
G01X-2.419615Y0
|
||||||
|
G02X-0.0625Y0.0625I0J0.0625
|
||||||
|
G01X0Y23.809322
|
||||||
|
G02X0.0625Y0.0625I0.0625J0
|
||||||
|
G01X2.405015Y0
|
||||||
|
G03X0.015Y0.015I0J0.015
|
||||||
|
G01X0Y1.837647
|
||||||
|
G02X0.015Y0.015I0.015J0
|
||||||
|
G01X4.005139Y0
|
||||||
|
G02X0.015Y-0.015I0J-0.015
|
||||||
|
G01X0Y-0.3573
|
||||||
|
G03X0.0598Y0I0.03J0
|
||||||
|
G01X0Y0.974934
|
||||||
|
G02X0.0625Y0.0625I0.0625J0
|
||||||
|
G01X25.420246Y0
|
||||||
|
G02X0.0625Y-0.0625I0J-0.0625
|
||||||
|
G01X0Y-0.974934
|
||||||
|
G03X0.0598Y0I0.03J0
|
||||||
|
G01X0Y0.3573
|
||||||
|
G02X0.015Y0.015I0.015J0
|
||||||
|
G01X0.679276Y0
|
||||||
|
G02X0.015Y-0.015I0J-0.015
|
||||||
|
G01X0Y-1.457647
|
||||||
|
G03X0.015Y-0.015I0.015J0
|
||||||
|
G01X1.145147Y0
|
||||||
|
G02X0.015Y-0.015I0J-0.015
|
||||||
|
G01X0Y-0.709988
|
||||||
|
G03X0.015Y-0.015I0.015J0
|
||||||
|
G01X0.944807Y0
|
||||||
|
G02X0.0625Y-0.0625I0J-0.0625
|
||||||
|
G01X0Y-23.891834
|
||||||
|
""";
|
||||||
|
}
|
||||||
+6
-50
@@ -3,7 +3,6 @@ using System.Drawing;
|
|||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using Clipper2Lib;
|
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -22,8 +21,6 @@ namespace OpenNest
|
|||||||
private Brush brush;
|
private Brush brush;
|
||||||
private Pen pen;
|
private Pen pen;
|
||||||
|
|
||||||
private const int OffsetPrecision = 4;
|
|
||||||
|
|
||||||
private List<PointF[]> _offsetPolygonPoints;
|
private List<PointF[]> _offsetPolygonPoints;
|
||||||
private double _cachedOffsetSpacing;
|
private double _cachedOffsetSpacing;
|
||||||
private double _cachedOffsetTolerance;
|
private double _cachedOffsetTolerance;
|
||||||
@@ -231,63 +228,22 @@ namespace OpenNest
|
|||||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Inflate the flattened part region (perimeter positive, holes negative) in
|
var offset = ClipperBridge.Offset(profile, spacing, tolerance);
|
||||||
// one Clipper pass. Offsetting entity-by-entity leaves spikes and inverted
|
var result = new List<PointF[]>(offset.Outers.Count + offset.Holes.Count);
|
||||||
// loops wherever a feature is narrower than the spacing; Clipper collapses
|
|
||||||
// those features and drops holes that close up entirely.
|
|
||||||
var paths = new PathsD();
|
|
||||||
AddRegionPath(paths, profile.Perimeter, tolerance, positive: true);
|
|
||||||
|
|
||||||
foreach (var cutout in profile.Cutouts)
|
foreach (var polygon in offset.Outers.Concat(offset.Holes))
|
||||||
AddRegionPath(paths, cutout, tolerance, positive: false);
|
|
||||||
|
|
||||||
var inflated = Clipper.InflatePaths(
|
|
||||||
paths,
|
|
||||||
spacing,
|
|
||||||
JoinType.Round,
|
|
||||||
EndType.Polygon,
|
|
||||||
2.0,
|
|
||||||
OffsetPrecision,
|
|
||||||
tolerance
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = new List<PointF[]>(inflated.Count);
|
|
||||||
|
|
||||||
foreach (var path in inflated)
|
|
||||||
{
|
{
|
||||||
if (path.Count < 3)
|
var pts = new PointF[polygon.Vertices.Count];
|
||||||
continue;
|
|
||||||
|
|
||||||
var pts = new PointF[path.Count + 1];
|
for (var j = 0; j < pts.Length; j++)
|
||||||
|
pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y);
|
||||||
|
|
||||||
for (var j = 0; j < path.Count; j++)
|
|
||||||
pts[j] = new PointF((float)path[j].x, (float)path[j].y);
|
|
||||||
|
|
||||||
pts[path.Count] = pts[0];
|
|
||||||
result.Add(pts);
|
result.Add(pts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddRegionPath(PathsD paths, Shape shape, double tolerance, bool positive)
|
|
||||||
{
|
|
||||||
var polygon = shape.ToPolygonWithTolerance(tolerance);
|
|
||||||
|
|
||||||
if (polygon.Vertices.Count < 3)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var path = new PathD(polygon.Vertices.Count);
|
|
||||||
|
|
||||||
foreach (var v in polygon.Vertices)
|
|
||||||
path.Add(new PointD(v.X, v.Y));
|
|
||||||
|
|
||||||
if (Clipper.IsPositive(path) != positive)
|
|
||||||
path.Reverse();
|
|
||||||
|
|
||||||
paths.Add(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebuildOffsetPath(Matrix matrix)
|
private void RebuildOffsetPath(Matrix matrix)
|
||||||
{
|
{
|
||||||
OffsetPath?.Dispose();
|
OffsetPath?.Dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user