refactor(posts): move post-processor projects under Posts/

Group the Cincinnati and GravographIS plugin projects in a Posts/
folder so new machine posts have one home. Project names, namespaces,
and the runtime Posts/ deploy target are unchanged; only relative
paths in the solution and project references move.
This commit is contained in:
aj
2026-09-26 22:35:23 -04:00
parent 0df2587cf2
commit c825f40213
26 changed files with 12 additions and 12 deletions
@@ -0,0 +1,392 @@
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; }
public bool IsSafetyHeadraise { get; set; }
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>
/// 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
if (_config.ProcessParameterMode == G89Mode.LibraryFile)
{
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
foreach (var code in ctx.Codes)
{
if (code is RapidMove)
continue; // skip rapids in contour (already handled above)
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)
if (!ctx.IsLastFeatureOnSheet)
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)
{
if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
{
writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
return;
}
var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47;
switch (mode)
{
case M47Mode.Always:
writer.WriteLine("M47");
break;
case M47Mode.BlockDelete:
writer.WriteLine("/M47");
break;
case M47Mode.None:
break;
}
}
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);
}
}
@@ -0,0 +1,257 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Writes a Cincinnati-format part sub-program definition.
/// Each sub-program contains the complete cutting sequence for one unique part geometry
/// (drawing + rotation), with coordinates normalized to origin (0,0).
/// Called via M98 from sheet sub-programs.
/// </summary>
public sealed class CincinnatiPartSubprogramWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly CoordinateFormatter _fmt;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(
CincinnatiPostConfig config,
Dictionary<int, int> holeSubprograms = null
)
{
_config = config;
_featureWriter = new CincinnatiFeatureWriter(config);
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_holeSubprograms = holeSubprograms;
}
/// <summary>
/// Writes a complete part sub-program for the given normalized program.
/// The program coordinates must already be normalized to origin (0,0).
/// </summary>
public void Write(
TextWriter w,
Program normalizedProgram,
string drawingName,
int subNumber,
string cutLibrary,
string etchLibrary,
double sheetDiagonal
)
{
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
if (allFeatures.Count == 0)
return;
// Classify and order: etch features first, then cut features
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}"));
for (var i = 0; i < ordered.Count; i++)
{
var (codes, isEtch) = ordered[i];
var isLastFeature = i == ordered.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
continue;
}
var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal,
};
_featureWriter.Write(w, ctx);
}
w.WriteLine($"M99 (END OF {drawingName})");
}
private void WriteHoleSubprogramCall(
TextWriter w,
SubProgramCall call,
int featureIndex,
bool isLastFeature
)
{
var postSubNum =
_holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num
: call.Id;
var featureNumber =
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
/// <summary>
/// If the program has no leading rapid, inserts a synthetic rapid at the
/// last motion endpoint (the contour return point). This ensures the feature
/// writer knows the true pierce location and preserves the first contour segment.
/// </summary>
internal static void EnsureLeadingRapid(Program pgm)
{
if (pgm.Codes.Count == 0 || pgm.Codes[0] is RapidMove)
return;
for (var i = pgm.Codes.Count - 1; i >= 0; i--)
{
if (pgm.Codes[i] is Motion lastMotion)
{
pgm.Codes.Insert(0, new RapidMove(lastMotion.EndPoint));
return;
}
}
}
/// <summary>
/// Creates a sub-program key for matching parts to their sub-programs.
/// </summary>
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
/// <summary>
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
/// along with their normalized programs for writing.
/// </summary>
internal static (
Dictionary<(int, long), int> mapping,
List<(int subNum, string name, Program program)> entries
) BuildRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<(int, long), int>();
var entries = new List<(int, string, Program)>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
var key = SubprogramKey(part);
if (!mapping.ContainsKey(key))
{
var subNum = nextSubNum++;
mapping[key] = subNum;
var pgm = part.Program.Clone() as Program;
pgm.Mode = Mode.Absolute;
var bbox = pgm.BoundingBox();
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
// If the program has no leading rapid, the feature writer
// will use the first motion endpoint as the pierce point,
// losing the first contour segment. Insert a synthetic rapid
// at the contour's return point (last motion endpoint) so
// the full contour is preserved.
EnsureLeadingRapid(pgm);
entries.Add((subNum, part.BaseDrawing.Name, pgm));
}
}
}
return (mapping, entries);
}
/// <summary>
/// Scans all parts across all plates and builds a nest-level registry of unique
/// hole sub-programs. Deduplicates by comparing sub-program code content.
/// </summary>
internal static (
Dictionary<int, int> modelToPostMapping,
List<(int subNum, Program program)> entries
) BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
{
var mapping = new Dictionary<int, int>();
var entries = new List<(int, Program)>();
var contentIndex = new Dictionary<string, int>();
var nextSubNum = startNumber;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
foreach (var code in part.Program.Codes)
{
if (code is not SubProgramCall call)
continue;
if (mapping.ContainsKey(call.Id))
continue;
var canonical = ProgramToCanonical(call.Program);
if (contentIndex.TryGetValue(canonical, out var existingNum))
{
mapping[call.Id] = existingNum;
}
else
{
var subNum = nextSubNum++;
mapping[call.Id] = subNum;
contentIndex[canonical] = subNum;
entries.Add((subNum, call.Program));
}
}
}
}
return (mapping, entries);
}
private static string ProgramToCanonical(Program pgm)
{
var sb = new StringBuilder();
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
foreach (var code in pgm.Codes)
{
if (code is LinearMove lm)
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
else if (code is ArcMove am)
sb.Append(
$"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}"
);
else if (code is RapidMove rm)
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
}
return sb.ToString();
}
}
@@ -0,0 +1,372 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
/// <summary>
/// Specifies how coordinate positioning is handled between parts.
/// </summary>
public enum CoordinateMode
{
/// <summary>Set absolute position.</summary>
G92,
/// <summary>Use relative/incremental positioning.</summary>
G91,
/// <summary>Use machine coordinate system.</summary>
G53,
}
/// <summary>
/// Specifies how G89 (hole drilling/tapping parameters) are provided.
/// </summary>
public enum G89Mode
{
/// <summary>Use external library file for G89 parameters.</summary>
LibraryFile,
/// <summary>Explicitly define G89 parameters in the program.</summary>
Explicit,
}
/// <summary>
/// Specifies where kerf compensation is applied.
/// </summary>
public enum KerfMode
{
/// <summary>Controller side (using cutter compensation codes).</summary>
ControllerSide,
/// <summary>Pre-applied to part geometry during post-processing.</summary>
PreApplied,
}
/// <summary>
/// Specifies which side of the cut line kerf compensation is applied to.
/// </summary>
public enum KerfSide
{
/// <summary>Kerf applied to the left side of the cut.</summary>
Left,
/// <summary>Kerf applied to the right side of the cut.</summary>
Right,
}
/// <summary>
/// Specifies how M47 (optional stop) commands are used.
/// </summary>
public enum M47Mode
{
/// <summary>Always include M47.</summary>
Always,
/// <summary>Include M47 with block delete functionality.</summary>
BlockDelete,
/// <summary>Automatically determine M47 placement.</summary>
Auto,
/// <summary>Do not use M47.</summary>
None,
}
/// <summary>
/// Specifies when pallet exchange occurs.
/// </summary>
public enum PalletMode
{
/// <summary>No pallet exchange.</summary>
None,
/// <summary>Pallet exchange at end of sheet.</summary>
EndOfSheet,
/// <summary>Pallet exchange at start and end of sheet.</summary>
StartAndEnd,
}
/// <summary>
/// Configuration for Cincinnati post processor.
/// Defines machine-specific parameters, output format, and cutting strategies.
/// </summary>
public sealed class CincinnatiPostConfig
{
[Category("1. Output")]
[DisplayName("Configuration Name")]
[Description("Configuration name/identifier (e.g. CL940).")]
public string ConfigurationName { get; set; } = "CL940";
[Category("1. Output")]
[DisplayName("Posted Units")]
[Description("Units for posted output (Inches or Millimeters).")]
public Units PostedUnits { get; set; } = Units.Inches;
[Category("1. Output")]
[DisplayName("Decimal Accuracy")]
[Description("Number of decimal places for numeric output.")]
public int PostedAccuracy { get; set; } = 4;
[Category("1. Output")]
[DisplayName("Use Line Numbers")]
[Description("Include line numbers in output.")]
public bool UseLineNumbers { get; set; } = true;
[Category("1. Output")]
[DisplayName("Feature Line Number Start")]
[Description("Starting line number for features.")]
public int FeatureLineNumberStart { get; set; } = 1;
[Category("2. Subprograms")]
[DisplayName("Use Sheet Subprograms")]
[Description("Use subprograms for sheet operations.")]
public bool UseSheetSubprograms { get; set; } = true;
[Category("2. Subprograms")]
[DisplayName("Sheet Subprogram Start")]
[Description("Starting subprogram number for sheet operations.")]
public int SheetSubprogramStart { get; set; } = 101;
[Category("2. Subprograms")]
[DisplayName("Use Part Subprograms")]
[Description(
"Use M98 sub-programs for part geometry. Reduces output size for repeated parts."
)]
public bool UsePartSubprograms { get; set; } = false;
[Category("2. Subprograms")]
[DisplayName("Part Subprogram Start")]
[Description("Starting sub-program number for part geometry sub-programs.")]
public int PartSubprogramStart { get; set; } = 200;
[Category("2. Subprograms")]
[DisplayName("Variable Declaration Subprogram")]
[Description("Subprogram number for variable declarations.")]
public int VariableDeclarationSubprogram { get; set; } = 100;
[Category("3. Positioning")]
[DisplayName("Coordinate Mode Between Parts")]
[Description("How coordinate positioning is handled between parts (G92, G91, or G53).")]
public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92;
[Category("4. Process")]
[DisplayName("Process Parameter Mode")]
[Description("How G89 parameters are provided (LibraryFile or Explicit).")]
public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
[Category("4. Process")]
[DisplayName("Default Assist Gas")]
[Description("Default assist gas when Nest.AssistGas is empty.")]
public string DefaultAssistGas { get; set; } = "O2";
[Category("4. Process")]
[DisplayName("Default Etch Gas")]
[Description("Gas used for etch operations.")]
public string DefaultEtchGas { get; set; } = "N2";
[Category("4. Process")]
[DisplayName("Use Exact Stop Mode")]
[Description("Enable exact stop mode (G61).")]
public bool UseExactStopMode { get; set; } = false;
[Category("4. Process")]
[DisplayName("Use Speed/Gas Commands")]
[Description("Enable speed/gas commands in output.")]
public bool UseSpeedGas { get; set; } = false;
[Category("4. Process")]
[DisplayName("Use Anti-Dive")]
[Description("Enable anti-dive functionality.")]
public bool UseAntiDive { get; set; } = true;
[Category("4. Process")]
[DisplayName("Use Smart Rapids")]
[Description("Enable smart rapids optimization.")]
public bool UseSmartRapids { get; set; } = false;
[Category("5. Kerf")]
[DisplayName("Kerf Compensation")]
[Description("Where kerf compensation is applied (ControllerSide or PreApplied).")]
public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide;
[Category("5. Kerf")]
[DisplayName("Default Kerf Side")]
[Description("Default side for kerf compensation (Left or Right).")]
public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left;
[Category("6. M47 (Optional Stop)")]
[DisplayName("Interior M47")]
[Description("How M47 is used in interior cuts.")]
public M47Mode InteriorM47 { get; set; } = M47Mode.Always;
[Category("6. M47 (Optional Stop)")]
[DisplayName("Exterior M47")]
[Description("How M47 is used in exterior cuts.")]
public M47Mode ExteriorM47 { get; set; } = M47Mode.Always;
[Category("6. M47 (Optional Stop)")]
[DisplayName("M47 Override Distance Threshold")]
[Description("Distance threshold for M47 override. Null = no override.")]
public double? M47OverrideDistanceThreshold { get; set; } = null;
[Category("7. Safety")]
[DisplayName("Safety Headraise Distance")]
[Description("Safety head raise distance in machine units. Null = disabled.")]
public int? SafetyHeadraiseDistance { get; set; } = 2000;
[Category("8. Pallet")]
[DisplayName("Pallet Exchange")]
[Description("When pallet exchange occurs (None, EndOfSheet, or StartAndEnd).")]
public PalletMode PalletExchange { get; set; } = PalletMode.EndOfSheet;
[Category("9. Feedrates")]
[DisplayName("Lead-In Feedrate %")]
[Description("Feedrate percentage for lead-in moves (e.g. 0.5 = 50%).")]
public double LeadInFeedratePercent { get; set; } = 0.5;
[Category("9. Feedrates")]
[DisplayName("Lead-In Arc Line 2 Feedrate %")]
[Description("Feedrate percentage for lead-in arc-to-line moves.")]
public double LeadInArcLine2FeedratePercent { get; set; } = 0.5;
[Category("9. Feedrates")]
[DisplayName("Lead-Out Feedrate %")]
[Description("Feedrate percentage for lead-out moves.")]
public double LeadOutFeedratePercent { get; set; } = 0.5;
[Category("9. Feedrates")]
[DisplayName("Circle Feedrate Multiplier")]
[Description("Feedrate multiplier for circular cuts (e.g. 0.8 = 80%).")]
public double CircleFeedrateMultiplier { get; set; } = 0.8;
[Category("9. Feedrates")]
[DisplayName("Arc Feedrate Mode")]
[Description("Arc feedrate calculation mode (None, Percentages, or Variables).")]
public ArcFeedrateMode ArcFeedrate { get; set; } = ArcFeedrateMode.None;
[Category("9. Feedrates")]
[DisplayName("Arc Feedrate Ranges")]
[Description(
"Radius-based arc feedrate ranges. Matched from smallest to largest MaxRadius."
)]
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,
},
};
[Category("A. Variables")]
[DisplayName("User Variable Start")]
[Description("Starting variable number for user-defined variables (#200, #201, etc.).")]
public int UserVariableStart { get; set; } = 200;
[Category("A. Variables")]
[DisplayName("Sheet Width Variable")]
[Description("Variable number for sheet width.")]
public int SheetWidthVariable { get; set; } = 110;
[Category("A. Variables")]
[DisplayName("Sheet Length Variable")]
[Description("Variable number for sheet length.")]
public int SheetLengthVariable { get; set; } = 111;
[Category("B. Libraries")]
[DisplayName("Material Libraries")]
[Description(
"Material-to-library mapping for cut operations. Maps (material, thickness, gas) to a G89 library file."
)]
public List<MaterialLibraryEntry> MaterialLibraries { get; set; } = new();
[Category("B. Libraries")]
[DisplayName("Etch Libraries")]
[Description("Gas-to-library mapping for etch operations.")]
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
[Category("B. Libraries")]
[DisplayName("Selected Library")]
[Description(
"Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve."
)]
[TypeConverter(typeof(MaterialLibraryNameConverter))]
public string SelectedLibrary { get; set; } = "";
public string FindBestLibrary(string materialName, double thickness)
{
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
return "";
return MaterialLibraries
.Where(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
)
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
.Select(e => e.Library)
.FirstOrDefault()
?? "";
}
}
public class MaterialLibraryEntry
{
public string Material { get; set; } = "";
public double Thickness { get; set; }
public string Gas { get; set; } = "";
public string Library { get; set; } = "";
}
public class EtchLibraryEntry
{
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; }
}
}
@@ -0,0 +1,378 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati
{
public sealed class CincinnatiPostProcessor
: IConfigurablePostProcessor,
IPostProcessorNestAware,
IMaterialProvidingPostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
public string Name => "Cincinnati CL-707";
public string Author => "OpenNest";
public string Description => "Cincinnati CL-707/CL-800/CL-900/CL-940/CLX family";
public CincinnatiPostConfig Config { get; }
object IConfigurablePostProcessor.Config => Config;
public IEnumerable<string> GetMaterialNames()
{
if (Config?.MaterialLibraries == null)
return System.Array.Empty<string>();
return Config
.MaterialLibraries.Select(e => e.Material)
.Where(s => !string.IsNullOrWhiteSpace(s));
}
public void PrepareForNest(Nest nest)
{
var materialName = nest?.Material?.Name ?? "";
var thickness = nest?.Thickness ?? 0.0;
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
}
public CincinnatiPostProcessor()
{
var configPath = GetConfigPath();
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
Config = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, JsonOptions);
}
else
{
Config = new CincinnatiPostConfig();
SaveConfig();
}
}
public CincinnatiPostProcessor(CincinnatiPostConfig config)
{
Config = config;
}
public void SaveConfig()
{
var configPath = GetConfigPath();
var json = JsonSerializer.Serialize(Config, JsonOptions);
File.WriteAllText(configPath, json);
}
private static string GetConfigPath()
{
var assemblyPath = typeof(CincinnatiPostProcessor).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)
{
// 1. Create variable manager and register standard variables
var vars = CreateVariableManager();
// 2. Filter to non-empty plates
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList();
// 3. Register user variables from drawing programs
var userVarMapping = RegisterUserVariables(vars, plates);
// 4. Resolve gas and library files
var resolver = new MaterialLibraryResolver(Config);
var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
// Resolve cut library from nest material/thickness for preamble
var firstPlate = plates.FirstOrDefault();
var initialCutLibrary = resolver.ResolveCutLibrary(
nest.Material?.Name ?? "",
nest.Thickness,
gas
);
// 5. Build part sub-program registry (if enabled)
Dictionary<(int, long), int> partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(
plates,
Config.PartSubprogramStart
);
// 5b. Build hole sub-program registry (SubProgramCalls across all parts)
var holeStartNumber = Config.PartSubprogramStart + (subprogramEntries?.Count ?? 0);
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(
plates,
holeStartNumber
);
// 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(
Config,
vars,
holeMapping.Count > 0 ? holeMapping : null
);
// 7. Build material description from nest
var material = nest.Material;
var materialDesc =
material != null
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
: "";
// 8. Write to stream
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
// Main program
preamble.WriteMainProgram(
writer,
nest.Name ?? "NEST",
materialDesc,
plates,
initialCutLibrary
);
// Variable declaration subprogram
preamble.WriteVariableDeclaration(writer, vars);
// Sheet subprograms (one per unique layout, quantity handled via L count in main)
for (var i = 0; i < plates.Count; i++)
{
var plate = plates[i];
var layoutIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i;
var cutLibrary = resolver.ResolveCutLibrary(
nest.Material?.Name ?? "",
nest.Thickness,
gas
);
sheetWriter.Write(
writer,
plate,
nest.Name ?? "NEST",
layoutIndex,
subNumber,
cutLibrary,
etchLibrary,
partSubprograms,
userVarMapping
);
}
// Part sub-programs (if enabled)
if (subprogramEntries != null)
{
var partSubWriter = new CincinnatiPartSubprogramWriter(
Config,
holeMapping.Count > 0 ? holeMapping : null
);
var sheetDiagonal =
firstPlate != null
? System.Math.Sqrt(
firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length
)
: 100.0;
foreach (var (subNum, name, pgm) in subprogramEntries)
{
partSubWriter.Write(
writer,
pgm,
name,
subNum,
initialCutLibrary,
etchLibrary,
sheetDiagonal
);
}
}
// Hole sub-programs (SubProgramCall definitions)
if (holeEntries.Count > 0)
{
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
var sheetDiagonal =
firstPlate != null
? System.Math.Sqrt(
firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length
)
: 100.0;
foreach (var (subNum, pgm) in holeEntries)
{
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
holeSubWriter.Write(
writer,
pgm,
"HOLE",
subNum,
initialCutLibrary,
etchLibrary,
sheetDiagonal
);
}
}
writer.Flush();
}
public void Post(Nest nest, string outputFile)
{
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
Post(nest, fs);
}
private Dictionary<(int drawingId, string varName), int> RegisterUserVariables(
ProgramVariableManager vars,
List<Plate> plates
)
{
var mapping = new Dictionary<(int drawingId, string varName), int>();
var nextNumber = Config.UserVariableStart;
// Track global variables by name so they share a single number
var globalNumbers = new Dictionary<string, int>(
System.StringComparer.OrdinalIgnoreCase
);
// Collect unique drawings from all plates
var seenDrawings = new HashSet<int>();
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
var drawing = part.BaseDrawing;
if (drawing.IsCutOff || !seenDrawings.Add(drawing.Id))
continue;
foreach (var kvp in drawing.Program.Variables)
{
var varDef = kvp.Value;
// Skip inline variables — they emit literal values
if (varDef.Inline)
continue;
if (varDef.Global)
{
if (!globalNumbers.TryGetValue(varDef.Name, out var globalNum))
{
globalNum = nextNumber++;
globalNumbers[varDef.Name] = globalNum;
// Register once in the variable manager
var commentName = ToPascalCase(varDef.Name);
var expression = FormatVariableValue(varDef.Value);
vars.GetOrCreate(commentName, globalNum, expression);
}
mapping[(drawing.Id, varDef.Name)] = globalNum;
}
else
{
var num = nextNumber++;
mapping[(drawing.Id, varDef.Name)] = num;
// Register with drawing name prefix in the comment
var drawingLabel = ToPascalCase(drawing.Name);
var varLabel = ToPascalCase(varDef.Name);
var commentName = $"{drawingLabel}{varLabel}";
var expression = FormatVariableValue(varDef.Value);
vars.GetOrCreate(commentName, num, expression);
}
}
}
}
return mapping;
}
/// <summary>
/// Converts a variable name from snake_case or camelCase to PascalCase.
/// Examples: "sheet_width" → "SheetWidth", "holeSpacing" → "HoleSpacing"
/// </summary>
private static string ToPascalCase(string name)
{
var sb = new StringBuilder(name.Length);
var capitalizeNext = true;
foreach (var c in name)
{
if (c == '_')
{
capitalizeNext = true;
continue;
}
if (capitalizeNext)
{
sb.Append(char.ToUpper(c));
capitalizeNext = false;
}
else
{
sb.Append(c);
}
}
return sb.ToString();
}
private static string FormatVariableValue(double value)
{
return value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
}
private ProgramVariableManager CreateVariableManager()
{
var vars = new ProgramVariableManager();
vars.GetOrCreate("ProcessFeedrate", 148); // Set by G89, no expression
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;
}
}
}
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.IO;
using OpenNest;
using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Emits the main program header and variable declaration subprogram
/// for a Cincinnati laser post-processor output file.
/// </summary>
public sealed class CincinnatiPreambleWriter
{
private readonly CincinnatiPostConfig _config;
public CincinnatiPreambleWriter(CincinnatiPostConfig config)
{
_config = config;
}
/// <summary>
/// Writes the main program header block.
/// </summary>
/// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param>
public void WriteMainProgram(
TextWriter w,
string nestName,
string materialDescription,
List<Plate> plates,
string initialLibrary
)
{
w.WriteLine(CoordinateFormatter.Comment($"NEST {nestName}"));
w.WriteLine(CoordinateFormatter.Comment($"CONFIGURATION - {_config.ConfigurationName}"));
w.WriteLine(
CoordinateFormatter.Comment(
DateTime.Now.ToString(
"MM-dd-yyyy hh:mm:ss tt",
System.Globalization.CultureInfo.InvariantCulture
)
)
);
if (!string.IsNullOrEmpty(materialDescription))
w.WriteLine(CoordinateFormatter.Comment($"Material = {materialDescription}"));
if (_config.UseExactStopMode)
w.WriteLine("G61");
w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM"));
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($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
if (_config.PalletExchange == PalletMode.StartAndEnd)
w.WriteLine("M50");
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
for (var i = 0; i < plates.Count; i++)
{
var layoutNumber = i + 1;
var subNum = _config.SheetSubprogramStart + i;
var qty = System.Math.Max(plates[i].Quantity, 1);
var lParam = qty > 1 ? $" L{qty}" : "";
var sheetLabel =
qty > 1 ? $"LAYOUT {layoutNumber} - {qty} SHEETS" : $"LAYOUT {layoutNumber}";
w.WriteLine($"N{layoutNumber} M98 P{subNum}{lParam} ({sheetLabel})");
}
w.WriteLine("M42");
w.WriteLine("M30 (END OF MAIN)");
}
/// <summary>
/// Writes the variable declaration subprogram block.
/// </summary>
public void WriteVariableDeclaration(TextWriter w, ProgramVariableManager vars)
{
w.WriteLine("(*****************************************************)");
w.WriteLine($":{_config.VariableDeclarationSubprogram}");
w.WriteLine("(Variable Declaration Start)");
foreach (var line in vars.EmitDeclarations())
w.WriteLine(line);
w.WriteLine("M99 (Variable Declaration End)");
}
}
@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Emits one Cincinnati-format sheet subprogram per plate.
/// Supports two modes: inline features (default) or M98 sub-program calls per part.
/// </summary>
public sealed class CincinnatiSheetWriter
{
private readonly CincinnatiPostConfig _config;
private readonly ProgramVariableManager _vars;
private readonly CoordinateFormatter _fmt;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiSheetWriter(
CincinnatiPostConfig config,
ProgramVariableManager vars,
Dictionary<int, int> holeSubprograms = null
)
{
_config = config;
_vars = vars;
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_featureWriter = new CincinnatiFeatureWriter(config);
_holeSubprograms = holeSubprograms;
}
/// <summary>
/// Writes a complete sheet subprogram for the given plate.
/// </summary>
/// <param name="cutLibrary">Resolved G89 library file for cut operations.</param>
/// <param name="etchLibrary">Resolved G89 library file for etch operations.</param>
/// <param name="partSubprograms">
/// Optional mapping of (drawingId, rotationKey) to sub-program number.
/// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
/// </param>
public void Write(
TextWriter w,
Plate plate,
string nestName,
int layoutIndex,
int subNumber,
string cutLibrary,
string etchLibrary,
Dictionary<(int, long), int> partSubprograms = null,
Dictionary<(int drawingId, string varName), int> userVarMapping = null
)
{
if (plate.Parts.Count == 0)
return;
var width = plate.Size.Width;
var length = plate.Size.Length;
var sheetDiagonal = System.Math.Sqrt(width * width + length * length);
var varDeclSub = _config.VariableDeclarationSubprogram;
var partCount = plate.Parts.Count(p => !p.BaseDrawing.IsCutOff);
// 1. Sheet header
w.WriteLine("(*****************************************************)");
w.WriteLine($"( START OF {nestName}.{layoutIndex:D3} )");
w.WriteLine($":{subNumber}");
w.WriteLine($"( Layout {layoutIndex} )");
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(width)} X {_fmt.FormatCoord(length)} )");
w.WriteLine($"( Total parts on sheet = {partCount} )");
w.WriteLine(
$"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)"
);
w.WriteLine(
$"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)"
);
// 2. Coordinate setup
w.WriteLine("M42");
w.WriteLine("N10000");
w.WriteLine("G92 X#5021 Y#5022");
if (!string.IsNullOrEmpty(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("GOTO1( Goto Feature )");
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
var nonCutoffParts = plate
.Parts.Where(p => !p.BaseDrawing.IsCutOff)
.OrderBy(p => p.Bottom)
.ThenBy(p => p.Left)
.ToList();
var cutoffParts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToList();
var allParts = nonCutoffParts.Concat(cutoffParts).ToList();
// 4. Emit parts
if (partSubprograms != null)
WritePartsWithSubprograms(
w,
allParts,
cutLibrary,
etchLibrary,
sheetDiagonal,
width,
length,
partSubprograms,
userVarMapping
);
else
WritePartsInline(
w,
allParts,
cutLibrary,
etchLibrary,
sheetDiagonal,
width,
length,
userVarMapping
);
// 5. Footer
w.WriteLine("M42");
if (_config.PalletExchange != PalletMode.None)
w.WriteLine("M50");
w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})");
}
private void WritePartsWithSubprograms(
TextWriter w,
List<Part> allParts,
string cutLibrary,
string etchLibrary,
double sheetDiagonal,
double plateWidth,
double plateLength,
Dictionary<(int, long), int> partSubprograms,
Dictionary<(int drawingId, string varName), int> userVarMapping
)
{
var lastPartName = "";
var featureIndex = 0;
for (var p = 0; p < allParts.Count; p++)
{
var part = allParts[p];
var partName = part.BaseDrawing.Name;
var isNewPart = partName != lastPartName;
var isSafetyHeadraise = isNewPart && lastPartName != "";
var isLastPart = p == allParts.Count - 1;
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
partSubprograms.TryGetValue(key, out var subNum);
var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0;
if (hasSubprogram)
{
WriteSubprogramCall(
w,
part,
subNum,
featureIndex,
partName,
isSafetyHeadraise,
isLastPart
);
featureIndex++;
}
else
{
// Inline features for cutoffs or parts without sub-programs
var features = FeatureUtils.SplitAndClassify(part);
for (var f = 0; f < features.Count; f++)
{
var (codes, isEtch) = features[f];
var isLastFeature = isLastPart && f == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature);
featureIndex++;
lastPartName = partName;
continue;
}
var featureNumber =
featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = partName,
IsFirstFeatureOfPart = isNewPart && f == 0,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise && f == 0,
IsExteriorFeature = false,
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal,
PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id,
IsCutOff = part.BaseDrawing.IsCutOff,
PlateWidth = plateWidth,
PlateLength = plateLength,
};
_featureWriter.Write(w, ctx);
featureIndex++;
}
}
lastPartName = partName;
}
}
private void WriteSubprogramCall(
TextWriter w,
Part part,
int subNum,
int featureIndex,
string partName,
bool isSafetyHeadraise,
bool isLastPart
)
{
// Safety headraise before rapid to new part
if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
// Rapid to part position (bounding box lower-left)
var featureNumber =
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G0 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
w.WriteLine(sb.ToString());
// Part name comment
w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
// Set local coordinate system at part position
w.WriteLine("G92 X0 Y0");
// Call part sub-program
w.WriteLine($"M98 P{subNum} ({partName})");
// Restore sheet coordinate system
w.WriteLine($"G92 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
// Head raise (unless last part on sheet)
if (!isLastPart)
w.WriteLine("M47");
}
private void WriteHoleSubprogramCall(
TextWriter w,
SubProgramCall call,
int featureIndex,
bool isLastFeature
)
{
var postSubNum =
_holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num
: call.Id;
var featureNumber =
featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1;
// Shift the local origin to the hole center via G52 (manual §1.52).
// G52 does not move the nozzle, so the sub-program's first rapid
// (the lead-in to the pierce point) takes the tool straight from the
// previous feature's end to pierce. The hole sub-program is authored
// in hole-local coordinates and resolves to `hole + local` under the
// shift. See docs/cincinnati-post-output.md for the full bracket.
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
// Cancel the local shift (manual §1.52).
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
private void WritePartsInline(
TextWriter w,
List<Part> allParts,
string cutLibrary,
string etchLibrary,
double sheetDiagonal,
double plateWidth,
double plateLength,
Dictionary<(int drawingId, string varName), int> userVarMapping
)
{
// Split and classify features, ordering etch before cut per part
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
foreach (var part in allParts)
{
var partFeatures = FeatureUtils.SplitAndClassify(part);
foreach (var (codes, isEtch) in partFeatures)
features.Add((part, codes, isEtch));
}
// Emit features
var lastPartName = "";
for (var i = 0; i < features.Count; i++)
{
var (part, codes, isEtch) = features[i];
var partName = part.BaseDrawing.Name;
var isFirstFeatureOfPart = partName != lastPartName;
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
var isLastFeature = i == features.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
lastPartName = partName;
continue;
}
var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1;
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = partName,
IsFirstFeatureOfPart = isFirstFeatureOfPart,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise,
IsExteriorFeature = false,
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal,
PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id,
IsCutOff = part.BaseDrawing.IsCutOff,
PlateWidth = plateWidth,
PlateLength = plateLength,
};
_featureWriter.Write(w, ctx);
lastPartName = partName;
}
}
}
@@ -0,0 +1,25 @@
namespace OpenNest.Posts.Cincinnati
{
public sealed class CoordinateFormatter
{
private readonly int _accuracy;
private readonly string _format;
public CoordinateFormatter(int accuracy)
{
_accuracy = accuracy;
_format = "0." + new string('#', accuracy);
}
public string FormatCoord(double value)
{
return System
.Math.Round(value, _accuracy)
.ToString(_format, System.Globalization.CultureInfo.InvariantCulture);
}
public static string Comment(string text) => $"( {text} )";
public static string InlineComment(string text) => $"({text})";
}
}
@@ -0,0 +1,193 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Shared utilities for splitting CNC programs into features and classifying them.
/// </summary>
public static class FeatureUtils
{
/// <summary>
/// Splits a flat list of codes into feature groups, breaking on rapid moves.
/// Each feature starts with a rapid move followed by cutting/etching moves.
/// </summary>
public static List<List<ICode>> SplitByRapids(List<ICode> codes)
{
var features = new List<List<ICode>>();
List<ICode> current = null;
foreach (var code in codes)
{
if (code is SubProgramCall)
{
// Flush any pending feature
if (current != null)
features.Add(current);
// SubProgramCall is its own feature
features.Add(new List<ICode> { code });
current = null;
}
else if (code is RapidMove)
{
if (current != null)
features.Add(current);
current = new List<ICode> { code };
}
else
{
current ??= new List<ICode>();
current.Add(code);
}
}
if (current != null && current.Count > 0)
features.Add(current);
return features;
}
/// <summary>
/// Classifies features as etch or cut and orders etch features before cut features.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> ClassifyAndOrder(
List<List<ICode>> features
)
{
var result = new List<(List<ICode>, bool)>();
var etch = new List<List<ICode>>();
var cut = new List<List<ICode>>();
foreach (var f in features)
{
if (IsEtch(f))
etch.Add(f);
else
cut.Add(f);
}
foreach (var f in etch)
result.Add((f, true));
foreach (var f in cut)
result.Add((f, false));
return result;
}
/// <summary>
/// Splits a part's program into features by rapids, classifies each as etch or cut,
/// and orders etch features before cut features.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part)
{
part.Program.Mode = Mode.Absolute;
var codes = part.Program.Codes;
// If no leading rapid, the first contour segment would be lost because
// the feature writer pierces at the first motion endpoint. Insert a
// synthetic rapid at the contour's return point to preserve closure.
if (codes.Count > 0 && codes[0] is not RapidMove)
{
for (var i = codes.Count - 1; i >= 0; i--)
{
if (codes[i] is Motion lastMotion)
{
var withRapid = new List<ICode>(codes.Count + 1);
withRapid.Add(new RapidMove(lastMotion.EndPoint));
withRapid.AddRange(codes);
codes = withRapid;
break;
}
}
}
return ClassifyAndOrder(SplitByRapids(codes));
}
/// <summary>
/// Returns true if any non-rapid move in the feature has LayerType.Scribe.
/// </summary>
public static bool IsEtch(List<ICode> codes)
{
foreach (var code in codes)
{
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
return true;
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
return true;
}
return false;
}
/// <summary>
/// Computes the total cut distance of a feature by summing segment lengths.
/// </summary>
public static double ComputeCutDistance(List<ICode> codes)
{
var distance = 0.0;
var currentPos = Vector.Zero;
foreach (var code in codes)
{
if (code is RapidMove rapid)
currentPos = rapid.EndPoint;
else if (code is LinearMove linear)
{
distance += currentPos.DistanceTo(linear.EndPoint);
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
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;
}
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
public sealed class MaterialLibraryNameConverter : StringConverter
{
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
{
var config = context?.Instance as CincinnatiPostConfig;
var names = new List<string> { "" };
if (config?.MaterialLibraries != null)
{
names.AddRange(
config
.MaterialLibraries.Select(e => e.Library)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
);
}
return new StandardValuesCollection(names);
}
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Posts.Cincinnati;
public sealed class MaterialLibraryResolver
{
private const double ThicknessTolerance = 0.001;
private readonly List<MaterialLibraryEntry> _materialLibraries;
private readonly List<EtchLibraryEntry> _etchLibraries;
private readonly string _selectedLibrary;
public MaterialLibraryResolver(CincinnatiPostConfig config)
{
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
_selectedLibrary = config.SelectedLibrary ?? "";
}
public string ResolveCutLibrary(string materialName, double thickness, string gas)
{
if (!string.IsNullOrEmpty(_selectedLibrary))
return EnsureLibExtension(_selectedLibrary);
var entry = _materialLibraries.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase)
&& System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance
&& string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase)
);
return EnsureLibExtension(entry?.Library ?? "");
}
public string ResolveEtchLibrary(string gas)
{
var entry = _etchLibraries.FirstOrDefault(e =>
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase)
);
return EnsureLibExtension(entry?.Library ?? "");
}
private static string EnsureLibExtension(string library)
{
if (string.IsNullOrEmpty(library))
return library;
if (!library.EndsWith(".lib", StringComparison.OrdinalIgnoreCase))
return library + ".lib";
return library;
}
public static string ResolveGas(Nest nest, CincinnatiPostConfig config)
{
return !string.IsNullOrEmpty(nest.AssistGas) ? nest.AssistGas : config.DefaultAssistGas;
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Posts.Cincinnati</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenNest.Posts.Cincinnati.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.Cincinnati.json</ConfigJson>
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.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,163 @@
{
"ConfigurationName": "CL940",
"PostedUnits": "Inches",
"PostedAccuracy": 4,
"UseLineNumbers": true,
"FeatureLineNumberStart": 1,
"UseSheetSubprograms": true,
"SheetSubprogramStart": 101,
"UsePartSubprograms": false,
"PartSubprogramStart": 200,
"VariableDeclarationSubprogram": 100,
"CoordModeBetweenParts": "G92",
"ProcessParameterMode": "LibraryFile",
"DefaultAssistGas": "O2",
"DefaultEtchGas": "N2",
"UseExactStopMode": false,
"UseSpeedGas": false,
"UseAntiDive": true,
"UseSmartRapids": false,
"KerfCompensation": "ControllerSide",
"DefaultKerfSide": "Left",
"InteriorM47": "Always",
"ExteriorM47": "Always",
"M47OverrideDistanceThreshold": null,
"SafetyHeadraiseDistance": 2000,
"PalletExchange": "EndOfSheet",
"LeadInFeedratePercent": 0.5,
"LeadInArcLine2FeedratePercent": 0.5,
"LeadOutFeedratePercent": 0.5,
"CircleFeedrateMultiplier": 0.8,
"ArcFeedrate": "None",
"ArcFeedrateRanges": [
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
],
"UserVariableStart": 200,
"SheetWidthVariable": 110,
"SheetLengthVariable": 111,
"MaterialLibraries": [
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
],
"EtchLibraries": [
{ "Gas": "AIR", "Library": "EtchAIR" },
{ "Gas": "N2", "Library": "EtchN2" },
{ "Gas": "N2", "Library": "EtchN2_fast" },
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
{ "Gas": "O2", "Library": "EtchO2" },
{ "Gas": "O2", "Library": "ETCHO2FINE" }
]
}
@@ -0,0 +1,33 @@
namespace OpenNest.Posts.Cincinnati
{
public sealed class SpeedClassifier
{
public double FastThreshold { get; set; } = 0.5;
public double SlowThreshold { get; set; } = 0.1;
public string Classify(double contourLength, double sheetDiagonal)
{
var ratio = contourLength / sheetDiagonal;
if (ratio >= FastThreshold)
return "FAST";
if (ratio <= SlowThreshold)
return "SLOW";
return "MEDIUM";
}
public string FormatCutDist(double contourLength, double sheetDiagonal)
{
return $"CutDist={FormatValue(contourLength)}/{FormatValue(sheetDiagonal)}";
}
private static string FormatValue(double value)
{
// Cincinnati convention: no leading zero for values < 1 (e.g., ".8702" not "0.8702")
var rounded = System.Math.Round(value, 4);
var str = rounded.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
if (rounded > 0 && rounded < 1 && str.StartsWith("0."))
return str.Substring(1);
return str;
}
}
}