fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS
GeometrySimplifier/ArcFit now fit arcs that pass exactly through run endpoints while balancing tangency error between trusted and estimated directions, fixing arcs that previously bulged or broke tangent continuity at fillet/compound-curve junctions. GravographIS post processor gains per-layer (engrave/cut) tool passes via a new GravographISPostConfig, so ENGRAVE/ETCH-tagged geometry runs as a separate scribe pass with its own feed/depth and an operator pause before the cut pass (spring-floated spindle needs a tool swap). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.LineTo(endpt);
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
}
|
||||
|
||||
return lastpt;
|
||||
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
}
|
||||
|
||||
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||
private static LayerType ClassifyLayer(Entity geo)
|
||||
{
|
||||
var name = geo.Layer?.Name;
|
||||
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Scribe;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// Fits a circular arc that passes exactly through both the first and last points
|
||||
/// while matching the given endpoint tangents as closely as possible. For any
|
||||
/// circle through two points, the tangents at those points make equal mirrored
|
||||
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||
/// two requested ones — when the requested tangents are consistent with a single
|
||||
/// circular arc, both are matched exactly.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
var ux = dx / chordLen;
|
||||
var uy = dy / chordLen;
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||
var theta = (theta1 + theta2) / 2;
|
||||
|
||||
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||
var d = -halfChord / System.Math.Tan(theta);
|
||||
|
||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double SignedAngle(double ux, double uy, Vector to)
|
||||
{
|
||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||
if (len < 1e-10) return 0;
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -374,11 +374,8 @@ public class GeometrySimplifier
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||
var endTangent = EstimateEndTangent(entities, k, points);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
@@ -386,8 +383,10 @@ public class GeometrySimplifier
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (extPoints.Count < 3) break;
|
||||
|
||||
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
@@ -407,37 +406,172 @@ public class GeometrySimplifier
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
private (Vector center, double radius, double deviation) TryFit(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
return (Vector.Invalid, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||
/// </summary>
|
||||
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
if (start.Trusted && !end.Trusted)
|
||||
{
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
else if (end.Trusted && !start.Trusted)
|
||||
{
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
|
||||
yield return FitMirrorAxis(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||
/// by running the start-tangent fit on the reversed point sequence.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||
List<Vector> points, Vector endTangent)
|
||||
{
|
||||
var reversed = new List<Vector>(points);
|
||||
reversed.Reverse();
|
||||
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||
/// vertices and only guide the fit.
|
||||
/// </summary>
|
||||
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||
|
||||
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||
private const double NeighborEdgeFactor = 3.0;
|
||||
|
||||
private static TangentEstimate EstimateStartTangent(
|
||||
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
|
||||
{
|
||||
if (chainedTangent.IsValid())
|
||||
return new TangentEstimate(chainedTangent, true);
|
||||
|
||||
if (entities[start] is Arc startArc)
|
||||
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||
|
||||
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||
if (start > 0)
|
||||
{
|
||||
var prev = entities[start - 1];
|
||||
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
|
||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
if (prev is Arc)
|
||||
return new TangentEstimate(GetExitDirection(prev), true);
|
||||
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
|
||||
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
|
||||
{
|
||||
if (entities[k] is Arc endArc)
|
||||
return new TangentEstimate(GetExitDirection(endArc), true);
|
||||
|
||||
var lastChordLen = points[^1].DistanceTo(points[^2]);
|
||||
if (k + 1 < entities.Count)
|
||||
{
|
||||
var next = entities[k + 1];
|
||||
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
|
||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||
{
|
||||
if (next is Arc nextArc)
|
||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
|
||||
return new TangentEstimate(GetExitDirection(nextLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the curve tangent at a polyline vertex from the circle through it and
|
||||
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
|
||||
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
|
||||
/// Falls back to the travel direction when the three points are collinear.
|
||||
/// </summary>
|
||||
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
|
||||
{
|
||||
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
|
||||
if (System.Math.Abs(d) < 1e-14)
|
||||
return travel;
|
||||
|
||||
var sqA = at.X * at.X + at.Y * at.Y;
|
||||
var sqB = b.X * b.X + b.Y * b.Y;
|
||||
var sqC = c.X * c.X + c.Y * c.Y;
|
||||
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
|
||||
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
|
||||
|
||||
var tangent = new Vector(-(at.Y - cy), at.X - cx);
|
||||
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
|
||||
tangent = new Vector(-tangent.X, -tangent.Y);
|
||||
return tangent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entry direction (tangent at start point) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetEntryDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
|
||||
Reference in New Issue
Block a user