fix(geometry): fit arc centers to endpoints; Clipper offset for spacing display

PEP-exported programs carry arc centers that are not equidistant from the
start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
the end radius left its start off the previous move's end, so contours
failed to chain. Project the center onto the chord's perpendicular bisector.

The Draw Offset display offset each entity separately, which left spikes and
inverted loops wherever a feature is narrower than the spacing (1.nest,
P260417-06). Inflate the flattened region with Clipper instead, which
collapses narrow features and drops holes that close up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-23 08:56:15 -04:00
co-authored by Claude Opus 5.5
parent 1c8305e8a1
commit 7964c87eb9
3 changed files with 141 additions and 18 deletions
@@ -121,6 +121,8 @@ namespace OpenNest.Converters
center += curpos;
}
center = FitCenterToEndpoints(center, curpos, endpt);
var startAngle = center.AngleTo(curpos);
var endAngle = center.AngleTo(endpt);
@@ -157,6 +159,35 @@ namespace OpenNest.Converters
curpos = endpt;
}
/// <summary>
/// Programs can carry arc centers that are not quite equidistant from the
/// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
/// the end radius alone then leaves its start point off the previous move's
/// end, which breaks contour chaining. Project the center onto the chord's
/// perpendicular bisector so the arc passes through both endpoints exactly.
/// </summary>
private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end)
{
var startRadius = center.DistanceTo(start);
var endRadius = center.DistanceTo(end);
if (startRadius.IsEqualTo(endRadius))
return center;
var chord = end - start;
var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y;
// Full circle (start == end): no chord to fit against.
if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon)
return center;
var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5);
var normal = new Vector(-chord.Y, chord.X);
var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq;
return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t);
}
private static Layer ConvertLayer(LayerType layer)
{
switch (layer)
@@ -0,0 +1,64 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Converters;
public class ConvertProgramArcTests
{
[Fact]
public void ArcWithCenterNotEquidistant_StartsAtPreviousEndpoint()
{
// PEP-exported notch: I0.03 on a 0.0598 chord puts the center 0.0300 from
// the start but 0.0298 from the end.
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(0, -0.3573));
pgm.Codes.Add(new ArcMove(0.0598, 0, 0.03, 0, RotationType.CCW));
pgm.Codes.Add(new LinearMove(0, 0.3573));
var arc = ConvertProgram.ToGeometry(pgm).OfType<Arc>().Single();
Assert.True(arc.StartPoint().DistanceTo(new Vector(0, -0.3573)) < 1e-9);
Assert.True(arc.EndPoint().DistanceTo(new Vector(0.0598, -0.3573)) < 1e-9);
Assert.Equal(0.0299, arc.Radius, 9);
}
[Fact]
public void ClosedContourWithInconsistentArc_ChainsIntoSinglePerimeter()
{
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(4, 0));
pgm.Codes.Add(new LinearMove(0, 2));
pgm.Codes.Add(new LinearMove(-1.9701, 0));
pgm.Codes.Add(new LinearMove(0, -0.5));
pgm.Codes.Add(new ArcMove(-0.0598, 0, -0.03, 0, RotationType.CW));
pgm.Codes.Add(new LinearMove(0, 0.5));
pgm.Codes.Add(new LinearMove(-1.9701, 0));
pgm.Codes.Add(new LinearMove(0, -2));
var entities = ConvertProgram.ToGeometry(pgm)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var profile = new ShapeProfile(entities);
Assert.Empty(profile.Cutouts);
Assert.Equal(entities.Count, profile.Perimeter.Entities.Count);
}
[Fact]
public void ConsistentArc_IsUnchanged()
{
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new ArcMove(2, 0, 1, 0, RotationType.CCW));
var arc = ConvertProgram.ToGeometry(pgm).OfType<Arc>().Single();
Assert.Equal(1.0, arc.Center.X, 12);
Assert.Equal(0.0, arc.Center.Y, 12);
Assert.Equal(1.0, arc.Radius, 12);
}
}
+46 -18
View File
@@ -3,6 +3,7 @@ using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using Clipper2Lib;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -21,6 +22,8 @@ namespace OpenNest
private Brush brush;
private Pen pen;
private const int OffsetPrecision = 4;
private List<PointF[]> _offsetPolygonPoints;
private double _cachedOffsetSpacing;
private double _cachedOffsetTolerance;
@@ -223,41 +226,66 @@ namespace OpenNest
private List<PointF[]> ComputeOffsetPolygons(double spacing, double tolerance)
{
var result = new List<PointF[]>();
var entities = ConvertProgram.ToGeometry(BasePart.Program);
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
);
AddOffsetPolygon(result, profile.Perimeter.OffsetOutward(spacing), tolerance);
// Inflate the flattened part region (perimeter positive, holes negative) in
// one Clipper pass. Offsetting entity-by-entity leaves spikes and inverted
// 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)
AddOffsetPolygon(result, cutout.OffsetInward(spacing), tolerance);
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)
continue;
var pts = new PointF[path.Count + 1];
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);
}
return result;
}
private static void AddOffsetPolygon(
List<PointF[]> result,
Shape offsetEntity,
double tolerance
)
private static void AddRegionPath(PathsD paths, Shape shape, double tolerance, bool positive)
{
if (offsetEntity == null)
var polygon = shape.ToPolygonWithTolerance(tolerance);
if (polygon.Vertices.Count < 3)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(tolerance);
polygon.RemoveSelfIntersections();
var path = new PathD(polygon.Vertices.Count);
if (polygon.Vertices.Count < 2)
return;
foreach (var v in polygon.Vertices)
path.Add(new PointD(v.X, v.Y));
var pts = new PointF[polygon.Vertices.Count];
if (Clipper.IsPositive(path) != positive)
path.Reverse();
for (var j = 0; j < pts.Length; j++)
pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y);
result.Add(pts);
paths.Add(path);
}
private void RebuildOffsetPath(Matrix matrix)