3 Commits
Author SHA1 Message Date
ajandClaude Opus 4.6 3d4204db7b fix: Cincinnati post processor arc feedrate, G89 spacing, pallet exchange, and preamble
- Add radius-based arc feedrate calculation (Variables/Percentages modes)
  with configurable radius ranges (#123/#124/#125 or inline expressions)
- Fix arc distance in SpeedClassifier using actual arc length instead of
  chord length (full circles previously computed as zero)
- Fix G89 P spacing: P now adjacent to filename per CL-707 manual syntax
- Add lead-out feedrate support (#129) and arc lead-in feedrate (#127)
- Fix pallet exchange: StartAndEnd emits M50 in preamble + last sheet only
- Add G121 Smart Rapids emission when UseSmartRapids is enabled
- Add G90 absolute mode to main program preamble alongside G20/G21

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:33:50 -04:00
ajandClaude Opus 4.6 722f758e94 feat: dual-tangent arc fitting and DXF version export
Add ArcFit.FitWithDualTangent to constrain replacement arcs to match
tangent directions at both endpoints, preventing kinks without
introducing gaps. Add DXF year selection to CAD converter export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:16:09 -04:00
ajandClaude Opus 4.6 9b2322abe9 refactor: simplify GeometrySimplifier by removing wrappers and extracting shared helpers
Remove pass-through wrappers (FitWithStartTangent, MaxRadialDeviation), extract
PerpendicularDistance and NormalizeAngle helpers to deduplicate mirror axis math,
convert GetExitDirection to switch expression, and simplify ComputeEndTangent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:05:28 -04:00
14 changed files with 705 additions and 95 deletions
+54
View File
@@ -56,6 +56,60 @@ namespace OpenNest.Geometry
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius)); return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
} }
/// <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.
/// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
List<Vector> points, Vector startTangent, Vector endTangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
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)
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 interiorDev = MaxRadialDeviation(points, cx, cy, r1);
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
}
/// <summary> /// <summary>
/// Computes the maximum radial deviation of interior points from a circle. /// Computes the maximum radial deviation of interior points from a circle.
/// </summary> /// </summary>
+66 -71
View File
@@ -134,13 +134,11 @@ public class GeometrySimplifier
if (midpoints.Count < 4) return MirrorAxisResult.None; if (midpoints.Count < 4) return MirrorAxisResult.None;
// Centroid var centroid = new Vector(
var cx = 0.0; midpoints.Average(p => p.X),
var cy = 0.0; midpoints.Average(p => p.Y));
foreach (var p in midpoints) { cx += p.X; cy += p.Y; } var cx = centroid.X;
cx /= midpoints.Count; var cy = centroid.Y;
cy /= midpoints.Count;
var centroid = new Vector(cx, cy);
// Covariance matrix for PCA // Covariance matrix for PCA
var cxx = 0.0; var cxx = 0.0;
@@ -192,12 +190,25 @@ public class GeometrySimplifier
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None; return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
} }
private static double NormalizeAngle(double angle) =>
angle < 0 ? angle + Angle.TwoPI : angle;
private static Vector Normalize(Vector v) private static Vector Normalize(Vector v)
{ {
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y); var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len); return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
} }
private static double PerpendicularDistance(Vector point, Vector axisPoint, Vector axisDir)
{
var dx = point.X - axisPoint.X;
var dy = point.Y - axisPoint.Y;
var dot = dx * axisDir.X + dy * axisDir.Y;
var px = dx - dot * axisDir.X;
var py = dy - dot * axisDir.Y;
return System.Math.Sqrt(px * px + py * py);
}
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir) private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
{ {
var matchTol = 0.1; var matchTol = 0.1;
@@ -206,14 +217,7 @@ public class GeometrySimplifier
for (var i = 0; i < points.Count; i++) for (var i = 0; i < points.Count; i++)
{ {
var p = points[i]; var p = points[i];
var dist = PerpendicularDistance(p, axisPoint, axisDir);
// Distance from point to axis
var dx = p.X - axisPoint.X;
var dy = p.Y - axisPoint.Y;
var dot = dx * axisDir.X + dy * axisDir.Y;
var perpX = dx - dot * axisDir.X;
var perpY = dy - dot * axisDir.Y;
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
// Points on the axis count as matched // Points on the axis count as matched
if (dist < matchTol) if (dist < matchTol)
@@ -223,14 +227,12 @@ public class GeometrySimplifier
} }
// Reflect across axis and look for partner // Reflect across axis and look for partner
var mx = p.X - 2 * perpX; var reflected = new MirrorAxisResult(axisPoint, axisDir, 0).Reflect(p);
var my = p.Y - 2 * perpY;
for (var j = 0; j < points.Count; j++) for (var j = 0; j < points.Count; j++)
{ {
if (i == j) continue; if (i == j) continue;
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) + var d = reflected.DistanceTo(points[j]);
(points[j].Y - my) * (points[j].Y - my));
if (d < matchTol) if (d < matchTol)
{ {
matched++; matched++;
@@ -259,14 +261,7 @@ public class GeometrySimplifier
var ci = candidates[i]; var ci = candidates[i];
var ciCenter = ci.BoundingBox.Center; var ciCenter = ci.BoundingBox.Center;
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis
// Distance from candidate center to axis
var dx = ciCenter.X - axis.Point.X;
var dy = ciCenter.Y - axis.Point.Y;
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
if (perpDist < 0.1) continue; // on the axis
var mirrorCenter = axis.Reflect(ciCenter); var mirrorCenter = axis.Reflect(ciCenter);
@@ -328,12 +323,8 @@ public class GeometrySimplifier
var mirrorEp = axis.Reflect(ep); var mirrorEp = axis.Reflect(ep);
// Mirroring reverses winding — swap start/end to preserve arc direction // Mirroring reverses winding — swap start/end to preserve arc direction
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X); var mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X); var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
// Normalize to [0, 2pi)
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed); var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
result.Layer = arc.Layer; result.Layer = arc.Layer;
@@ -357,15 +348,16 @@ public class GeometrySimplifier
} }
chainedTangent = ComputeEndTangent(result.Center, result.Points); chainedTangent = ComputeEndTangent(result.Center, result.Points);
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
candidates.Add(new ArcCandidate candidates.Add(new ArcCandidate
{ {
StartIndex = j, StartIndex = j,
EndIndex = result.EndIndex, EndIndex = result.EndIndex,
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]), FittedArc = arc,
MaxDeviation = result.Deviation, MaxDeviation = result.Deviation,
BoundingBox = result.Points.GetBoundingBox(), BoundingBox = result.Points.GetBoundingBox(),
FirstPoint = result.Points[0], FirstPoint = arc.StartPoint(),
LastPoint = result.Points[^1], LastPoint = arc.EndPoint(),
}); });
j = result.EndIndex + 1; j = result.EndIndex + 1;
@@ -386,14 +378,16 @@ public class GeometrySimplifier
? chainedTangent ? chainedTangent
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y); : new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = TryFit(points, startTangent); var endTangent = GetExitDirection(entities[k]);
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
if (!center.IsValid()) return null; if (!center.IsValid()) return null;
// Extend the arc as far as possible // Extend the arc as far as possible
while (k + 1 <= runEnd) while (k + 1 <= runEnd)
{ {
var extPoints = CollectPoints(entities, start, k + 1); var extPoints = CollectPoints(entities, start, k + 1);
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d); var extEndTangent = GetExitDirection(entities[k + 1]);
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
if (!nc.IsValid()) break; if (!nc.IsValid()) break;
k++; k++;
@@ -413,9 +407,23 @@ public class GeometrySimplifier
return new ArcFitResult(center, radius, dev, points, k); return new ArcFitResult(center, radius, dev, points, k);
} }
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent) private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
{ {
var (center, radius, dev) = FitWithStartTangent(points, startTangent); // Try dual-tangent fit first (matches direction at both endpoints)
if (endTangent.IsValid())
{
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
if (dc.IsValid() && dd <= Tolerance)
{
var isRev = SumSignedAngles(dc, points) < 0;
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
if (aDev <= Tolerance)
return (dc, dr, System.Math.Max(dd, aDev));
}
}
// Fall back to start-tangent-only, then mirror axis
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance) if (!center.IsValid() || dev > Tolerance)
(center, radius, dev) = FitMirrorAxis(points); (center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance) if (!center.IsValid() || dev > Tolerance)
@@ -430,16 +438,6 @@ public class GeometrySimplifier
return (center, radius, System.Math.Max(dev, arcDev)); return (center, radius, System.Math.Max(dev, arcDev));
} }
/// <summary>
/// Fits a circular arc constrained to be tangent to the given direction at the
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
/// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent) =>
ArcFit.FitWithStartTangent(points, tangent);
/// <summary> /// <summary>
/// Computes the tangent direction at the last point of a fitted arc, /// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc. /// used to chain tangent continuity to the next arc.
@@ -447,15 +445,10 @@ public class GeometrySimplifier
private static Vector ComputeEndTangent(Vector center, List<Vector> points) private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{ {
var lastPt = points[^1]; var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X; var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y; var ry = lastPt.Y - center.Y;
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
if (totalAngle >= 0) return new Vector(-sign * ry, sign * rx);
return new Vector(-ry, rx);
else
return new Vector(ry, -rx);
} }
/// <summary> /// <summary>
@@ -496,12 +489,12 @@ public class GeometrySimplifier
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord); var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
var dOpt = GoldenSectionMin(dInit - range, dInit + range, var dOpt = GoldenSectionMin(dInit - range, dInit + range,
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny, d => ArcFit.MaxRadialDeviation(points, mx + d * nx, my + d * ny,
System.Math.Sqrt(halfChord * halfChord + d * d))); System.Math.Sqrt(halfChord * halfChord + d * d)));
var center = new Vector(mx + dOpt * nx, my + dOpt * ny); var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt); var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius)); return (center, radius, ArcFit.MaxRadialDeviation(points, center.X, center.Y, radius));
} }
private static double GoldenSectionMin(double low, double high, Func<double, double> eval) private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
@@ -554,20 +547,28 @@ public class GeometrySimplifier
var firstPoint = points[0]; var firstPoint = points[0];
var lastPoint = points[^1]; var lastPoint = points[^1];
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X); var startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X); var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
var isReversed = SumSignedAngles(center, points) < 0; var isReversed = SumSignedAngles(center, points) < 0;
// Normalize to [0, 2pi)
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
var arc = new Arc(center, radius, startAngle, endAngle, isReversed); var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
arc.Layer = sourceEntity.Layer; arc.Layer = sourceEntity.Layer;
arc.Color = sourceEntity.Color; arc.Color = sourceEntity.Color;
return arc; return arc;
} }
/// <summary>
/// Returns the exit direction (tangent at endpoint) of an entity.
/// </summary>
private static Vector GetExitDirection(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.EndAngle), -System.Math.Cos(arc.EndAngle))
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
_ => Vector.Invalid,
};
/// <summary> /// <summary>
/// Sums signed angular change traversing consecutive points around a center. /// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW. /// Positive = CCW, negative = CW.
@@ -587,12 +588,6 @@ public class GeometrySimplifier
return total; return total;
} }
/// <summary>
/// Max deviation of intermediate points (excluding endpoints) from a circle.
/// </summary>
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) =>
ArcFit.MaxRadialDeviation(points, cx, cy, radius);
/// <summary> /// <summary>
/// Measures the maximum distance from sampled points along the fitted arc /// Measures the maximum distance from sampled points along the fitted arc
/// back to the original line segments. This catches cases where points lie /// back to the original line segments. This catches cases where points lie
@@ -70,7 +70,7 @@ public sealed class CincinnatiFeatureWriter
{ {
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal); var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal); var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})"); writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
} }
else else
{ {
@@ -108,7 +108,7 @@ public sealed class CincinnatiFeatureWriter
sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}"); sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}");
// Feedrate — etch always uses process feedrate // Feedrate — etch always uses process feedrate
var feedVar = ctx.IsEtch ? "#148" : GetFeedVariable(linear.Layer); var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
if (feedVar != lastFeedVar) if (feedVar != lastFeedVar)
{ {
sb.Append($" F{feedVar}"); sb.Append($" F{feedVar}");
@@ -138,11 +138,11 @@ public sealed class CincinnatiFeatureWriter
var j = arc.CenterPoint.Y - currentPos.Y; var j = arc.CenterPoint.Y - currentPos.Y;
sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}"); sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}");
// Feedrate — etch always uses process feedrate, cut uses layer-based // Feedrate — etch always uses process feedrate, cut uses layer/radius-based
var radius = currentPos.DistanceTo(arc.CenterPoint);
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint); var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
var feedVar = ctx.IsEtch ? "#148" var feedVar = ctx.IsEtch ? "#148"
: isFullCircle ? "[#148*#128]" : GetArcFeedrate(arc.Layer, radius, isFullCircle);
: GetFeedVariable(arc.Layer);
if (feedVar != lastFeedVar) if (feedVar != lastFeedVar)
{ {
sb.Append($" F{feedVar}"); sb.Append($" F{feedVar}");
@@ -223,16 +223,45 @@ public sealed class CincinnatiFeatureWriter
} }
} }
private static string GetFeedVariable(LayerType layer) private static string GetLinearFeedVariable(LayerType layer)
{ {
return layer switch return layer switch
{ {
LayerType.Leadin => "#126", LayerType.Leadin => "#126",
LayerType.Cut => "#148", LayerType.Leadout => "#129",
_ => "#148" _ => "#148"
}; };
} }
private string GetArcFeedrate(LayerType layer, double radius, bool isFullCircle)
{
if (layer == LayerType.Leadin) return "#127";
if (layer == LayerType.Leadout) return "#129";
if (isFullCircle) return "[#148*#128]";
return GetArcCutFeedrate(radius);
}
private string GetArcCutFeedrate(double radius)
{
if (_config.ArcFeedrate == ArcFeedrateMode.None)
return "#148";
// Find the smallest range that contains this radius
ArcFeedrateRange best = null;
foreach (var range in _config.ArcFeedrateRanges)
{
if (radius <= range.MaxRadius && (best == null || range.MaxRadius < best.MaxRadius))
best = range;
}
if (best == null)
return "#148";
return _config.ArcFeedrate == ArcFeedrateMode.Variables
? $"#{best.VariableNumber}"
: $"[#148*{best.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]";
}
private static bool IsFullCircle(Vector start, Vector end) private static bool IsFullCircle(Vector start, Vector end)
{ {
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y); return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
@@ -269,12 +269,35 @@ namespace OpenNest.Posts.Cincinnati
/// </summary> /// </summary>
public double LeadInArcLine2FeedratePercent { get; set; } = 0.5; public double LeadInArcLine2FeedratePercent { get; set; } = 0.5;
/// <summary>
/// Gets or sets the feedrate percentage for lead-out moves.
/// Default: 0.5 (50%)
/// </summary>
public double LeadOutFeedratePercent { get; set; } = 0.5;
/// <summary> /// <summary>
/// Gets or sets the feedrate multiplier for circular cuts. /// Gets or sets the feedrate multiplier for circular cuts.
/// Default: 0.8 (80%) /// Default: 0.8 (80%)
/// </summary> /// </summary>
public double CircleFeedrateMultiplier { get; set; } = 0.8; public double CircleFeedrateMultiplier { get; set; } = 0.8;
/// <summary>
/// Gets or sets the arc feedrate calculation mode.
/// Default: ArcFeedrateMode.None
/// </summary>
public ArcFeedrateMode ArcFeedrate { get; set; } = ArcFeedrateMode.None;
/// <summary>
/// Gets or sets the radius-based arc feedrate ranges.
/// Ranges are matched from smallest MaxRadius to largest.
/// </summary>
public List<ArcFeedrateRange> ArcFeedrateRanges { get; set; } = new()
{
new() { MaxRadius = 0.125, FeedratePercent = 0.25, VariableNumber = 123 },
new() { MaxRadius = 0.750, FeedratePercent = 0.50, VariableNumber = 124 },
new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 }
};
/// <summary> /// <summary>
/// Gets or sets the variable number for sheet width. /// Gets or sets the variable number for sheet width.
/// Default: 110 /// Default: 110
@@ -301,4 +324,34 @@ namespace OpenNest.Posts.Cincinnati
public string Gas { get; set; } = ""; public string Gas { get; set; } = "";
public string Library { get; set; } = ""; public string Library { get; set; } = "";
} }
/// <summary>
/// Specifies how arc feedrates are calculated based on radius.
/// </summary>
public enum ArcFeedrateMode
{
/// <summary>No radius-based arc feedrate adjustment (only full circles use multiplier).</summary>
None,
/// <summary>Inline percentage expressions: F [#148*pct] based on radius range.</summary>
Percentages,
/// <summary>Radius-range-based variables: F #varNum based on radius range.</summary>
Variables
}
/// <summary>
/// Defines a radius range and its associated feedrate for arc moves.
/// </summary>
public class ArcFeedrateRange
{
/// <summary>Maximum radius for this range (inclusive).</summary>
public double MaxRadius { get; set; }
/// <summary>Feedrate as a fraction of process feedrate (e.g. 0.25 = 25%).</summary>
public double FeedratePercent { get; set; }
/// <summary>Variable number for Variables mode (e.g. 123).</summary>
public int VariableNumber { get; set; }
}
} }
@@ -112,8 +112,9 @@ namespace OpenNest.Posts.Cincinnati
var sheetIndex = i + 1; var sheetIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i; var subNumber = Config.SheetSubprogramStart + i;
var cutLibrary = resolver.ResolveCutLibrary(plate.Material?.Name ?? "", plate.Thickness, gas); var cutLibrary = resolver.ResolveCutLibrary(plate.Material?.Name ?? "", plate.Thickness, gas);
var isLastSheet = i == plates.Count - 1;
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber, sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber,
cutLibrary, etchLibrary, partSubprograms); cutLibrary, etchLibrary, partSubprograms, isLastSheet);
} }
// Part sub-programs (if enabled) // Part sub-programs (if enabled)
@@ -148,6 +149,18 @@ namespace OpenNest.Posts.Cincinnati
vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]"); vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]");
vars.GetOrCreate("LeadInArcLine2Feedrate", 127, $"[#148*{Config.LeadInArcLine2FeedratePercent}]"); vars.GetOrCreate("LeadInArcLine2Feedrate", 127, $"[#148*{Config.LeadInArcLine2FeedratePercent}]");
vars.GetOrCreate("CircleFeedrate", 128, Config.CircleFeedrateMultiplier.ToString("0.#")); vars.GetOrCreate("CircleFeedrate", 128, Config.CircleFeedrateMultiplier.ToString("0.#"));
vars.GetOrCreate("LeadOutFeedrate", 129, $"[#148*{Config.LeadOutFeedratePercent}]");
if (Config.ArcFeedrate == ArcFeedrateMode.Variables)
{
foreach (var range in Config.ArcFeedrateRanges)
{
var name = $"ArcFeedR{range.MaxRadius.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}";
vars.GetOrCreate(name, range.VariableNumber,
$"[#148*{range.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]");
}
}
return vars; return vars;
} }
} }
@@ -37,15 +37,21 @@ public sealed class CincinnatiPreambleWriter
w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM")); w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM"));
w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21" : "G20"); w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21 G90" : "G20 G90");
if (_config.UseSmartRapids)
w.WriteLine("G121 (SMART RAPIDS)");
w.WriteLine("M42"); w.WriteLine("M42");
if (_config.ProcessParameterMode == G89Mode.LibraryFile && !string.IsNullOrEmpty(initialLibrary)) if (_config.ProcessParameterMode == G89Mode.LibraryFile && !string.IsNullOrEmpty(initialLibrary))
w.WriteLine($"G89 P {initialLibrary}"); w.WriteLine($"G89 P{initialLibrary}");
w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)"); w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
if (_config.PalletExchange == PalletMode.StartAndEnd)
w.WriteLine("M50");
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)"); w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
for (var i = 1; i <= sheetCount; i++) for (var i = 1; i <= sheetCount; i++)
@@ -37,7 +37,8 @@ public sealed class CincinnatiSheetWriter
/// </param> /// </param>
public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber,
string cutLibrary, string etchLibrary, string cutLibrary, string etchLibrary,
Dictionary<(int, long), int> partSubprograms = null) Dictionary<(int, long), int> partSubprograms = null,
bool isLastSheet = false)
{ {
if (plate.Parts.Count == 0) if (plate.Parts.Count == 0)
return; return;
@@ -64,12 +65,12 @@ public sealed class CincinnatiSheetWriter
w.WriteLine("N10000"); w.WriteLine("N10000");
w.WriteLine("G92 X#5021 Y#5022"); w.WriteLine("G92 X#5021 Y#5022");
if (!string.IsNullOrEmpty(cutLibrary)) if (!string.IsNullOrEmpty(cutLibrary))
w.WriteLine($"G89 P {cutLibrary}"); w.WriteLine($"G89 P{cutLibrary}");
w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)"); w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)");
w.WriteLine("G90"); w.WriteLine("G90");
w.WriteLine("M47"); w.WriteLine("M47");
if (!string.IsNullOrEmpty(cutLibrary)) if (!string.IsNullOrEmpty(cutLibrary))
w.WriteLine($"G89 P {cutLibrary}"); w.WriteLine($"G89 P{cutLibrary}");
w.WriteLine("GOTO1( Goto Feature )"); w.WriteLine("GOTO1( Goto Feature )");
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last // 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
@@ -94,7 +95,9 @@ public sealed class CincinnatiSheetWriter
// 5. Footer // 5. Footer
w.WriteLine("M42"); w.WriteLine("M42");
w.WriteLine("G0 X0 Y0"); w.WriteLine("G0 X0 Y0");
if (_config.PalletExchange != PalletMode.None) var emitM50 = _config.PalletExchange == PalletMode.EndOfSheet
|| (_config.PalletExchange == PalletMode.StartAndEnd && isLastSheet);
if (emitM50)
w.WriteLine($"N{sheetIndex + 1} M50"); w.WriteLine($"N{sheetIndex + 1} M50");
w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})"); w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})");
} }
+39 -1
View File
@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Posts.Cincinnati; namespace OpenNest.Posts.Cincinnati;
@@ -105,11 +106,48 @@ public static class FeatureUtils
} }
else if (code is ArcMove arc) else if (code is ArcMove arc)
{ {
distance += currentPos.DistanceTo(arc.EndPoint); distance += ComputeArcLength(currentPos, arc);
currentPos = arc.EndPoint; currentPos = arc.EndPoint;
} }
} }
return distance; return distance;
} }
/// <summary>
/// Computes the arc length from the current position through an arc move.
/// Uses radius * sweep angle instead of chord length.
/// </summary>
public static double ComputeArcLength(Vector startPos, ArcMove arc)
{
var radius = startPos.DistanceTo(arc.CenterPoint);
if (radius < Tolerance.Epsilon)
return 0.0;
// Full circle: start ≈ end
if (Tolerance.IsEqualTo(startPos.X, arc.EndPoint.X)
&& Tolerance.IsEqualTo(startPos.Y, arc.EndPoint.Y))
return 2.0 * System.Math.PI * radius;
var startAngle = System.Math.Atan2(
startPos.Y - arc.CenterPoint.Y,
startPos.X - arc.CenterPoint.X);
var endAngle = System.Math.Atan2(
arc.EndPoint.Y - arc.CenterPoint.Y,
arc.EndPoint.X - arc.CenterPoint.X);
double sweep;
if (arc.Rotation == RotationType.CW)
{
sweep = startAngle - endAngle;
if (sweep <= 0) sweep += 2.0 * System.Math.PI;
}
else
{
sweep = endAngle - startAngle;
if (sweep <= 0) sweep += 2.0 * System.Math.PI;
}
return radius * sweep;
}
} }
@@ -297,7 +297,7 @@ public class CincinnatiFeatureWriterTests
ctx.SheetDiagonal = 30.0; ctx.SheetDiagonal = 30.0;
var output = WriteFeature(config, ctx); var output = WriteFeature(config, ctx);
Assert.Contains("G89 P MILD10", output); Assert.Contains("G89 PMILD10", output);
} }
[Fact] [Fact]
@@ -470,6 +470,123 @@ public class CincinnatiFeatureWriterTests
Assert.True(m131Idx < m47Idx, "M131 should come before M47"); Assert.True(m131Idx < m47Idx, "M131 should come before M47");
} }
[Fact]
public void LeadoutFeedrate_UsesVariable129()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
var codes = new List<ICode>
{
new RapidMove(1.0, 1.0),
new LinearMove(2.0, 1.0) { Layer = LayerType.Leadout }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#129", output);
}
[Fact]
public void ArcLeadin_UsesVariable127()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(12.0, 20.0), new Vector(11.0, 20.0), RotationType.CCW) { Layer = LayerType.Leadin }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#127", output);
}
[Fact]
public void ArcFeedrate_VariablesMode_UsesRadiusRangeVariable()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
config.ArcFeedrate = ArcFeedrateMode.Variables;
// Arc with radius 0.5 (center at 10.5, 20; start at 10, 20) → R=0.5 → #124 (R ≤ 0.750)
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(11.0, 20.0), new Vector(10.5, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#124", output);
}
[Fact]
public void ArcFeedrate_PercentagesMode_UsesInlineExpression()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
config.ArcFeedrate = ArcFeedrateMode.Percentages;
// Arc with radius 0.1 (≤ 0.125) → 25%
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(10.2, 20.0), new Vector(10.1, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F[#148*0.25]", output);
}
[Fact]
public void ArcFeedrate_LargeRadius_UsesProcessFeedrate()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
config.ArcFeedrate = ArcFeedrateMode.Variables;
// Arc with radius 10 (> 4.500) → falls through to #148
var codes = new List<ICode>
{
new RapidMove(0.0, 0.0),
new ArcMove(new Vector(20.0, 0.0), new Vector(10.0, 0.0), RotationType.CCW) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#148", output);
}
[Fact]
public void ArcFeedrate_NoneMode_UsesProcessFeedrateForNonCircle()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
config.ArcFeedrate = ArcFeedrateMode.None;
// Small radius arc but mode is None → process feedrate
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(10.2, 20.0), new Vector(10.1, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#148", output);
}
[Fact]
public void G89_PAdjacentToFilename()
{
var config = DefaultConfig();
var ctx = SimpleContext();
ctx.LibraryFile = "MS135N2.lib";
var output = WriteFeature(config, ctx);
// P must be directly adjacent to filename, no space
Assert.Contains("G89 PMS135N2.lib", output);
Assert.DoesNotContain("G89 P MS135N2.lib", output);
}
private static int CountOccurrences(string text, string pattern) private static int CountOccurrences(string text, string pattern)
{ {
var count = 0; var count = 0;
@@ -43,6 +43,74 @@ public class CincinnatiPostProcessorTests
Assert.Contains("M99", output); Assert.Contains("M99", output);
} }
[Fact]
public void Post_EmitsLeadOutVariable()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
Assert.Contains("#129=", output);
}
[Fact]
public void Post_WithArcFeedrateVariables_EmitsRangeVariables()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
ArcFeedrate = ArcFeedrateMode.Variables
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
Assert.Contains("#123=", output);
Assert.Contains("#124=", output);
Assert.Contains("#125=", output);
}
[Fact]
public void Post_WithArcFeedrateNone_OmitsRangeVariables()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
ArcFeedrate = ArcFeedrateMode.None
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
Assert.DoesNotContain("#123=", output);
Assert.DoesNotContain("#124=", output);
Assert.DoesNotContain("#125=", output);
}
[Fact]
public void Post_EmitsG90InPreamble()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
Assert.Contains("G20 G90", output);
}
[Fact] [Fact]
public void Post_ImplementsIPostProcessor() public void Post_ImplementsIPostProcessor()
{ {
@@ -24,9 +24,9 @@ public class CincinnatiPreambleWriterTests
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains("( NEST TestNest )", output); Assert.Contains("( NEST TestNest )", output);
Assert.Contains("( CONFIGURATION - CL940 )", output); Assert.Contains("( CONFIGURATION - CL940 )", output);
Assert.Contains("G20", output); Assert.Contains("G20 G90", output);
Assert.Contains("M42", output); Assert.Contains("M42", output);
Assert.Contains("G89 P MS135N2PANEL.lib", output); Assert.Contains("G89 PMS135N2PANEL.lib", output);
Assert.Contains("M98 P100 (Variable Declaration)", output); Assert.Contains("M98 P100 (Variable Declaration)", output);
Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output); Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output);
Assert.Contains("N1 M98 P101 (SHEET 1)", output); Assert.Contains("N1 M98 P101 (SHEET 1)", output);
@@ -44,7 +44,72 @@ public class CincinnatiPreambleWriterTests
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.Contains("G21", sb.ToString()); Assert.Contains("G21 G90", sb.ToString());
}
[Fact]
public void WriteMainProgram_EmitsG90WithUnits()
{
var config = new CincinnatiPostConfig { PostedUnits = Units.Inches };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.Contains("G20 G90", sb.ToString());
}
[Fact]
public void WriteMainProgram_EmitsG121_WhenSmartRapidsEnabled()
{
var config = new CincinnatiPostConfig { UseSmartRapids = true };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.Contains("G121 (SMART RAPIDS)", sb.ToString());
}
[Fact]
public void WriteMainProgram_OmitsG121_WhenSmartRapidsDisabled()
{
var config = new CincinnatiPostConfig { UseSmartRapids = false };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.DoesNotContain("G121", sb.ToString());
}
[Fact]
public void WriteMainProgram_EmitsM50_WhenStartAndEnd()
{
var config = new CincinnatiPostConfig { PalletExchange = PalletMode.StartAndEnd };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.Contains("M50", sb.ToString());
}
[Fact]
public void WriteMainProgram_OmitsM50_WhenEndOfSheet()
{
var config = new CincinnatiPostConfig { PalletExchange = PalletMode.EndOfSheet };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, "");
Assert.DoesNotContain("M50", sb.ToString());
} }
[Fact] [Fact]
@@ -32,7 +32,7 @@ public class CincinnatiSheetWriterTests
Assert.Contains("#110=", output); Assert.Contains("#110=", output);
Assert.Contains("#111=", output); Assert.Contains("#111=", output);
Assert.Contains("G92 X#5021 Y#5022", output); Assert.Contains("G92 X#5021 Y#5022", output);
Assert.Contains("G89 P MS135N2PANEL.lib", output); Assert.Contains("G89 PMS135N2PANEL.lib", output);
Assert.Contains("M99", output); Assert.Contains("M99", output);
} }
@@ -137,9 +137,110 @@ public class CincinnatiSheetWriterTests
Assert.True(g85Idx < g84Idx, "G85 (etch) should come before G84 (cut)"); Assert.True(g85Idx < g84Idx, "G85 (etch) should come before G84 (cut)");
// Etch uses etch library // Etch uses etch library
Assert.Contains("G89 P EtchN2.lib", output); Assert.Contains("G89 PEtchN2.lib", output);
// Cut uses cut library // Cut uses cut library
Assert.Contains("G89 P MS250O2.lib", output); Assert.Contains("G89 PMS250O2.lib", output);
}
[Fact]
public void WriteSheet_StartAndEnd_NoM50OnNonLastSheet()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.StartAndEnd,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false);
var output = sb.ToString();
Assert.DoesNotContain("M50", output);
}
[Fact]
public void WriteSheet_StartAndEnd_M50OnLastSheet()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.StartAndEnd,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: true);
var output = sb.ToString();
Assert.Contains("M50", output);
}
[Fact]
public void WriteSheet_EndOfSheet_AlwaysEmitsM50()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.EndOfSheet,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false);
var output = sb.ToString();
Assert.Contains("M50", output);
}
[Fact]
public void ComputeArcLength_FullCircle_Returns2PiR()
{
var start = new Vector(10.0, 20.0);
var arc = new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW);
var length = FeatureUtils.ComputeArcLength(start, arc);
// Radius = 5, full circle = 2 * PI * 5 ≈ 31.416
Assert.Equal(2.0 * System.Math.PI * 5.0, length, 4);
}
[Fact]
public void ComputeArcLength_Semicircle_ReturnsPiR()
{
// Semicircle from (0,0) to (10,0) with center at (5,0), CCW → goes through (5,5)
var start = new Vector(0.0, 0.0);
var arc = new ArcMove(new Vector(10.0, 0.0), new Vector(5.0, 0.0), RotationType.CCW);
var length = FeatureUtils.ComputeArcLength(start, arc);
// Radius = 5, semicircle = PI * 5 ≈ 15.708
Assert.Equal(System.Math.PI * 5.0, length, 4);
}
[Fact]
public void ComputeCutDistance_WithArcs_UsesArcLengthNotChord()
{
// Full circle: chord = 0 but arc length = 2πr
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var distance = FeatureUtils.ComputeCutDistance(codes);
// Full circle with R=5 → 2πr ≈ 31.416
Assert.True(distance > 30.0, $"Expected arc length > 30 but got {distance}");
} }
[Fact] [Fact]
+51
View File
@@ -1,4 +1,7 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO;
using System.IO;
using System.Linq;
using Xunit; using Xunit;
namespace OpenNest.Tests; namespace OpenNest.Tests;
@@ -127,4 +130,52 @@ public class GeometrySimplifierTests
// Index 6 is the fitted arc replacing the second run // Index 6 is the fitted arc replacing the second run
Assert.IsType<Arc>(result.Entities[6]); Assert.IsType<Arc>(result.Entities[6]);
} }
[Fact]
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
{
var path = @"C:\Users\aisaacs\Desktop\Sullys Q29 DXFs\SULLYS-031 Dyna Pan.dxf";
if (!File.Exists(path))
return; // skip if file not available
var importer = new DxfImporter();
var result = importer.Import(path);
var shapes = ShapeBuilder.GetShapes(result.Entities);
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
foreach (var shape in shapes)
{
var candidates = simplifier.Analyze(shape);
if (candidates.Count == 0) continue;
var simplified = simplifier.Apply(shape, candidates);
// Check for gaps between consecutive entities
for (var i = 0; i < simplified.Entities.Count - 1; i++)
{
var current = simplified.Entities[i];
var next = simplified.Entities[i + 1];
var currentEnd = current switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => Vector.Invalid
};
var nextStart = next switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => Vector.Invalid
};
if (!currentEnd.IsValid() || !nextStart.IsValid()) continue;
var gap = currentEnd.DistanceTo(nextStart);
Assert.True(gap < 0.005,
$"Gap of {gap:F4} between entities {i} ({current.GetType().Name}) and {i + 1} ({next.GetType().Name})");
}
}
}
} }
+19 -2
View File
@@ -494,13 +494,30 @@ namespace OpenNest.Forms
using var dlg = new SaveFileDialog using var dlg = new SaveFileDialog
{ {
Filter = "DXF Files|*.dxf", Filter = "DXF 2018 (*.dxf)|*.dxf|" +
"DXF 2013 (*.dxf)|*.dxf|" +
"DXF 2010 (*.dxf)|*.dxf|" +
"DXF 2007 (*.dxf)|*.dxf|" +
"DXF 2004 (*.dxf)|*.dxf|" +
"DXF 2000 (*.dxf)|*.dxf|" +
"DXF R14 (*.dxf)|*.dxf",
FileName = Path.ChangeExtension(item.Name, ".dxf"), FileName = Path.ChangeExtension(item.Name, ".dxf"),
}; };
if (dlg.ShowDialog() != DialogResult.OK) return; if (dlg.ShowDialog() != DialogResult.OK) return;
var doc = new ACadSharp.CadDocument(); var version = dlg.FilterIndex switch
{
2 => ACadSharp.ACadVersion.AC1027,
3 => ACadSharp.ACadVersion.AC1024,
4 => ACadSharp.ACadVersion.AC1021,
5 => ACadSharp.ACadVersion.AC1018,
6 => ACadSharp.ACadVersion.AC1015,
7 => ACadSharp.ACadVersion.AC1014,
_ => ACadSharp.ACadVersion.AC1032,
};
var doc = new ACadSharp.CadDocument(version);
foreach (var entity in item.Entities) foreach (var entity in item.Entities)
{ {
switch (entity) switch (entity)