feat(posts): add Cincinnati CI Fiber (TF5200) post-processor

New post-processor plugin OpenNest.Posts.CincinnatiCIFiber for the CI
Fiber laser family (nLight CLX / Beckhoff TF5200 / Precitec ProCutter,
e.g. the CI Fiber 4020 8kW). Named by machine family, not table size.

Emits the machine program contract of the Cincinnati-supplied sample NC
(12992-4SS_NEST.nc): V.E.* header, restart jump, per-part V.E.R4 blocks,
per-contour N labels with V.E.R3, skippable /L macro lines (L0/L2+G41
interior, L4+G42 exterior, L6 cut-on, ZHSOFF cut-end), G162 incremental
arc I/J, trimmed 3-decimal spaceless coordinates, CRLF, M50/M30/%.

Contour classification (interior vs exterior) derives from the material
side of the closed cut path, not hardcoded winding. SubProgramCall holes
are flattened to sheet coordinates (rotation-safe). Arc lead-ins are
rejected per TF5200 13.2.4.1 (first motion block after G41/G42 selection
must be linear). Table envelope (default 160.25 x 81.25 in) validated.

Tests: structure golden on a square-with-hole nest, rotated-hole flatten,
coordinate format, arc-lead-in rejection, table validation, suppressed/
scribe skipping, plus a SkippableFact regression against the real sample
NC (109 parts, 2071 contours, L2=1962, L4=109, perimeter vertices match
within 0.001). Fixtures configure through OpenNest.Tests/test-config.json
and the regression skips when absent.
This commit is contained in:
aj
2026-09-27 04:10:22 -04:00
parent c825f40213
commit 82feb78b0f
14 changed files with 1540 additions and 1 deletions
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// One contour (pierce + lead-in + cut path) reduced to sheet-absolute
/// moves, ready to emit as a TF5200 N-label block.
/// </summary>
public sealed class CIFiberContour
{
/// <summary>Pierce point (sheet-absolute) — rapid target.</summary>
public Vector Pierce { get; set; }
/// <summary>
/// Lead-in move to the contour start (sheet-absolute endpoint), or
/// null when the contour begins cutting immediately at the pierce.
/// Per TF5200 §13.2.4.1 the first motion block after G41/G42 selection
/// must be LINEAR, so callers must reject contours whose lead-in is an
/// arc unless the config tolerates it.
/// </summary>
public Motion LeadIn { get; set; }
/// <summary>
/// Additional lead-in layer moves after the first (e.g. the arc that
/// follows CleanHoleLeadIn's line). Emitted before the cut layer turns
/// on, like the sample's lead block.
/// </summary>
public List<Motion> LeadInExtra { get; } = new();
/// <summary>Cutting moves (sheet-absolute) executed after the cut layer turns on.</summary>
public List<Motion> Cuts { get; } = new();
/// <summary>True when the contour is an external perimeter (exterior).</summary>
public bool IsExterior { get; set; }
}
/// <summary>
/// Extracts cut contours for one part in sheet coordinates.
/// Circular holes arrive as <see cref="SubProgramCall"/>s in an incremental
/// hole-local program; they are flattened here to sheet-absolute moves the
/// same way <see cref="NestPolylineExtractor"/> walks them for HPGL output.
/// Moves with <see cref="Motion.Suppressed"/> are skipped.
/// </summary>
public static class CIFiberContourExtractor
{
public static List<CIFiberContour> Extract(Part part)
{
if (part == null)
throw new ArgumentNullException(nameof(part));
var contours = new List<CIFiberContour>();
Flatten(part.Program, part.Location, Vector.Zero, contours);
return contours;
}
/// <summary>
/// Walk one program whose coordinates are relative to (partLocation +
/// callOffset). Sub-program calls recurse with the extra offset.
/// A contour starts at each rapid move; moves before the first rapid
/// form a contour whose pierce is the last move's endpoint (closed-loop
/// continuation), matching FeatureUtils' synthetic-rapid convention.
/// </summary>
private static void Flatten(
Program program,
Vector partLocation,
Vector subOffset,
List<CIFiberContour> contours
)
{
if (program == null)
return;
var origin = partLocation + subOffset;
// Work on a clone when conversion is needed so caller programs are
// never mutated (the CI sample inlines absolute moves).
var codes = program.Codes;
if (program.Mode == Mode.Incremental)
{
var clone = (Program)program.Clone();
clone.Mode = Mode.Absolute;
codes = clone.Codes;
}
CIFiberContour current = null;
void Flush()
{
if (current != null && (current.LeadIn != null || current.Cuts.Count > 0))
contours.Add(current);
current = null;
}
foreach (var code in codes)
{
if (code is Motion suppressed && suppressed.Suppressed)
continue;
switch (code)
{
case RapidMove rapid:
Flush();
current = new CIFiberContour { Pierce = rapid.EndPoint + origin };
break;
case SubProgramCall call:
// A hole call is its own contour: flatten into a temp
// list, then merge as one contour anchored at the call
// offset.
Flush();
var sub = new List<CIFiberContour>();
Flatten(call.Program, partLocation, subOffset + call.Offset, sub);
contours.AddRange(sub);
break;
case Motion motion:
if (current == null)
{
// Contour with no explicit rapid (program starts
// mid-contour): in absolute mode the implicit
// start position is the frame origin, which is the
// closure point of a closed contour = the pierce.
current = new CIFiberContour { Pierce = origin };
}
var absolute = (Motion)motion.Clone();
absolute.Offset(origin);
var layer = LayerOf(motion);
if (layer == LayerType.Leadin)
{
// First lead-in move opens the lead block; further
// lead-in moves (CleanHole line+arc) extend it.
if (current.LeadIn == null)
current.LeadIn = absolute;
else
current.LeadInExtra.Add(absolute);
}
else
{
// Cut, Leadout and Display all cut: OpenNest tags
// contour moves Display by default (see
// ContourCuttingStrategy.ConvertShapeToMoves),
// only Scribe is reliably a mark. Scribe contours
// are dropped by the writer when SkipScribe is on.
current.Cuts.Add(absolute);
}
break;
}
}
Flush();
}
private static LayerType LayerOf(Motion motion)
{
return motion switch
{
LinearMove l => l.Layer,
ArcMove a => a.Layer,
_ => LayerType.Cut,
};
}
}
}
@@ -0,0 +1,55 @@
using System;
using System.Globalization;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// Formats numeric values for the CI Fiber machine program.
/// Coordinates: up to <see cref="PostedAccuracy"/> decimals with trailing
/// zeros trimmed ("3.706", "5.01", "0"), never "-0" (sample NC format).
/// Header values (V.E.*) use fixed decimals.
/// </summary>
public sealed class CIFiberFormatter
{
private readonly int _accuracy;
private readonly string _trimFormat;
private readonly string _fixedFormat;
public CIFiberFormatter(int accuracy)
{
_accuracy = System.Math.Max(0, accuracy);
_trimFormat = _accuracy == 0 ? "0" : "0." + new string('#', _accuracy);
_fixedFormat = _accuracy == 0 ? "0" : "0." + new string('0', _accuracy);
}
/// <summary>
/// Variable-trim coordinate/number format: rounded to the accuracy,
/// trailing zeros removed, "0" for zero, never "-0".
/// </summary>
public string Coord(double value)
{
var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero);
if (rounded == 0.0)
rounded = 0.0; // collapse -0
return rounded.ToString(_trimFormat, CultureInfo.InvariantCulture);
}
/// <summary>Fixed-decimal format for the V.E.* header variables.</summary>
public string Fixed(double value)
{
var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero);
if (rounded == 0.0)
rounded = 0.0;
return rounded.ToString(_fixedFormat, CultureInfo.InvariantCulture);
}
/// <summary>CRLF line writing helper matching the machine sample exactly.</summary>
public static void Line(System.IO.TextWriter w, string text)
{
w.Write(text);
w.Write("\r\n");
}
}
}
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// Configuration for the Cincinnati CI Fiber (TF5200 / nLight CLX) post-processor.
/// All layer macro names, header variable names and the material code map are
/// editable so the post can serve other CI Fiber tables, not only the 4020.
/// </summary>
public class CIFiberPostConfig
{
/// <summary>Emitted in the "( CONFIGURATION - ... )" header comment.</summary>
[DisplayName("Configuration name")]
public string ConfigurationName { get; set; } = "CI FIBER 8K";
/// <summary>
/// Decimal places for posted coordinates and header values. The sample
/// machine program posts 3 decimals for inch units.
/// </summary>
[DisplayName("Posted accuracy")]
public int PostedAccuracy { get; set; } = 3;
/// <summary>Value for V.E.UNIT when the nest is in inches.</summary>
[DisplayName("Unit code (inches)")]
public int InchUnitCode { get; set; } = 1;
/// <summary>
/// Value for V.E.UNIT when the nest is in millimeters. The TF5200
/// metric unit code is unconfirmed; adjust if the controller rejects 0.
/// </summary>
[DisplayName("Unit code (millimeters)")]
public int MetricUnitCode { get; set; } = 0;
/// <summary>Emit the V.E.SHEET_WEIGHT header variable (value 0).</summary>
[DisplayName("Emit sheet weight")]
public bool EmitSheetWeight { get; set; } = false;
/// <summary>Skip contours whose moves are all LayerType.Scribe (marks).</summary>
[DisplayName("Skip scribe layer")]
public bool SkipScribe { get; set; } = true;
/// <summary>Maximum plate length (X) this configuration drives; 0 disables the check.</summary>
[DisplayName("Maximum table X")]
public double MaxTableX { get; set; } = 160.25;
/// <summary>Maximum plate width (Y) this configuration drives; 0 disables the check.</summary>
[DisplayName("Maximum table Y")]
public double MaxTableY { get; set; } = 81.25;
/// <summary>Comment text after the "( PART:" prefix for each part.</summary>
[DisplayName("Part comment")]
public string PartComment { get; set; } = "";
/// <summary>Skippable global subroutine used to cancel comp / park between features.</summary>
[DisplayName("Layer: cancel (L0)")]
public string LayerCancel { get; set; } = "L0";
/// <summary>Skippable global subroutine for interior lead-in plus G41.</summary>
[DisplayName("Layer: interior leadin (L2)")]
public string LayerInteriorLeadin { get; set; } = "L2";
/// <summary>Skippable global subroutine for exterior lead-in plus G42.</summary>
[DisplayName("Layer: exterior leadin (L4)")]
public string LayerExteriorLeadin { get; set; } = "L4";
/// <summary>Skippable global subroutine that switches the cut layer on.</summary>
[DisplayName("Layer: cut on (L6)")]
public string LayerCut { get; set; } = "L6";
/// <summary>Skippable global subroutine at the end of each contour (head separation / comp off).</summary>
[DisplayName("Layer: cut end (ZHSOFF)")]
public string LayerCutEnd { get; set; } = "ZHSOFF";
/// <summary>External program called once at program start.</summary>
[DisplayName("Start macro")]
public string ProgramStartMacro { get; set; } = "PROGRAMSTART.NC";
/// <summary>External program called after the last sheet.</summary>
[DisplayName("End macro")]
public string ProgramEndMacro { get; set; } = "PROGRAMEND.NC";
/// <summary>Material name (case-insensitive) to machine material code map for V.E.MATERIAL.</summary>
[DisplayName("Material codes")]
public Dictionary<string, string> MaterialCodes { get; set; } =
new(StringComparer.OrdinalIgnoreCase) { ["Mild Steel"] = "MSN" };
/// <summary>Fallback V.E.MATERIAL code when the material name has no mapping.</summary>
[DisplayName("Default material code")]
public string DefaultMaterialCode { get; set; } = "MSN";
public string ResolveMaterialCode(string materialName)
{
if (!string.IsNullOrWhiteSpace(materialName)
&& MaterialCodes != null
&& MaterialCodes.TryGetValue(materialName.Trim(), out var code)
&& !string.IsNullOrWhiteSpace(code))
return code;
return DefaultMaterialCode ?? "MSN";
}
/// <summary>
/// Throws if the plate exceeds the configured table envelope.
/// Zero limits disable the check.
/// </summary>
public void ValidateTableSize(double lengthX, double widthY)
{
if (MaxTableX > 0 && lengthX > MaxTableX + ToleranceEpsilon)
throw new InvalidOperationException(
$"Plate length {lengthX:0.###} exceeds maximum table X {MaxTableX:0.###}."
);
if (MaxTableY > 0 && widthY > MaxTableY + ToleranceEpsilon)
throw new InvalidOperationException(
$"Plate width {widthY:0.###} exceeds maximum table Y {MaxTableY:0.###}."
);
}
private const double ToleranceEpsilon = 1e-6;
}
}
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.CNC;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// Post-processor for the CI Fiber family (e.g. the CI Fiber 4020 8kW with
/// nLight CLX / Beckhoff TF5200 / Precitec ProCutter). Named by machine
/// family, not table size: the same post drives other CI Fiber tables via
/// the configured table envelope. Emits the TF5200 skippable-macro program
/// structure of the machine sample (see <see cref="CIFiberProgramWriter"/>).
/// </summary>
public sealed class CIFiberPostProcessor : IConfigurablePostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
public string Name => "Cincinnati CI Fiber";
public string Author => "OpenNest";
public string Description =>
"CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW";
public CIFiberPostConfig Config { get; }
object IConfigurablePostProcessor.Config => Config;
public CIFiberPostProcessor()
{
var configPath = GetConfigPath();
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
Config =
JsonSerializer.Deserialize<CIFiberPostConfig>(json, JsonOptions)
?? new CIFiberPostConfig();
}
else
{
Config = new CIFiberPostConfig();
SaveConfig();
}
}
public CIFiberPostProcessor(CIFiberPostConfig config)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
}
public void SaveConfig()
{
var configPath = GetConfigPath();
var json = JsonSerializer.Serialize(Config, JsonOptions);
File.WriteAllText(configPath, json);
}
private static string GetConfigPath()
{
var assemblyPath = typeof(CIFiberPostProcessor).Assembly.Location;
var dir = Path.GetDirectoryName(assemblyPath);
var name = Path.GetFileNameWithoutExtension(assemblyPath);
return Path.Combine(dir, name + ".json");
}
public void Post(Nest nest, Stream outputStream)
{
if (nest == null)
throw new ArgumentNullException(nameof(nest));
if (outputStream == null)
throw new ArgumentNullException(nameof(outputStream));
// CRLF file, ASCII (UTF-8 without BOM), matching the machine sample.
var encoding = new UTF8Encoding(false);
using var writer = new StreamWriter(
outputStream,
encoding,
1024,
leaveOpen: true
);
new CIFiberProgramWriter(Config).Write(nest, writer);
writer.Flush();
}
public void Post(Nest nest, string outputFile)
{
using var fs = new FileStream(
outputFile,
FileMode.Create,
FileAccess.Write
);
Post(nest, fs);
}
}
}
@@ -0,0 +1,296 @@
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 )
/// ( August 20, 2026 01:10 PM )
/// ( 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 )
/// ( Part #k ) ( PART:... ) V.E.R4=k
/// N&lt;n&gt;: /L "L0" V.E.R3=&lt;n&gt; G0X..Y..
/// /L "L2" + G41 | /L "L4" + G42
/// G1X..Y.. (linear lead-in)
/// /L "L6" (cut layer on)
/// G1/G2/G3 ...
/// /L "ZHSOFF"
/// ( PART END )
/// /L "L0"
/// L PROGRAMEND.NC
/// M50
/// M30
/// %
/// </code>
///
/// Coordinates are spaceless (G1X3.706Y47.488); 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);
}
public void Write(Nest nest, TextWriter w)
{
if (nest == null)
throw new ArgumentNullException(nameof(nest));
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList();
WriteHeader(nest, 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 < plates.Count; s++)
{
var plate = plates[s];
_config.ValidateTableSize(plate.Size.Length, plate.Size.Width);
CIFiberFormatter.Line(w, $"( Sheet number - {s + 1} )");
contourNumber = WriteSheet(plate, w, contourNumber);
}
WriteTail(w);
}
private void WriteHeader(Nest nest, 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)}");
// Multi-plate nests run one sheet after another in this program;
// the header size describes the first sheet (the machine sample
// carries a single sheet).
var firstPlate = nest.Plates.FirstOrDefault();
var xSize = firstPlate?.Size.Length ?? 0.0;
var ySize = firstPlate?.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}");
CIFiberFormatter.Line(w, "M50");
CIFiberFormatter.Line(w, "M30");
CIFiberFormatter.Line(w, "%");
}
private int WriteSheet(Plate plate, TextWriter w, int contourNumber)
{
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 = ResolvePartComment(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();
foreach (var contour in contours)
{
contourNumber++;
WriteContour(contour, contourNumber, w);
}
CIFiberFormatter.Line(w, "( PART END )");
return contourNumber;
}
private void WriteContour(CIFiberContour contour, int contourNumber, TextWriter w)
{
var isExterior = contour.IsExterior || CIFiberWinding.IsExterior(contour);
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}");
CIFiberFormatter.Line(w, $"G0X{Fmt(contour.Pierce.X)}Y{Fmt(contour.Pierce.Y)}");
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
)
);
CIFiberFormatter.Line(
w,
isExterior ? "G42" : "G41"
);
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));
}
private string ResolvePartComment(Part part)
{
if (!string.IsNullOrWhiteSpace(_config.PartComment))
return _config.PartComment;
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",
};
}
}
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// Classifies a flattened contour as interior or exterior from the
/// material side of its cut path, so the post picks the G41 (inside, kerf
/// left of travel) vs G42 (outside, kerf right of travel) lead-in macro to
/// keep the kerf on the scrap side, matching the machine sample (holes cut
/// CCW + G41, perimeter cut CCW + G42).
///
/// OpenNest contours all keep material on the left of travel (see
/// OffsetSide / ContourCuttingStrategy): the perimeter runs clockwise
/// (negative shoelace area) and holes run counter-clockwise (positive).
/// A contour path that is not closed classifies as interior — a defensive
/// default; open paths should not reach the post.
/// </summary>
public static class CIFiberWinding
{
/// <summary>
/// Signed shoelace area of the contour path (arcs sampled at their
/// arc-mid point). 0 for degenerate paths.
/// </summary>
public static double SignedArea(CIFiberContour contour)
{
var pts = SamplePoints(contour);
if (pts.Count < 3)
return 0.0;
var sum = 0.0;
for (var i = 0; i < pts.Count; i++)
{
var a = pts[i];
var b = pts[(i + 1) % pts.Count];
sum += a.X * b.Y - b.X * a.Y;
}
return sum / 2.0;
}
/// <summary>
/// True when the contour is a closed CW path — an external perimeter
/// under the OpenNest material-left convention.
/// </summary>
public static bool IsExterior(CIFiberContour contour)
{
if (!IsClosed(contour))
return false;
return SignedArea(contour) < 0.0;
}
/// <summary>True when the cut path returns to its start point.</summary>
public static bool IsClosed(CIFiberContour contour, double tolerance = 1e-6)
{
var pts = SamplePoints(contour);
if (pts.Count < 3)
return false;
return pts[0].DistanceTo(pts[^1]) <= tolerance;
}
/// <summary>
/// Ordered points along the path: lead-in endpoint, then each cut move
/// endpoint, with arc interior samples at <=90-degree steps (so full
/// circles produce a well-formed polygon). Consecutive duplicates are
/// dropped so zero-length lead-ins do not skew the sample.
/// </summary>
public static List<Vector> SamplePoints(CIFiberContour contour)
{
var pts = new List<Vector>();
void Add(Vector v)
{
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
pts.Add(v);
}
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
Add(prev);
foreach (var cut in contour.Cuts)
{
if (cut is ArcMove arc)
{
foreach (var mid in ArcSamplePoints(prev, arc))
Add(mid);
}
Add(cut.EndPoint);
prev = cut.EndPoint;
}
return pts;
}
/// <summary>
/// Interior points on the arc from <paramref name="prev"/> to the arc
/// endpoint at no more than ~90-degree sweep intervals. A full circle
/// yields four interior points, giving the shoelace a non-degenerate
/// polygon whose sign is the circle's true winding.
/// </summary>
public static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
{
var points = new List<Vector>();
var center = arc.CenterPoint;
var radius = center.DistanceTo(arc.EndPoint);
if (radius < 1e-12)
return points;
var a0 = System.Math.Atan2(prev.Y - center.Y, prev.X - center.X);
var a1 = System.Math.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
double sweep;
if (arc.Rotation == RotationType.CW)
{
sweep = a0 - a1;
if (sweep <= 0)
sweep += 2.0 * System.Math.PI;
}
else
{
sweep = a1 - a0;
if (sweep <= 0)
sweep += 2.0 * System.Math.PI;
}
// Full circle (start == end) reads as zero sweep above.
if (
sweep < 1e-9
&& System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9
&& System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9
)
sweep = 2.0 * System.Math.PI;
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0)));
for (var k = 1; k <= steps; k++)
{
var angle = a0 + direction * sweep * k / (steps + 1);
points.Add(
new Vector(
center.X + radius * System.Math.Cos(angle),
center.Y + radius * System.Math.Sin(angle)
)
);
}
return points;
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Posts.CincinnatiCIFiber</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenNest.Posts.CincinnatiCIFiber.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\..\OpenNest\bin\$(Configuration)\net8.0-windows\Posts\</PostsDir>
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.CincinnatiCIFiber.json</ConfigJson>
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.CincinnatiCIFiber.json</DeployedConfigJson>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
</Target>
</Project>
@@ -0,0 +1,22 @@
{
"ConfigurationName": "CI FIBER 8K",
"PostedAccuracy": 3,
"InchUnitCode": 1,
"MetricUnitCode": 0,
"EmitSheetWeight": false,
"SkipScribe": true,
"MaxTableX": 160.25,
"MaxTableY": 81.25,
"PartComment": "",
"LayerCancel": "L0",
"LayerInteriorLeadin": "L2",
"LayerExteriorLeadin": "L4",
"LayerCut": "L6",
"LayerCutEnd": "ZHSOFF",
"ProgramStartMacro": "PROGRAMSTART.NC",
"ProgramEndMacro": "PROGRAMEND.NC",
"MaterialCodes": {
"Mild Steel": "MSN"
},
"DefaultMaterialCode": "MSN"
}