Files
OpenNest/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.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

420 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// Emits the CI Fiber (TF5200) machine program: header, restart jump,
/// per-sheet / per-part / per-contour blocks and the tail, matching the
/// structure of the Cincinnati-supplied sample NC:
///
/// <code>
/// ( &lt;nest name&gt; )
/// ( CONFIGURATION - CI FIBER 8K )
/// ( &lt;month d, yyyy hh:mm AM|PM&gt; )
/// ( Material = Mild Steel .060 )
/// V.E.MATERIAL = "MSN" ... V.E.UNIT = 1
/// G90
/// L PROGRAMSTART.NC
/// P3=V.E.R3
/// $GOTO NP3:
/// N0:
/// ( Sheet number - 1 )
/// ... (single-program mode only: /L "L0", pallet change, next sheet)
/// ( Part #k ) ( PART:... ) V.E.R4=k
/// N&lt;n&gt;: /L "L0" V.E.R3=&lt;n&gt; G0X..Y..
/// /L "L2" (hole) | /L "L4" (perimeter), then G41 | G42
/// (whichever puts the kerf in the scrap)
/// G1X..Y.. (linear lead-in)
/// /L "L6" (cut layer on)
/// G1/G2/G3 ...
/// /L "ZHSOFF"
/// ( PART END )
/// /L "L0"
/// L PROGRAMEND.NC
/// M50 (configurable pallet change)
/// M30
/// %
/// </code>
///
/// Coordinates are spaceless (G1X1.234Y5.678); arcs post I/J incremental
/// from the arc start (TF5200 G162 default). The first motion block after
/// the G41/G42 selection is always LINEAR (TF5200 §13.2.4.1): contours
/// without a lead-in move are rejected with a clear error.
/// </summary>
public sealed class CIFiberProgramWriter
{
private readonly CIFiberPostConfig _config;
private readonly CIFiberFormatter _fmt;
public CIFiberProgramWriter(CIFiberPostConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_fmt = new CIFiberFormatter(config.PostedAccuracy);
}
/// <summary>The plates that are posted: every plate with parts, in nest order.</summary>
public static IReadOnlyList<Plate> PostedSheets(Nest nest) =>
nest?.Plates.Where(p => p.Parts.Count > 0).ToList()
?? throw new ArgumentNullException(nameof(nest));
/// <summary>
/// Writes every posted sheet into one program, with a pallet change
/// between sheets and after the last.
/// </summary>
public void Write(Nest nest, TextWriter w)
{
if (nest == null)
throw new ArgumentNullException(nameof(nest));
HeadHeightLock.EnsurePostable(nest, _config.HasHeadHeightLockCodes(), "Cincinnati CI Fiber");
HeadRaise.EnsurePostable(nest, _config.HasHeadRaiseCode(), "Cincinnati CI Fiber");
Write(nest, PostedSheets(nest), 1, w);
}
/// <summary>
/// Writes one sheet as a complete program. Contour labels restart at 1;
/// <paramref name="sheetNumber"/> only labels the sheet comment.
/// </summary>
public void WriteSheet(Nest nest, Plate sheet, int sheetNumber, TextWriter w)
{
if (nest == null)
throw new ArgumentNullException(nameof(nest));
if (sheet == null)
throw new ArgumentNullException(nameof(sheet));
HeadHeightLock.EnsurePostable(nest, _config.HasHeadHeightLockCodes(), "Cincinnati CI Fiber");
HeadRaise.EnsurePostable(nest, _config.HasHeadRaiseCode(), "Cincinnati CI Fiber");
Write(nest, new[] { sheet }, sheetNumber, w);
}
/// <summary>
/// Throws if any sheet exceeds the table, or if a single program would hold
/// sheets of different sizes (its header carries only one size).
/// </summary>
public void Validate(IReadOnlyList<Plate> sheets, bool singleProgram)
{
foreach (var sheet in sheets)
_config.ValidateTableSize(sheet.Size.Length, sheet.Size.Width);
if (!singleProgram || sheets.Count < 2)
return;
var first = sheets[0].Size;
if (sheets.Any(p => !SameSize(p.Size, first)))
throw new InvalidOperationException(
"The sheets are different sizes, but one program has a single sheet "
+ "size in its header. Turn on \"One program per sheet\" to post them."
);
}
private static bool SameSize(Size a, Size b) =>
System.Math.Abs(a.Length - b.Length) < 1e-6 && System.Math.Abs(a.Width - b.Width) < 1e-6;
private void Write(Nest nest, IReadOnlyList<Plate> sheets, int firstSheetNumber, TextWriter w)
{
// Check every sheet before writing anything.
Validate(sheets, singleProgram: true);
WriteHeader(nest, sheets.Count > 0 ? sheets[0] : nest.Plates.FirstOrDefault(), w);
CIFiberFormatter.Line(w, "G90");
CIFiberFormatter.Line(w, $"L {_config.ProgramStartMacro}");
CIFiberFormatter.Line(w, "P3=V.E.R3");
CIFiberFormatter.Line(w, "$GOTO NP3:");
CIFiberFormatter.Line(w, "N0:");
var contourNumber = 0;
for (var s = 0; s < sheets.Count; s++)
{
if (s > 0)
{
// Park, then swap pallets before the next sheet.
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
WritePalletChange(w);
}
CIFiberFormatter.Line(w, $"( Sheet number - {firstSheetNumber + s} )");
contourNumber = WriteSheet(sheets[s], w, contourNumber);
}
WriteTail(w);
}
private void WriteHeader(Nest nest, Plate firstSheet, TextWriter w)
{
CIFiberFormatter.Line(w, $"( {nest.Name ?? ""} )");
CIFiberFormatter.Line(w, $"( CONFIGURATION - {_config.ConfigurationName} )");
CIFiberFormatter.Line(
w,
"( "
+ DateTime.Now.ToString("MMMM d, yyyy hh:mm tt", CultureInfo.InvariantCulture)
+ " )"
);
var materialName = nest.Material?.Name ?? "";
var thickness = _fmt.Fixed(nest.Thickness);
if (thickness.StartsWith("0"))
thickness = thickness.Substring(1); // sample posts ".060"
CIFiberFormatter.Line(w, $"( Material = {materialName} {thickness} )".TrimEnd());
var code = _config.ResolveMaterialCode(materialName);
CIFiberFormatter.Line(w, $"V.E.MATERIAL = \"{code}\"");
CIFiberFormatter.Line(w, $"V.E.THICKNESS = {_fmt.Fixed(nest.Thickness)}");
// A single program holding several sheets still carries only the
// first sheet's size (the machine sample has one sheet).
var xSize = firstSheet?.Size.Length ?? 0.0;
var ySize = firstSheet?.Size.Width ?? 0.0;
CIFiberFormatter.Line(w, $"V.E.X_SIZE = {_fmt.Fixed(xSize)}");
CIFiberFormatter.Line(w, $"V.E.Y_SIZE = {_fmt.Fixed(ySize)}");
if (_config.EmitSheetWeight)
CIFiberFormatter.Line(w, "V.E.SHEET_WEIGHT = 0");
var unit = nest.Units == Units.Millimeters
? _config.MetricUnitCode
: _config.InchUnitCode;
CIFiberFormatter.Line(w, $"V.E.UNIT = {unit}");
}
private void WriteTail(TextWriter w)
{
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
CIFiberFormatter.Line(w, $"L {_config.ProgramEndMacro}");
WritePalletChange(w);
CIFiberFormatter.Line(w, "M30");
CIFiberFormatter.Line(w, "%");
}
private void WritePalletChange(TextWriter w)
{
if (!string.IsNullOrWhiteSpace(_config.PalletChangeCode))
CIFiberFormatter.Line(w, _config.PalletChangeCode.Trim());
}
private int WriteSheet(Plate plate, TextWriter w, int contourNumber)
{
// Plate order is the cut sequence, cut-offs included: the user sets
// where each cut-off runs, so the post must not reorder them.
var partNumber = 0;
foreach (var part in plate.Parts)
{
partNumber++;
contourNumber = WritePart(part, partNumber, w, contourNumber);
}
return contourNumber;
}
private int WritePart(Part part, int partNumber, TextWriter w, int contourNumber)
{
CIFiberFormatter.Line(w, $"( Part #{partNumber} )");
var partComment = PartName(part);
CIFiberFormatter.Line(w, $"( PART:{partComment} )");
CIFiberFormatter.Line(w, $"V.E.R4={partNumber}");
var contours = CIFiberContourExtractor.Extract(part);
if (_config.SkipScribe)
contours = contours.Where(c => !IsScribeContour(c)).ToList();
var isCutOff = part.BaseDrawing.IsCutOff;
var perimeter = CIFiberWinding.PerimeterIndex(contours);
for (var i = 0; i < contours.Count; i++)
{
contourNumber++;
if (isCutOff)
WriteCutOffContour(contours[i], contourNumber, w);
else
WriteContour(contours[i], contours[i].IsExterior || i == perimeter, contourNumber, w);
}
CIFiberFormatter.Line(w, "( PART END )");
return contourNumber;
}
// A rapid marked to pass over parts already cut is made with the head raised.
private void WriteRapidToPierce(CIFiberContour contour, TextWriter w)
{
if (contour.Raised)
CIFiberFormatter.Line(w, _config.HeadRaiseCode);
CIFiberFormatter.Line(w, $"G0X{Fmt(contour.Pierce.X)}Y{Fmt(contour.Pierce.Y)}");
}
private void WriteContour(CIFiberContour contour, bool isExterior, int contourNumber, TextWriter w)
{
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}");
WriteRapidToPierce(contour, w);
if (contour.LeadIn == null)
throw new InvalidOperationException(
$"Contour {contourNumber} has no lead-in move. TF5200 13.2.4.1 "
+ "requires a LINEAR motion block immediately after G41/G42 "
+ "selection; assign lead-ins before posting."
);
if (contour.LeadIn is not LinearMove)
throw new InvalidOperationException(
$"Contour {contourNumber} has an arc lead-in. TF5200 13.2.4.1 "
+ "requires the first motion block after G41/G42 selection "
+ "to be LINEAR."
);
CIFiberFormatter.Line(
w,
SkippableLine(
isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin
)
);
// G41 leaves the product right of travel, G42 left (Cincinnati CNC
// handbook). Pick the side that puts the kerf in the scrap for the
// way this contour actually runs.
CIFiberFormatter.Line(
w,
CIFiberWinding.CompensatesLeft(contour, isExterior) ? "G41" : "G42"
);
var prev = contour.LeadIn.EndPoint;
CIFiberFormatter.Line(
w,
$"G1X{Fmt(contour.LeadIn.EndPoint.X)}Y{Fmt(contour.LeadIn.EndPoint.Y)}"
);
// Additional lead-layer moves (e.g. CleanHole arc) still run
// before the cut layer turns on.
foreach (var extra in contour.LeadInExtra)
{
CIFiberFormatter.Line(w, FormatMotion(extra, prev));
prev = extra.EndPoint;
}
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCut));
foreach (var cut in contour.Cuts)
{
CIFiberFormatter.Line(w, FormatMotion(cut, prev));
prev = cut.EndPoint;
}
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
}
/// <summary>
/// Cut-offs are open straight lines with no lead-in: the line is the
/// beam centreline (CutOffSettings clearance already allows for kerf)
/// and an open line has no inside/outside, so no G41/G42 is selected
/// and the §13.2.4.1 linear-lead-in rule does not apply. The exterior
/// lead layer still runs so the pierce sequence matches a perimeter.
/// </summary>
private void WriteCutOffContour(CIFiberContour contour, int contourNumber, TextWriter w)
{
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}");
WriteRapidToPierce(contour, w);
CIFiberFormatter.Line(w, SkippableLine(_config.LayerExteriorLeadin));
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCut));
var prev = contour.Pierce;
if (contour.LeadIn != null)
{
CIFiberFormatter.Line(w, FormatMotion(contour.LeadIn, prev));
prev = contour.LeadIn.EndPoint;
}
foreach (var motion in contour.LeadInExtra)
{
CIFiberFormatter.Line(w, FormatMotion(motion, prev));
prev = motion.EndPoint;
}
// The overcut past the far sheet edge runs with the head height locked.
for (var i = 0; i <= contour.Cuts.Count; i++)
{
foreach (var (_, locks) in contour.HeadLocks.Where(change => change.Before == i))
CIFiberFormatter.Line(w, locks ? _config.HeadHeightLockCode : _config.HeadHeightUnlockCode);
if (i == contour.Cuts.Count)
break;
CIFiberFormatter.Line(w, FormatMotion(contour.Cuts[i], prev));
prev = contour.Cuts[i].EndPoint;
}
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
}
/// <summary>Source file name without extension, else the drawing name.</summary>
private static string PartName(Part part)
{
var name = part.BaseDrawing?.Name ?? "";
var source = part.BaseDrawing?.Source?.Path;
if (!string.IsNullOrEmpty(source))
return Path.GetFileNameWithoutExtension(source);
return name;
}
private static bool IsScribeContour(CIFiberContour contour)
{
var hasAny = false;
foreach (var cut in contour.Cuts)
{
var layer = LayerOf(cut);
if (layer != LayerType.Scribe)
return false;
hasAny = true;
}
return hasAny;
}
private static LayerType LayerOf(Motion motion) =>
motion switch
{
LinearMove l => l.Layer,
ArcMove a => a.Layer,
_ => LayerType.Cut,
};
private string Fmt(double value) => _fmt.Coord(value);
/// <summary>
/// One motion line: G0/G1/G2/G3 with spaceless X/Y, plus I/J for arcs.
/// I/J are incremental from the arc START (TF5200 G162/basic default,
/// verified against the machine sample): center minus arc start point.
/// </summary>
private string FormatMotion(Motion motion, Vector arcStart)
{
var prefix = MovePrefix(motion);
var line = $"{prefix}X{Fmt(motion.EndPoint.X)}Y{Fmt(motion.EndPoint.Y)}";
if (motion is ArcMove arc)
{
var i = arc.CenterPoint.X - arcStart.X;
var j = arc.CenterPoint.Y - arcStart.Y;
line += $"I{Fmt(i)}J{Fmt(j)}";
}
return line;
}
private static string SkippableLine(string macroName) => $"/L \"{macroName}\"";
private static string MovePrefix(Motion motion) =>
motion switch
{
RapidMove => "G0",
ArcMove a => a.Rotation == RotationType.CW ? "G2" : "G3",
_ => "G1",
};
}
}