Files
OpenNest/Posts/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
T
ajandClaude Opus 5.5 c6b6678a2f feat(planning): raise the head over cut parts as the last resort
When no search finds a route that keeps every rapid off parts already
cut, Plan Cutting used to offer only an unverified safest route needing
explicit acceptance. Cincinnati's own software instead raises the head
for such a rapid. Now the safest-route fallback marks each planner-chosen
rapid that still crosses cut parts with a HEAD RAISE comment, and the
plan is Ready with every raise listed. The strict search never marks a
rapid, and the batch prefers fewer broken checks, then fewer raises,
then less travel, so a plate that can be cut head-down never gets one.

The reader flags marked rapids and the checker sets their crossings
apart, in replay and pre-post verification alike. The Cincinnati CL
post writes M47 before a marked rapid whatever the M47 mode (the
distance-based safety raise is unchanged); the CI Fiber post writes a
new Head raise block and refuses marked nests while it is blank, since
its raise code is unconfirmed. Rapids into locked programs, cut-offs
and hole sub-programs are never marked and keep their findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:37:33 -04:00

400 lines
14 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block.
/// </summary>
public sealed class FeatureContext
{
public List<ICode> Codes { get; set; } = new();
public int FeatureNumber { get; set; }
public string PartName { get; set; } = "";
public bool IsFirstFeatureOfPart { get; set; }
public bool IsLastFeatureOnSheet { get; set; }
/// <summary>True when this feature cuts the part's exterior (selects ExteriorM47).</summary>
public bool IsExteriorFeature { get; set; }
public bool IsEtch { get; set; }
public string LibraryFile { get; set; } = "";
public double CutDistance { get; set; }
public double SheetDiagonal { get; set; }
/// <summary>
/// Rapid travel from this feature's end to the next pierce, used by the safety
/// head raise and <see cref="M47Mode.Auto"/>. Null when unknown.
/// </summary>
public double? RapidDistance { get; set; }
/// <summary>True when the rapid to the next pierce is marked to raise the head (<see cref="HeadRaise"/>).</summary>
public bool NextRapidRaised { get; set; }
/// <summary>
/// Part location on the plate. Added to all output X/Y coordinates
/// so part-relative programs become plate-absolute under G90.
/// </summary>
public Vector PartLocation { get; set; } = Vector.Zero;
/// <summary>
/// Maps (drawingId, variableName) to assigned machine variable numbers.
/// Used to emit #number references instead of literal values for user variables.
/// </summary>
public Dictionary<(int drawingId, string varName), int> UserVariableMapping { get; set; }
/// <summary>
/// The drawing ID for the current part, used to look up user variable mappings.
/// </summary>
public int DrawingId { get; set; }
/// <summary>
/// True if this feature is a cut-off line. Used to substitute plate-edge
/// coordinates with sheet width/length variables.
/// </summary>
public bool IsCutOff { get; set; }
/// <summary>Plate width (Y extent for vertical cutoffs).</summary>
public double PlateWidth { get; set; }
/// <summary>Plate length (X extent for horizontal cutoffs).</summary>
public double PlateLength { get; set; }
}
/// <summary>
/// Emits one Cincinnati-format G-code feature block (one contour) to a TextWriter.
/// Handles rapid positioning, pierce, kerf compensation, anti-dive, feedrate modal
/// suppression, arc I/J conversion (absolute to incremental), and M47 head raise.
/// </summary>
public sealed class CincinnatiFeatureWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CoordinateFormatter _fmt;
private readonly SpeedClassifier _speedClassifier;
public CincinnatiFeatureWriter(CincinnatiPostConfig config)
{
_config = config;
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_speedClassifier = new SpeedClassifier();
}
/// <summary>
/// Writes a complete feature block for the given context.
/// </summary>
public void Write(TextWriter writer, FeatureContext ctx)
{
var currentPos = Vector.Zero;
var lastFeedVar = "";
var kerfEmitted = false;
var offset = ctx.PartLocation;
// Find the pierce point from the first rapid move
var piercePoint = FindPiercePoint(ctx.Codes);
// 1. Rapid to pierce point (with line number if configured)
WriteRapidToPierce(writer, ctx, piercePoint, offset);
// 2. Part name comment on first feature of each part
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}"));
// 3. G89 process params
var lib = ctx.LibraryFile;
if (!string.IsNullOrEmpty(lib))
{
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})");
}
else
{
writer.WriteLine("(WARNING: No library found)");
}
// 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut
writer.WriteLine(ctx.IsEtch ? "G85" : "G84");
// 5. Anti-dive off
if (_config.UseAntiDive)
writer.WriteLine("M130 (ANTI DIVE OFF)");
// Update current position to pierce point
currentPos = piercePoint;
// 6. Lead-in + contour moves with kerf comp and feedrate variables
var headLocked = false;
foreach (var code in ctx.Codes)
{
if (code is RapidMove)
continue; // skip rapids in contour (already handled above)
// A cut-off's overcut past the far sheet edge runs with the head height locked.
if (HeadHeightLock.IsLock(code))
{
writer.WriteLine(_config.HeadHeightLockCode);
headLocked = true;
continue;
}
if (HeadHeightLock.IsUnlock(code))
{
writer.WriteLine(_config.HeadHeightUnlockCode);
headLocked = false;
continue;
}
if (code is LinearMove linear)
{
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (
!ctx.IsEtch
&& !kerfEmitted
&& _config.KerfCompensation == KerfMode.ControllerSide
)
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
var xCoord = FormatCoordWithVars(
linear.EndPoint.X + offset.X,
"X",
linear.VariableRefs,
ctx
);
var yCoord = FormatCoordWithVars(
linear.EndPoint.Y + offset.Y,
"Y",
linear.VariableRefs,
ctx
);
sb.Append($"G1 X{xCoord} Y{yCoord}");
// Feedrate — etch always uses process feedrate
var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
if (feedVar != lastFeedVar)
{
sb.Append($" F{feedVar}");
lastFeedVar = feedVar;
}
writer.WriteLine(sb.ToString());
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (
!ctx.IsEtch
&& !kerfEmitted
&& _config.KerfCompensation == KerfMode.ControllerSide
)
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
// G2 = CW, G3 = CCW
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3";
var xCoord = FormatCoordWithVars(
arc.EndPoint.X + offset.X,
"X",
arc.VariableRefs,
ctx
);
var yCoord = FormatCoordWithVars(
arc.EndPoint.Y + offset.Y,
"Y",
arc.VariableRefs,
ctx
);
sb.Append($"{gCode} X{xCoord} Y{yCoord}");
// Convert absolute center to incremental I/J
var i = arc.CenterPoint.X - currentPos.X;
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/radius-based
var radius = currentPos.DistanceTo(arc.CenterPoint);
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
var feedVar = ctx.IsEtch ? "#148" : GetArcFeedrate(arc.Layer, radius, isFullCircle);
if (feedVar != lastFeedVar)
{
sb.Append($" F{feedVar}");
lastFeedVar = feedVar;
}
writer.WriteLine(sb.ToString());
currentPos = arc.EndPoint;
}
}
// 7. Cancel kerf compensation
if (kerfEmitted)
writer.WriteLine("G40");
// 8. Beam off
writer.WriteLine(_config.UseSpeedGas ? "M135" : "M35");
// 9. Anti-dive on
if (_config.UseAntiDive)
writer.WriteLine("M131 (ANTI DIVE ON)");
// 10. Head raise (unless last feature on sheet). A locked head stays down for the
// rapid back along a cut-off's overcut; the next feature unlocks it.
if (!ctx.IsLastFeatureOnSheet && !headLocked)
WriteM47(writer, ctx);
}
/// <summary>
/// Formats a coordinate value, using a #number variable reference if the motion
/// has a VariableRef for this axis and the variable is mapped (non-inline).
/// For cut-off features, plate-edge coordinates are substituted with
/// the sheet width/length variables.
/// Inline variables fall through to literal formatting.
/// </summary>
private string FormatCoordWithVars(
double value,
string axis,
Dictionary<string, string> variableRefs,
FeatureContext ctx
)
{
// User-defined variable references take priority
if (
variableRefs != null
&& variableRefs.TryGetValue(axis, out var varName)
&& ctx.UserVariableMapping != null
&& ctx.UserVariableMapping.TryGetValue((ctx.DrawingId, varName), out var varNum)
)
{
return $"#{varNum}";
}
// Cut-off plate-edge substitution
if (ctx.IsCutOff)
{
var sheetVar = MatchCutOffSheetVariable(value, axis, ctx);
if (sheetVar != null)
return sheetVar;
}
return _fmt.FormatCoord(value);
}
/// <summary>
/// For cut-off coordinates, checks if the value matches a plate edge dimension
/// and returns the sheet variable reference (e.g., "#110") if so.
/// </summary>
private string MatchCutOffSheetVariable(double value, string axis, FeatureContext ctx)
{
// Vertical cutoffs travel along Y — the Y endpoint at the plate edge = sheet width
// Horizontal cutoffs travel along X — the X endpoint at the plate edge = sheet length
if (axis == "Y" && Tolerance.IsEqualTo(value, ctx.PlateWidth))
return $"#{_config.SheetWidthVariable}";
if (axis == "X" && Tolerance.IsEqualTo(value, ctx.PlateLength))
return $"#{_config.SheetLengthVariable}";
return null;
}
private Vector FindPiercePoint(List<ICode> codes)
{
foreach (var code in codes)
{
if (code is RapidMove rapid)
return rapid.EndPoint;
}
// If no rapid move, use the endpoint of the first motion
foreach (var code in codes)
{
if (code is Motion motion)
return motion.EndPoint;
}
return Vector.Zero;
}
private void WriteRapidToPierce(
TextWriter writer,
FeatureContext ctx,
Vector piercePoint,
Vector offset
)
{
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{ctx.FeatureNumber} ");
var xCoord = FormatCoordWithVars(piercePoint.X + offset.X, "X", null, ctx);
var yCoord = FormatCoordWithVars(piercePoint.Y + offset.Y, "Y", null, ctx);
sb.Append($"G0 X{xCoord} Y{yCoord}");
writer.WriteLine(sb.ToString());
}
private void WriteM47(TextWriter writer, FeatureContext ctx)
{
var line = CincinnatiHeadRaise.Line(_config, ctx.IsExteriorFeature, ctx.RapidDistance, ctx.NextRapidRaised);
if (line != null)
writer.WriteLine(line);
}
private static string GetLinearFeedVariable(LayerType layer)
{
return layer switch
{
LayerType.Leadin => "#126",
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);
}
}