Compare commits

...

3 Commits

Author SHA1 Message Date
aj 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
aj 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
aj 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));
}
/// <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>
/// Computes the maximum radial deviation of interior points from a circle.
/// </summary>
+66 -71
View File
@@ -134,13 +134,11 @@ public class GeometrySimplifier
if (midpoints.Count < 4) return MirrorAxisResult.None;
// Centroid
var cx = 0.0;
var cy = 0.0;
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
cx /= midpoints.Count;
cy /= midpoints.Count;
var centroid = new Vector(cx, cy);
var centroid = new Vector(
midpoints.Average(p => p.X),
midpoints.Average(p => p.Y));
var cx = centroid.X;
var cy = centroid.Y;
// Covariance matrix for PCA
var cxx = 0.0;
@@ -192,12 +190,25 @@ public class GeometrySimplifier
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)
{
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);
}
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)
{
var matchTol = 0.1;
@@ -206,14 +217,7 @@ public class GeometrySimplifier
for (var i = 0; i < points.Count; i++)
{
var p = points[i];
// 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);
var dist = PerpendicularDistance(p, axisPoint, axisDir);
// Points on the axis count as matched
if (dist < matchTol)
@@ -223,14 +227,12 @@ public class GeometrySimplifier
}
// Reflect across axis and look for partner
var mx = p.X - 2 * perpX;
var my = p.Y - 2 * perpY;
var reflected = new MirrorAxisResult(axisPoint, axisDir, 0).Reflect(p);
for (var j = 0; j < points.Count; j++)
{
if (i == j) continue;
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
(points[j].Y - my) * (points[j].Y - my));
var d = reflected.DistanceTo(points[j]);
if (d < matchTol)
{
matched++;
@@ -259,14 +261,7 @@ public class GeometrySimplifier
var ci = candidates[i];
var ciCenter = ci.BoundingBox.Center;
// 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
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 0.1) continue; // on the axis
var mirrorCenter = axis.Reflect(ciCenter);
@@ -328,12 +323,8 @@ public class GeometrySimplifier
var mirrorEp = axis.Reflect(ep);
// Mirroring reverses winding — swap start/end to preserve arc direction
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
var mirrorEnd = 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 mirrorStart = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
result.Layer = arc.Layer;
@@ -357,15 +348,16 @@ public class GeometrySimplifier
}
chainedTangent = ComputeEndTangent(result.Center, result.Points);
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
candidates.Add(new ArcCandidate
{
StartIndex = j,
EndIndex = result.EndIndex,
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
FittedArc = arc,
MaxDeviation = result.Deviation,
BoundingBox = result.Points.GetBoundingBox(),
FirstPoint = result.Points[0],
LastPoint = result.Points[^1],
FirstPoint = arc.StartPoint(),
LastPoint = arc.EndPoint(),
});
j = result.EndIndex + 1;
@@ -386,14 +378,16 @@ public class GeometrySimplifier
? chainedTangent
: 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;
// Extend the arc as far as possible
while (k + 1 <= runEnd)
{
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;
k++;
@@ -413,9 +407,23 @@ public class GeometrySimplifier
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)
(center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
@@ -430,16 +438,6 @@ public class GeometrySimplifier
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>
/// Computes the tangent direction at the last point of a fitted 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)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
if (totalAngle >= 0)
return new Vector(-ry, rx);
else
return new Vector(ry, -rx);
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
return new Vector(-sign * ry, sign * rx);
}
/// <summary>
@@ -496,12 +489,12 @@ public class GeometrySimplifier
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
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)));
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
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)
@@ -554,20 +547,28 @@ public class GeometrySimplifier
var firstPoint = points[0];
var lastPoint = points[^1];
var startAngle = 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 startAngle = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
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);
arc.Layer = sourceEntity.Layer;
arc.Color = sourceEntity.Color;
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>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
@@ -587,12 +588,6 @@ public class GeometrySimplifier
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>
/// Measures the maximum distance from sampled points along the fitted arc
/// 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 cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})");
writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
}
else
{
@@ -108,7 +108,7 @@ public sealed class CincinnatiFeatureWriter
sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}");
// 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)
{
sb.Append($" F{feedVar}");
@@ -138,11 +138,11 @@ public sealed class CincinnatiFeatureWriter
var j = arc.CenterPoint.Y - currentPos.Y;
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 feedVar = ctx.IsEtch ? "#148"
: isFullCircle ? "[#148*#128]"
: GetFeedVariable(arc.Layer);
: GetArcFeedrate(arc.Layer, radius, isFullCircle);
if (feedVar != lastFeedVar)
{
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
{
LayerType.Leadin => "#126",
LayerType.Cut => "#148",
LayerType.Leadout => "#129",
_ => "#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)
{
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
@@ -269,12 +269,35 @@ namespace OpenNest.Posts.Cincinnati
/// </summary>
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>
/// Gets or sets the feedrate multiplier for circular cuts.
/// Default: 0.8 (80%)
/// </summary>
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>
/// Gets or sets the variable number for sheet width.
/// Default: 110
@@ -301,4 +324,34 @@ namespace OpenNest.Posts.Cincinnati
public string Gas { 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 subNumber = Config.SheetSubprogramStart + i;
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,
cutLibrary, etchLibrary, partSubprograms);
cutLibrary, etchLibrary, partSubprograms, isLastSheet);
}
// Part sub-programs (if enabled)
@@ -148,6 +149,18 @@ namespace OpenNest.Posts.Cincinnati
vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]");
vars.GetOrCreate("LeadInArcLine2Feedrate", 127, $"[#148*{Config.LeadInArcLine2FeedratePercent}]");
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;
}
}
@@ -37,15 +37,21 @@ public sealed class CincinnatiPreambleWriter
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");
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)");
if (_config.PalletExchange == PalletMode.StartAndEnd)
w.WriteLine("M50");
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
for (var i = 1; i <= sheetCount; i++)
@@ -37,7 +37,8 @@ public sealed class CincinnatiSheetWriter
/// </param>
public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber,
string cutLibrary, string etchLibrary,
Dictionary<(int, long), int> partSubprograms = null)
Dictionary<(int, long), int> partSubprograms = null,
bool isLastSheet = false)
{
if (plate.Parts.Count == 0)
return;
@@ -64,12 +65,12 @@ public sealed class CincinnatiSheetWriter
w.WriteLine("N10000");
w.WriteLine("G92 X#5021 Y#5022");
if (!string.IsNullOrEmpty(cutLibrary))
w.WriteLine($"G89 P {cutLibrary}");
w.WriteLine($"G89 P{cutLibrary}");
w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)");
w.WriteLine("G90");
w.WriteLine("M47");
if (!string.IsNullOrEmpty(cutLibrary))
w.WriteLine($"G89 P {cutLibrary}");
w.WriteLine($"G89 P{cutLibrary}");
w.WriteLine("GOTO1( Goto Feature )");
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
@@ -94,7 +95,9 @@ public sealed class CincinnatiSheetWriter
// 5. Footer
w.WriteLine("M42");
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($"M99 (END OF {nestName}.{sheetIndex:D3})");
}
+39 -1
View File
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Posts.Cincinnati;
@@ -105,11 +106,48 @@ public static class FeatureUtils
}
else if (code is ArcMove arc)
{
distance += currentPos.DistanceTo(arc.EndPoint);
distance += ComputeArcLength(currentPos, arc);
currentPos = arc.EndPoint;
}
}
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;
var output = WriteFeature(config, ctx);
Assert.Contains("G89 P MILD10", output);
Assert.Contains("G89 PMILD10", output);
}
[Fact]
@@ -470,6 +470,123 @@ public class CincinnatiFeatureWriterTests
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)
{
var count = 0;
@@ -43,6 +43,74 @@ public class CincinnatiPostProcessorTests
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]
public void Post_ImplementsIPostProcessor()
{
@@ -24,9 +24,9 @@ public class CincinnatiPreambleWriterTests
var output = sb.ToString();
Assert.Contains("( NEST TestNest )", output);
Assert.Contains("( CONFIGURATION - CL940 )", output);
Assert.Contains("G20", output);
Assert.Contains("G20 G90", 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("GOTO1 (GOTO SHEET NUMBER)", output);
Assert.Contains("N1 M98 P101 (SHEET 1)", output);
@@ -44,7 +44,72 @@ public class CincinnatiPreambleWriterTests
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]
@@ -32,7 +32,7 @@ public class CincinnatiSheetWriterTests
Assert.Contains("#110=", output);
Assert.Contains("#111=", 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);
}
@@ -137,9 +137,110 @@ public class CincinnatiSheetWriterTests
Assert.True(g85Idx < g84Idx, "G85 (etch) should come before G84 (cut)");
// Etch uses etch library
Assert.Contains("G89 P EtchN2.lib", output);
Assert.Contains("G89 PEtchN2.lib", output);
// 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]
+51
View File
@@ -1,4 +1,7 @@
using OpenNest.Geometry;
using OpenNest.IO;
using System.IO;
using System.Linq;
using Xunit;
namespace OpenNest.Tests;
@@ -127,4 +130,52 @@ public class GeometrySimplifierTests
// Index 6 is the fitted arc replacing the second run
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
{
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"),
};
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)
{
switch (entity)