Compare commits

..

10 Commits

Author SHA1 Message Date
aj 833abfe72e feat: add optional M98 part sub-programs to Cincinnati post processor
Each unique part geometry (drawing + rotation) is written once as a
reusable sub-program called via M98, reducing output size for nests
with repeated parts. G92 coordinate repositioning handles per-instance
plate placement with restore after each call. Cut-offs remain inline.

Controlled by UsePartSubprograms (default false) and PartSubprogramStart
config properties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:43:44 -04:00
aj 379000bbd8 feat: auto-copy Cincinnati post processor DLL to Posts/ on build
The WinForms app's LoadPosts() scans Posts/ for IPostProcessor DLLs.
A build target copies the Cincinnati DLL there so it appears in the
Nest > Post menu automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:12:47 -04:00
aj 5936272ce4 refactor: move ProgramVariableManager to Core, add config file support
- Move ProgramVariable and ProgramVariableManager from
  OpenNest.Posts.Cincinnati to OpenNest.Core/CNC (namespace OpenNest.CNC)
  so they can be used internally in nest programs
- Add parameterless constructor to CincinnatiPostProcessor that loads
  config from a .json file next to the DLL (creates defaults on first run)
- Enums serialize as readable strings (e.g., "Inches", "ControllerSide")
- Config file: OpenNest.Posts.Cincinnati.json in the Posts/ directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:08:31 -04:00
aj da8e7e6fd3 feat: interactive cut-off selection and drag via line hit-testing
Select cut-offs by clicking their lines instead of a grip point.
Drag is axis-constrained with live regeneration during movement.
Selected cut-off highlighted with bright blue 3.5px line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:49:27 -04:00
aj 53d24ddaf1 feat: implement Polygon.OffsetEntity and use geometric offset for cut-off clearance
Polygon.OffsetEntity now computes proper miter-join offsets using edge
normals and winding direction, with self-intersection cleanup. CutOff
exclusion zones use geometric perimeter offset instead of scalar padding,
giving uniform clearance around parts regardless of cut angle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:46:18 -04:00
aj 8efdc8720c fix: review fixes — culture-invariant formatting, sealed config, threshold boundary
- Use CultureInfo.InvariantCulture in CoordinateFormatter, SpeedClassifier,
  and CincinnatiPreambleWriter to prevent locale-dependent G-code output
- Make CincinnatiPostConfig sealed per spec
- Fix SpeedClassifier.Classify threshold to >= (matching spec)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:45:22 -04:00
aj ca8a0942ab feat: add CincinnatiPostProcessor implementing IPostProcessor
Orchestrates CincinnatiPreambleWriter and CincinnatiSheetWriter to produce
a complete Cincinnati CNC output file from a Nest; includes 4 integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:41:06 -04:00
aj 8c3659a439 feat: add CincinnatiSheetWriter for per-plate subprogram emission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:38:24 -04:00
aj 95a0815484 feat: add CincinnatiPreambleWriter for main program and variable declaration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:35:27 -04:00
aj e9caa9b8eb feat: add CincinnatiFeatureWriter for per-feature G-code emission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:33:06 -04:00
20 changed files with 2016 additions and 37 deletions
@@ -1,4 +1,4 @@
namespace OpenNest.Posts.Cincinnati
namespace OpenNest.CNC
{
public sealed class ProgramVariable
{
@@ -2,7 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace OpenNest.Posts.Cincinnati
namespace OpenNest.CNC
{
public sealed class ProgramVariableManager
{
+22 -2
View File
@@ -129,9 +129,11 @@ namespace OpenNest
private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
{
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
var usedOffset = target != perimeter;
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
if (!perimeter.Intersects(cutLine, out var pts) || pts.Count < 2)
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
return null;
var coords = pts
@@ -142,13 +144,31 @@ namespace OpenNest
if (coords.Count % 2 != 0)
return null;
var padding = usedOffset ? 0 : clearance;
var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2)
result.Add((coords[i] - clearance, coords[i + 1] + clearance));
result.Add((coords[i] - padding, coords[i + 1] + padding));
return result;
}
private static Entity OffsetOutward(Entity perimeter, double clearance)
{
if (clearance <= 0)
return null;
try
{
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
offset?.UpdateBounds();
return offset;
}
catch
{
return null;
}
}
private Vector MakePoint(double cutCoord, double lineCoord) =>
Axis == CutOffAxis.Vertical
? new Vector(cutCoord, lineCoord)
+58 -2
View File
@@ -317,12 +317,68 @@ namespace OpenNest.Geometry
public override Entity OffsetEntity(double distance, OffsetSide side)
{
throw new NotImplementedException();
if (Vertices.Count < 3)
return null;
var isClosed = IsClosed();
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
if (count < 3)
return null;
var ccw = CalculateArea() > 0;
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
var sign = side == outward ? 1.0 : -1.0;
var d = distance * sign;
var normals = new Vector[count];
for (var i = 0; i < count; i++)
{
var next = (i + 1) % count;
var dx = Vertices[next].X - Vertices[i].X;
var dy = Vertices[next].Y - Vertices[i].Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < Tolerance.Epsilon)
return null;
normals[i] = new Vector(-dy / len * d, dx / len * d);
}
var result = new Polygon();
for (var i = 0; i < count; i++)
{
var prev = (i - 1 + count) % count;
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
var edgeA = new Line(a1, a2);
var edgeB = new Line(b1, b2);
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
result.Vertices.Add(pt);
else
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
}
result.Close();
result.RemoveSelfIntersections();
result.UpdateBounds();
return result;
}
public override Entity OffsetEntity(double distance, Vector pt)
{
throw new NotImplementedException();
var left = OffsetEntity(distance, OffsetSide.Left);
var right = OffsetEntity(distance, OffsetSide.Right);
if (left == null) return right;
if (right == null) return left;
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
return distLeft > distRight ? left : right;
}
/// <summary>
@@ -0,0 +1,230 @@
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 string LibraryFile { get; set; } = "";
public double CutDistance { get; set; }
public double SheetDiagonal { 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;
// 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.FeatureNumber, piercePoint);
// 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 RepeatG89BeforeEachFeature)
if (_config.RepeatG89BeforeEachFeature && _config.ProcessParameterMode == G89Mode.LibraryFile)
{
var lib = !string.IsNullOrEmpty(ctx.LibraryFile) ? ctx.LibraryFile : _config.DefaultLibraryFile;
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})");
}
// 4. Pierce and start cut
writer.WriteLine("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
if (!kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide)
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41" : "G42");
kerfEmitted = true;
}
sb.Append($"G1X{_fmt.FormatCoord(linear.EndPoint.X)}Y{_fmt.FormatCoord(linear.EndPoint.Y)}");
// Feedrate
var feedVar = GetFeedVariable(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
if (!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";
sb.Append($"{gCode}X{_fmt.FormatCoord(arc.EndPoint.X)}Y{_fmt.FormatCoord(arc.EndPoint.Y)}");
// 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 — full circles use multiplied feedrate
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
var feedVar = isFullCircle ? "[#148*#128]" : GetFeedVariable(arc.Layer);
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);
}
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, int featureNumber, Vector piercePoint)
{
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber}");
sb.Append($"G0X{_fmt.FormatCoord(piercePoint.X)}Y{_fmt.FormatCoord(piercePoint.Y)}");
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 GetFeedVariable(LayerType layer)
{
return layer switch
{
LayerType.Leadin => "#126",
LayerType.Cut => "#148",
_ => "#148"
};
}
private static bool IsFullCircle(Vector start, Vector end)
{
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
}
}
@@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.IO;
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;
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
{
_config = config;
_featureWriter = new CincinnatiFeatureWriter(config);
}
/// <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 libraryFile, double sheetDiagonal)
{
var features = SplitFeatures(normalizedProgram.Codes);
if (features.Count == 0)
return;
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}"));
for (var i = 0; i < features.Count; i++)
{
var codes = features[i];
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
var cutDistance = ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = i == features.Count - 1,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
LibraryFile = libraryFile,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal
};
_featureWriter.Write(w, ctx);
}
w.WriteLine("G0X0Y0");
w.WriteLine($"M99(END OF {drawingName})");
}
/// <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));
internal static List<List<ICode>> SplitFeatures(List<ICode> codes)
{
var features = new List<List<ICode>>();
List<ICode> current = null;
foreach (var code in codes)
{
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;
}
internal 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 += currentPos.DistanceTo(arc.EndPoint);
currentPos = arc.EndPoint;
}
}
return distance;
}
}
@@ -88,7 +88,7 @@ namespace OpenNest.Posts.Cincinnati
/// Configuration for Cincinnati post processor.
/// Defines machine-specific parameters, output format, and cutting strategies.
/// </summary>
public class CincinnatiPostConfig
public sealed class CincinnatiPostConfig
{
/// <summary>
/// Gets or sets the configuration name/identifier.
@@ -126,6 +126,20 @@ namespace OpenNest.Posts.Cincinnati
/// </summary>
public int SheetSubprogramStart { get; set; } = 101;
/// <summary>
/// Gets or sets whether to use M98 sub-programs for part geometry.
/// When enabled, each unique part geometry is written as a reusable sub-program
/// called via M98, reducing output size for nests with repeated parts.
/// Default: false
/// </summary>
public bool UsePartSubprograms { get; set; } = false;
/// <summary>
/// Gets or sets the starting sub-program number for part geometry sub-programs.
/// Default: 200
/// </summary>
public int PartSubprogramStart { get; set; } = 200;
/// <summary>
/// Gets or sets the subprogram number for variable declarations.
/// Default: 100
@@ -0,0 +1,167 @@
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 : IPostProcessor
{
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; }
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. 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 = new Dictionary<(int, long), int>();
subprogramEntries = new List<(int, string, Program)>();
var nextSubNum = Config.PartSubprogramStart;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff) continue;
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
if (!partSubprograms.ContainsKey(key))
{
var subNum = nextSubNum++;
partSubprograms[key] = subNum;
// Create normalized program at origin
var pgm = part.Program.Clone() as Program;
var bbox = pgm.BoundingBox();
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
subprogramEntries.Add((subNum, part.BaseDrawing.Name, pgm));
}
}
}
}
// 4. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
// 5. Build material description from first plate
var material = plates.FirstOrDefault()?.Material;
var materialDesc = material != null
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
: "";
// 6. Write to stream
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
// Main program
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count);
// Variable declaration subprogram
preamble.WriteVariableDeclaration(writer, vars);
// Sheet subprograms
for (var i = 0; i < plates.Count; i++)
{
var sheetIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i;
sheetWriter.Write(writer, plates[i], nest.Name ?? "NEST", sheetIndex, subNumber,
partSubprograms);
}
// Part sub-programs (if enabled)
if (subprogramEntries != null)
{
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
var firstPlate = plates.FirstOrDefault();
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,
Config.DefaultLibraryFile ?? "", sheetDiagonal);
}
}
writer.Flush();
}
public void Post(Nest nest, string outputFile)
{
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
Post(nest, fs);
}
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.#"));
return vars;
}
}
}
@@ -0,0 +1,73 @@
using System;
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>
public void WriteMainProgram(TextWriter w, string nestName, string materialDescription, int sheetCount)
{
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" : "G20");
w.WriteLine("M42");
if (_config.ProcessParameterMode == G89Mode.LibraryFile && !string.IsNullOrEmpty(_config.DefaultLibraryFile))
w.WriteLine($"G89 P {_config.DefaultLibraryFile}");
w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
for (var i = 1; i <= sheetCount; i++)
{
var subNum = _config.SheetSubprogramStart + (i - 1);
w.WriteLine($"N{i}M98 P{subNum} (SHEET {i})");
}
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,309 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
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;
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars)
{
_config = config;
_vars = vars;
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_featureWriter = new CincinnatiFeatureWriter(config);
}
/// <summary>
/// Writes a complete sheet subprogram for the given plate.
/// </summary>
/// <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 sheetIndex, int subNumber,
Dictionary<(int, long), int> partSubprograms = 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 libraryFile = _config.DefaultLibraryFile ?? "";
var varDeclSub = _config.VariableDeclarationSubprogram;
var partCount = plate.Parts.Count(p => !p.BaseDrawing.IsCutOff);
// 1. Sheet header
w.WriteLine("(*****************************************************)");
w.WriteLine($"( START OF {nestName}.{sheetIndex:D3} )");
w.WriteLine($":{subNumber}");
w.WriteLine($"( Sheet {sheetIndex} )");
w.WriteLine($"( Layout {sheetIndex} )");
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(length)} X {_fmt.FormatCoord(width)} )");
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("G92X#5021Y#5022");
if (!string.IsNullOrEmpty(libraryFile))
w.WriteLine($"G89 P {libraryFile}");
w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)");
w.WriteLine("G90");
w.WriteLine("M47(CPT)");
if (!string.IsNullOrEmpty(libraryFile))
w.WriteLine($"G89 P {libraryFile}");
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, libraryFile, sheetDiagonal, partSubprograms);
else
WritePartsInline(w, allParts, libraryFile, sheetDiagonal);
// 5. Footer
w.WriteLine("M42");
w.WriteLine("G0X0Y0");
if (_config.PalletExchange != PalletMode.None)
w.WriteLine($"N{sheetIndex + 1}M50");
w.WriteLine($"M99(END OF {nestName}.{sheetIndex:D3})");
}
private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts,
string libraryFile, double sheetDiagonal,
Dictionary<(int, long), int> partSubprograms)
{
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 = SplitPartFeatures(part);
for (var f = 0; f < features.Count; f++)
{
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var isLastFeature = isLastPart && f == features.Count - 1;
var cutDistance = ComputeCutDistance(features[f]);
var ctx = new FeatureContext
{
Codes = features[f],
FeatureNumber = featureNumber,
PartName = partName,
IsFirstFeatureOfPart = isNewPart && f == 0,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise && f == 0,
IsExteriorFeature = false,
LibraryFile = libraryFile,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal
};
_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($"G0X{_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("G92X0Y0");
// Call part sub-program
w.WriteLine($"M98P{subNum}({partName})");
// Restore sheet coordinate system
w.WriteLine($"G92X{_fmt.FormatCoord(part.Left)}Y{_fmt.FormatCoord(part.Bottom)}");
// Head raise (unless last part on sheet)
if (!isLastPart)
w.WriteLine("M47");
}
private void WritePartsInline(TextWriter w, List<Part> allParts,
string libraryFile, double sheetDiagonal)
{
// Multi-contour splitting
var features = new List<(Part part, List<ICode> codes)>();
foreach (var part in allParts)
{
List<ICode> current = null;
foreach (var code in part.Program.Codes)
{
if (code is RapidMove)
{
if (current != null)
features.Add((part, current));
current = new List<ICode> { code };
}
else
{
current ??= new List<ICode>();
current.Add(code);
}
}
if (current != null && current.Count > 0)
features.Add((part, current));
}
// Emit features
var lastPartName = "";
for (var i = 0; i < features.Count; i++)
{
var (part, codes) = features[i];
var partName = part.BaseDrawing.Name;
var isFirstFeatureOfPart = partName != lastPartName;
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
var isLastFeature = i == features.Count - 1;
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
var cutDistance = ComputeCutDistance(codes);
var ctx = new FeatureContext
{
Codes = codes,
FeatureNumber = featureNumber,
PartName = partName,
IsFirstFeatureOfPart = isFirstFeatureOfPart,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise,
IsExteriorFeature = false,
LibraryFile = libraryFile,
CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal
};
_featureWriter.Write(w, ctx);
lastPartName = partName;
}
}
private static List<List<ICode>> SplitPartFeatures(Part part)
{
var features = new List<List<ICode>>();
List<ICode> current = null;
foreach (var code in part.Program.Codes)
{
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;
}
private 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 += currentPos.DistanceTo(arc.EndPoint);
currentPos = arc.EndPoint;
}
}
return distance;
}
}
@@ -13,7 +13,8 @@ namespace OpenNest.Posts.Cincinnati
public string FormatCoord(double value)
{
return System.Math.Round(value, _accuracy).ToString(_format);
return System.Math.Round(value, _accuracy)
.ToString(_format, System.Globalization.CultureInfo.InvariantCulture);
}
public static string Comment(string text) => $"( {text} )";
@@ -6,4 +6,11 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" />
</Target>
</Project>
+2 -2
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Posts.Cincinnati
public string Classify(double contourLength, double sheetDiagonal)
{
var ratio = contourLength / sheetDiagonal;
if (ratio > FastThreshold) return "FAST";
if (ratio >= FastThreshold) return "FAST";
if (ratio <= SlowThreshold) return "SLOW";
return "MEDIUM";
}
@@ -22,7 +22,7 @@ namespace OpenNest.Posts.Cincinnati
{
// 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.####");
var str = rounded.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
if (rounded > 0 && rounded < 1 && str.StartsWith("0."))
return str.Substring(1);
return str;
@@ -0,0 +1,437 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class CincinnatiFeatureWriterTests
{
private static CincinnatiPostConfig DefaultConfig() => new()
{
UseLineNumbers = true,
FeatureLineNumberStart = 1,
UseAntiDive = true,
KerfCompensation = KerfMode.ControllerSide,
DefaultKerfSide = KerfSide.Left,
RepeatG89BeforeEachFeature = true,
ProcessParameterMode = G89Mode.LibraryFile,
DefaultLibraryFile = "MILD10",
InteriorM47 = M47Mode.Always,
ExteriorM47 = M47Mode.Always,
UseSpeedGas = false,
PostedAccuracy = 4,
SafetyHeadraiseDistance = 2000
};
private static FeatureContext SimpleContext(List<ICode>? codes = null) => new()
{
Codes = codes ?? new List<ICode>
{
new RapidMove(13.401, 57.4895),
new LinearMove(14.0, 57.5) { Layer = LayerType.Leadin },
new LinearMove(20.0, 57.5) { Layer = LayerType.Cut }
},
FeatureNumber = 1,
PartName = "BRACKET",
IsFirstFeatureOfPart = true,
IsLastFeatureOnSheet = false,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
LibraryFile = "MILD10",
CutDistance = 18.0,
SheetDiagonal = 30.0
};
private static string WriteFeature(CincinnatiPostConfig config, FeatureContext ctx)
{
var writer = new CincinnatiFeatureWriter(config);
using var sw = new StringWriter();
writer.Write(sw, ctx);
return sw.ToString();
}
[Fact]
public void RapidToPiercePoint_WithLineNumber()
{
var config = DefaultConfig();
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("N1G0X13.401Y57.4895", lines[0]);
}
[Fact]
public void RapidToPiercePoint_WithoutLineNumber()
{
var config = DefaultConfig();
config.UseLineNumbers = false;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.StartsWith("G0X13.401Y57.4895", lines[0]);
}
[Fact]
public void G84_PierceEmitted()
{
var config = DefaultConfig();
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("G84", output);
}
[Fact]
public void AntiDive_M130M131_EmittedWhenEnabled()
{
var config = DefaultConfig();
config.UseAntiDive = true;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("M130 (ANTI DIVE OFF)", output);
Assert.Contains("M131 (ANTI DIVE ON)", output);
}
[Fact]
public void AntiDive_NotEmittedWhenDisabled()
{
var config = DefaultConfig();
config.UseAntiDive = false;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.DoesNotContain("M130", output);
Assert.DoesNotContain("M131", output);
}
[Fact]
public void KerfCompensation_G41G40_EmittedWhenControllerSide()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.ControllerSide;
config.DefaultKerfSide = KerfSide.Left;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("G41", output);
Assert.Contains("G40", output);
}
[Fact]
public void KerfCompensation_G42_EmittedForRightSide()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.ControllerSide;
config.DefaultKerfSide = KerfSide.Right;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("G42", output);
}
[Fact]
public void KerfCompensation_NotEmittedWhenPreApplied()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.DoesNotContain("G41", output);
Assert.DoesNotContain("G42", output);
Assert.DoesNotContain("G40", output);
}
[Fact]
public void M35_BeamOffEmitted()
{
var config = DefaultConfig();
config.UseSpeedGas = false;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("M35", output);
}
[Fact]
public void M135_BeamOffEmittedWhenSpeedGas()
{
var config = DefaultConfig();
config.UseSpeedGas = true;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.Contains("M135", output);
}
[Fact]
public void M47_EmittedWhenNotLastFeature()
{
var config = DefaultConfig();
var ctx = SimpleContext();
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.Contains("M47", output);
}
[Fact]
public void M47_OmittedWhenLastFeatureOnSheet()
{
var config = DefaultConfig();
var ctx = SimpleContext();
ctx.IsLastFeatureOnSheet = true;
var output = WriteFeature(config, ctx);
// M47 should not appear, but M35 should still be there
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47");
}
[Fact]
public void M47_BlockDeleteMode_EmitsSlashM47()
{
var config = DefaultConfig();
config.InteriorM47 = M47Mode.BlockDelete;
var ctx = SimpleContext();
ctx.IsExteriorFeature = false;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.Contains("/M47", output);
}
[Fact]
public void M47_NoneMode_NoM47Emitted()
{
var config = DefaultConfig();
config.InteriorM47 = M47Mode.None;
var ctx = SimpleContext();
ctx.IsExteriorFeature = false;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47");
}
[Fact]
public void ArcIJ_ConvertedFromAbsoluteToIncremental()
{
var config = DefaultConfig();
// Arc starts at rapid endpoint (10, 20), center at (15, 20) absolute
// So incremental I = 15 - 10 = 5, J = 20 - 20 = 0
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(
endPoint: new Vector(10.0, 20.0),
centerPoint: new Vector(15.0, 20.0),
rotation: RotationType.CW
) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
// Should contain incremental I=5, J=0
Assert.Contains("I5", output);
Assert.Contains("J0", output);
}
[Fact]
public void ArcMove_G2ForCW_G3ForCCW()
{
var config = DefaultConfig();
var cwCodes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var ccwCodes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CCW) { Layer = LayerType.Cut }
};
var cwOutput = WriteFeature(config, SimpleContext(cwCodes));
var ccwOutput = WriteFeature(config, SimpleContext(ccwCodes));
Assert.Contains("G2X", cwOutput);
Assert.Contains("G3X", ccwOutput);
}
[Fact]
public void PartNameComment_EmittedOnFirstFeature()
{
var config = DefaultConfig();
var ctx = SimpleContext();
ctx.IsFirstFeatureOfPart = true;
ctx.PartName = "FLANGE";
var output = WriteFeature(config, ctx);
Assert.Contains("( PART: FLANGE )", output);
}
[Fact]
public void PartNameComment_NotEmittedOnSubsequentFeatures()
{
var config = DefaultConfig();
var ctx = SimpleContext();
ctx.IsFirstFeatureOfPart = false;
ctx.PartName = "FLANGE";
var output = WriteFeature(config, ctx);
Assert.DoesNotContain("PART:", output);
}
[Fact]
public void G89_EmittedWhenRepeatEnabled()
{
var config = DefaultConfig();
config.RepeatG89BeforeEachFeature = true;
config.ProcessParameterMode = G89Mode.LibraryFile;
config.DefaultLibraryFile = "MILD10";
var ctx = SimpleContext();
ctx.LibraryFile = "MILD10";
ctx.CutDistance = 18.0;
ctx.SheetDiagonal = 30.0;
var output = WriteFeature(config, ctx);
Assert.Contains("G89 P MILD10", output);
}
[Fact]
public void G89_NotEmittedWhenRepeatDisabled()
{
var config = DefaultConfig();
config.RepeatG89BeforeEachFeature = false;
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
Assert.DoesNotContain("G89", output);
}
[Fact]
public void FeedrateModalSuppression_OnlyEmitsOnChange()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied; // simplify output
var codes = new List<ICode>
{
new RapidMove(1.0, 1.0),
new LinearMove(2.0, 1.0) { Layer = LayerType.Cut },
new LinearMove(3.0, 1.0) { Layer = LayerType.Cut },
new LinearMove(4.0, 1.0) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
// F#148 should appear only once (on the first cut move)
var count = CountOccurrences(output, "F#148");
Assert.Equal(1, count);
}
[Fact]
public void LeadinFeedrate_UsesVariable126()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied; // simplify output
var codes = new List<ICode>
{
new RapidMove(1.0, 1.0),
new LinearMove(2.0, 1.0) { Layer = LayerType.Leadin }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F#126", output);
}
[Fact]
public void FullCircleArc_UsesMultipliedFeedrate()
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.PreApplied;
// Full circle: start == end
var codes = new List<ICode>
{
new RapidMove(10.0, 20.0),
new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut }
};
var ctx = SimpleContext(codes);
var output = WriteFeature(config, ctx);
Assert.Contains("F[#148*#128]", output);
}
[Fact]
public void SafetyHeadraise_EmitsM47WithDistance()
{
var config = DefaultConfig();
config.SafetyHeadraiseDistance = 2000;
var ctx = SimpleContext();
ctx.IsSafetyHeadraise = true;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.Contains("M47 P2000(Safety Headraise)", output);
}
[Fact]
public void ExteriorM47Mode_UsesExteriorConfig()
{
var config = DefaultConfig();
config.ExteriorM47 = M47Mode.BlockDelete;
config.InteriorM47 = M47Mode.Always;
var ctx = SimpleContext();
ctx.IsExteriorFeature = true;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.Contains("/M47", output);
}
[Fact]
public void OutputSequence_CorrectOrder()
{
var config = DefaultConfig();
var ctx = SimpleContext();
var output = WriteFeature(config, ctx);
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
// Find indices of key lines
var rapidIdx = Array.FindIndex(lines, l => l.Contains("G0X"));
var partIdx = Array.FindIndex(lines, l => l.Contains("PART:"));
var g89Idx = Array.FindIndex(lines, l => l.Contains("G89"));
var g84Idx = Array.FindIndex(lines, l => l.Contains("G84"));
var m130Idx = Array.FindIndex(lines, l => l.Contains("M130"));
var g40Idx = Array.FindIndex(lines, l => l.Contains("G40"));
var m35Idx = Array.FindIndex(lines, l => l.Contains("M35"));
var m131Idx = Array.FindIndex(lines, l => l.Contains("M131"));
var m47Idx = Array.FindIndex(lines, l => l.Trim() == "M47");
Assert.True(rapidIdx < partIdx, "Rapid should come before part comment");
Assert.True(partIdx < g89Idx, "Part comment should come before G89");
Assert.True(g89Idx < g84Idx, "G89 should come before G84");
Assert.True(g84Idx < m130Idx, "G84 should come before M130");
Assert.True(g40Idx < m35Idx, "G40 should come before M35");
Assert.True(m35Idx < m131Idx, "M35 should come before M131");
Assert.True(m131Idx < m47Idx, "M131 should come before M47");
}
private static int CountOccurrences(string text, string pattern)
{
var count = 0;
var idx = 0;
while ((idx = text.IndexOf(pattern, idx, StringComparison.Ordinal)) != -1)
{
count++;
idx += pattern.Length;
}
return count;
}
}
@@ -0,0 +1,322 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class CincinnatiPostProcessorTests
{
[Fact]
public void Post_ProducesOutput_ForSinglePlateNest()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig
{
ConfigurationName = "CL940",
DefaultLibraryFile = "MS135N2PANEL.lib",
PostedAccuracy = 4
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Main program elements
Assert.Contains("( NEST TestNest )", output);
Assert.Contains("( CONFIGURATION - CL940 )", output);
Assert.Contains("G20", output);
Assert.Contains("M30 (END OF MAIN)", output);
// Variable declaration
Assert.Contains(":100", output);
Assert.Contains("#126=", output);
// Sheet subprogram
Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output);
Assert.Contains("G84", output);
Assert.Contains("M99", output);
}
[Fact]
public void Post_ImplementsIPostProcessor()
{
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig());
IPostProcessor pp = post;
Assert.Equal("Cincinnati CL-707", pp.Name);
Assert.Equal("OpenNest", pp.Author);
}
[Fact]
public void Post_SkipsEmptyPlates()
{
var nest = new Nest("TestNest");
nest.Plates.Add(new Plate(48, 96)); // empty plate
var plate2 = new Plate(48, 96);
plate2.Parts.Add(new Part(new Drawing("Part1", CreateSquareProgram())));
nest.Plates.Add(plate2);
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Should only have one sheet subprogram call in main
Assert.Contains("N1M98 P101 (SHEET 1)", output);
Assert.DoesNotContain("SHEET 2", output);
}
[Fact]
public void Post_ToFile_CreatesFile()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var post = new CincinnatiPostProcessor(config);
var tempFile = Path.GetTempFileName() + ".CNC";
try
{
post.Post(nest, tempFile);
Assert.True(File.Exists(tempFile));
var content = File.ReadAllText(tempFile);
Assert.Contains("M30", content);
}
finally
{
if (File.Exists(tempFile))
File.Delete(tempFile);
}
}
[Fact]
public void Config_RoundTripsAsJson()
{
var config = new CincinnatiPostConfig
{
ConfigurationName = "CL940_CORONA",
DefaultLibraryFile = "MS135N2PANEL.lib",
PostedUnits = Units.Inches,
KerfCompensation = KerfMode.ControllerSide,
UseAntiDive = true
};
var opts = new JsonSerializerOptions
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
var json = JsonSerializer.Serialize(config, opts);
var deserialized = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, opts);
Assert.Equal("CL940_CORONA", deserialized.ConfigurationName);
Assert.Equal("MS135N2PANEL.lib", deserialized.DefaultLibraryFile);
Assert.Equal(Units.Inches, deserialized.PostedUnits);
Assert.Equal(KerfMode.ControllerSide, deserialized.KerfCompensation);
Assert.True(deserialized.UseAntiDive);
// Enums serialize as strings
Assert.Contains("\"Inches\"", json);
Assert.Contains("\"ControllerSide\"", json);
}
[Fact]
public void ParameterlessConstructor_LoadsOrCreatesConfig()
{
// The parameterless constructor reads from a .json file next to the assembly,
// or creates defaults if none exists. Either way, Config should be non-null.
var post = new CincinnatiPostProcessor();
Assert.NotNull(post.Config);
Assert.Equal("CL940", post.Config.ConfigurationName);
}
[Fact]
public void Post_WithPartSubprograms_WritesM98Calls()
{
var nest = CreateTestNest();
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
UsePartSubprograms = true,
PartSubprogramStart = 200
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Sheet should contain M98 call to part sub-program
Assert.Contains("M98P200", output);
// Should have G92 for local coordinate positioning
Assert.Contains("G92X0Y0", output);
// Part sub-program definition
Assert.Contains(":200", output);
Assert.Contains("G84", output);
// Sub-program ends with G0X0Y0 and M99
Assert.Contains("G0X0Y0", output);
Assert.Contains("M99(END OF Square)", output);
// G92 restore after M98 call
Assert.Contains("G92X", output);
}
[Fact]
public void Post_WithPartSubprograms_ReusesSameSubprogram()
{
var nest = new Nest("TestNest");
var drawing = new Drawing("Square", CreateSquareProgram());
var plate = new Plate(48, 96);
plate.Parts.Add(new Part(drawing, new Vector(5, 5)));
plate.Parts.Add(new Part(drawing, new Vector(20, 5)));
nest.Plates.Add(plate);
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
UsePartSubprograms = true,
PartSubprogramStart = 200
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Both parts should call the same sub-program
var m98Count = System.Text.RegularExpressions.Regex.Matches(output, "M98P200").Count;
Assert.Equal(2, m98Count);
// Only one sub-program definition
var subDefCount = System.Text.RegularExpressions.Regex.Matches(output, ":200").Count;
Assert.Equal(1, subDefCount);
}
[Fact]
public void Post_WithPartSubprograms_DifferentRotationsGetSeparateSubprograms()
{
var nest = new Nest("TestNest");
var drawing = new Drawing("Square", CreateSquareProgram());
var plate = new Plate(48, 96);
var part1 = new Part(drawing, new Vector(5, 5));
plate.Parts.Add(part1);
var part2 = new Part(drawing, new Vector(20, 5));
part2.Rotate(System.Math.PI / 2); // 90 degrees
plate.Parts.Add(part2);
nest.Plates.Add(plate);
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
UsePartSubprograms = true,
PartSubprogramStart = 200
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Should have two different sub-programs
Assert.Contains(":200", output);
Assert.Contains(":201", output);
Assert.Contains("M98P200", output);
Assert.Contains("M98P201", output);
}
[Fact]
public void Post_WithPartSubprograms_CutoffsAreInline()
{
var nest = new Nest("TestNest");
var drawing = new Drawing("Square", CreateSquareProgram());
var cutoffDrawing = new Drawing("CutOff", CreateSquareProgram()) { IsCutOff = true };
var plate = new Plate(48, 96);
plate.Parts.Add(new Part(drawing, new Vector(5, 5)));
plate.Parts.Add(new Part(cutoffDrawing, new Vector(0, 30)));
nest.Plates.Add(plate);
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
UsePartSubprograms = true,
PartSubprogramStart = 200
};
var post = new CincinnatiPostProcessor(config);
using var ms = new MemoryStream();
post.Post(nest, ms);
var output = Encoding.UTF8.GetString(ms.ToArray());
// Regular part uses sub-program
Assert.Contains("M98P200", output);
Assert.Contains(":200", output);
// Cutoff should NOT have its own sub-program
Assert.DoesNotContain(":201", output);
}
[Fact]
public void Post_WithPartSubprograms_ConfigRoundTrips()
{
var config = new CincinnatiPostConfig
{
UsePartSubprograms = true,
PartSubprogramStart = 300
};
var opts = new JsonSerializerOptions
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
var json = JsonSerializer.Serialize(config, opts);
var deserialized = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, opts);
Assert.True(deserialized.UsePartSubprograms);
Assert.Equal(300, deserialized.PartSubprogramStart);
}
private static Nest CreateTestNest()
{
var nest = new Nest("TestNest");
var drawing = new Drawing("Square", CreateSquareProgram());
nest.Drawings.Add(drawing);
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(drawing, new Vector(10, 10)));
nest.Plates.Add(plate);
return nest;
}
private static Program CreateSquareProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(2, 0));
pgm.Codes.Add(new LinearMove(2, 2));
pgm.Codes.Add(new LinearMove(0, 2));
pgm.Codes.Add(new LinearMove(0, 0));
return pgm;
}
}
@@ -0,0 +1,98 @@
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class CincinnatiPreambleWriterTests
{
[Fact]
public void WriteMainProgram_EmitsHeader()
{
var config = new CincinnatiPostConfig
{
ConfigurationName = "CL940",
PostedUnits = Units.Inches,
DefaultLibraryFile = "MS135N2PANEL.lib"
};
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "TestNest", "Mild Steel, 10GA", 2);
var output = sb.ToString();
Assert.Contains("( NEST TestNest )", output);
Assert.Contains("( CONFIGURATION - CL940 )", output);
Assert.Contains("G20", output);
Assert.Contains("M42", output);
Assert.Contains("G89 P MS135N2PANEL.lib", output);
Assert.Contains("M98 P100 (Variable Declaration)", output);
Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output);
Assert.Contains("N1M98 P101 (SHEET 1)", output);
Assert.Contains("N2M98 P102 (SHEET 2)", output);
Assert.Contains("M30 (END OF MAIN)", output);
}
[Fact]
public void WriteMainProgram_EmitsG21ForMetric()
{
var config = new CincinnatiPostConfig { PostedUnits = Units.Millimeters };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1);
Assert.Contains("G21", sb.ToString());
}
[Fact]
public void WriteMainProgram_EmitsG61_WhenExactStop()
{
var config = new CincinnatiPostConfig { UseExactStopMode = true };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1);
Assert.Contains("G61", sb.ToString());
}
[Fact]
public void WriteMainProgram_OmitsG61_WhenNotExactStop()
{
var config = new CincinnatiPostConfig { UseExactStopMode = false };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1);
Assert.DoesNotContain("G61", sb.ToString());
}
[Fact]
public void WriteVariableDeclaration_EmitsSubprogram()
{
var config = new CincinnatiPostConfig();
var vars = new ProgramVariableManager();
vars.GetOrCreate("LeadInFeedrate", 126, "[#148*0.5]");
vars.GetOrCreate("CircleFeedrate", 128, ".8");
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
writer.WriteVariableDeclaration(sw, vars);
var output = sb.ToString();
Assert.Contains(":100", output);
Assert.Contains("(Variable Declaration Start)", output);
Assert.Contains("#126=", output);
Assert.Contains("#128=", output);
Assert.Contains("M99 (Variable Declaration End)", output);
}
}
@@ -0,0 +1,117 @@
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class CincinnatiSheetWriterTests
{
[Fact]
public void WriteSheet_EmitsSheetHeader()
{
var config = new CincinnatiPostConfig
{
DefaultLibraryFile = "MS135N2PANEL.lib",
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101);
var output = sb.ToString();
Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output);
Assert.Contains("#110=", output);
Assert.Contains("#111=", output);
Assert.Contains("G92X#5021Y#5022", output);
Assert.Contains("M99", output);
}
[Fact]
public void WriteSheet_EmitsReturnToOriginAndPalletExchange()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.EndOfSheet,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101);
var output = sb.ToString();
Assert.Contains("M42", output);
Assert.Contains("G0X0Y0", output);
Assert.Contains("M50", output);
}
[Fact]
public void WriteSheet_SkipsEmptyPlate()
{
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var plate = new Plate(48.0, 96.0);
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101);
Assert.Equal("", sb.ToString());
}
[Fact]
public void WriteSheet_SplitsMultiContourParts()
{
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
var pgm = new Program();
// First contour (hole)
pgm.Codes.Add(new RapidMove(1, 1));
pgm.Codes.Add(new LinearMove(2, 1));
pgm.Codes.Add(new LinearMove(2, 2));
pgm.Codes.Add(new LinearMove(1, 1));
// Second contour (exterior)
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new LinearMove(5, 5));
pgm.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("MultiContour", pgm)));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101);
var output = sb.ToString();
// Should have two G84 pierce commands (one per contour)
var g84Count = output.Split('\n').Count(l => l.Trim() == "G84");
Assert.Equal(2, g84Count);
}
private static Program CreateSimpleProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(1, 0));
pgm.Codes.Add(new LinearMove(1, 1));
pgm.Codes.Add(new LinearMove(0, 1));
pgm.Codes.Add(new LinearMove(0, 0));
return pgm;
}
}
@@ -1,4 +1,4 @@
using OpenNest.Posts.Cincinnati;
using OpenNest.CNC;
namespace OpenNest.Tests.Cincinnati;
@@ -6,7 +6,8 @@ public class SpeedClassifierTests
{
[Theory]
[InlineData(20.0, 10.0, "FAST")]
[InlineData(5.0, 10.0, "MEDIUM")]
[InlineData(5.0, 10.0, "FAST")]
[InlineData(4.9, 10.0, "MEDIUM")]
[InlineData(0.5, 10.0, "SLOW")]
public void Classify_ReturnsExpectedClass(double contourLength, double sheetDiagonal, string expected)
{
+28 -25
View File
@@ -33,6 +33,7 @@ namespace OpenNest.Controls
private CutOffSettings cutOffSettings = new CutOffSettings();
private CutOff selectedCutOff;
private bool draggingCutOff;
private Dictionary<Part, Geometry.Entity> dragPerimeterCache;
protected List<LayoutPart> parts;
private List<LayoutPart> stationaryParts = new List<LayoutPart>();
private List<LayoutPart> activeParts = new List<LayoutPart>();
@@ -242,6 +243,7 @@ namespace OpenNest.Controls
{
SelectedCutOff = hitCutOff;
draggingCutOff = true;
dragPerimeterCache = Plate.BuildPerimeterCache(Plate);
return;
}
else
@@ -270,6 +272,7 @@ namespace OpenNest.Controls
if (draggingCutOff && selectedCutOff != null)
{
draggingCutOff = false;
dragPerimeterCache = null;
Plate.RegenerateCutOffs(cutOffSettings);
Invalidate();
return;
@@ -333,7 +336,12 @@ namespace OpenNest.Controls
if (draggingCutOff && selectedCutOff != null)
{
selectedCutOff.Position = CurrentPoint;
if (selectedCutOff.Axis == CutOffAxis.Vertical)
selectedCutOff.Position = new Vector(CurrentPoint.X, selectedCutOff.Position.Y);
else
selectedCutOff.Position = new Vector(selectedCutOff.Position.X, CurrentPoint.Y);
selectedCutOff.Regenerate(Plate, cutOffSettings, dragPerimeterCache);
Invalidate();
return;
}
@@ -455,7 +463,6 @@ namespace OpenNest.Controls
DrawPlate(e.Graphics);
DrawParts(e.Graphics);
DrawCutOffs(e.Graphics);
DrawCutOffGrip(e.Graphics);
DrawActiveWorkArea(e.Graphics);
DrawDebugRemnants(e.Graphics);
@@ -612,7 +619,8 @@ namespace OpenNest.Controls
if (Plate?.CutOffs == null || Plate.CutOffs.Count == 0)
return;
using var pen = new Pen(Color.FromArgb(64, 64, 64), 1.5f / ViewScale);
using var pen = new Pen(Color.FromArgb(64, 64, 64), 1.5f);
using var selectedPen = new Pen(Color.FromArgb(0, 120, 255), 3.5f);
foreach (var cutoff in Plate.CutOffs)
{
@@ -620,35 +628,19 @@ namespace OpenNest.Controls
if (program == null || program.Codes.Count == 0)
continue;
var activePen = cutoff == selectedCutOff ? selectedPen : pen;
for (var i = 0; i < program.Codes.Count - 1; i += 2)
{
if (program.Codes[i] is RapidMove rapid &&
program.Codes[i + 1] is LinearMove linear)
{
DrawLine(g, rapid.EndPoint, linear.EndPoint, pen);
DrawLine(g, rapid.EndPoint, linear.EndPoint, activePen);
}
}
}
}
private void DrawCutOffGrip(Graphics g)
{
if (selectedCutOff == null)
return;
var radius = 4f / ViewScale;
var pos = selectedCutOff.Position;
var graphPt = PointWorldToGraph(pos);
var scaledRadius = LengthWorldToGui(radius);
g.FillEllipse(Brushes.DarkGray,
graphPt.X - scaledRadius, graphPt.Y - scaledRadius,
scaledRadius * 2, scaledRadius * 2);
g.DrawEllipse(Pens.Black,
graphPt.X - scaledRadius, graphPt.Y - scaledRadius,
scaledRadius * 2, scaledRadius * 2);
}
public CutOff GetCutOffAtPoint(Vector point, double tolerance)
{
if (Plate?.CutOffs == null)
@@ -656,9 +648,20 @@ namespace OpenNest.Controls
foreach (var cutoff in Plate.CutOffs)
{
var dist = cutoff.Position.DistanceTo(point);
if (dist <= tolerance)
return cutoff;
var program = cutoff.Drawing?.Program;
if (program == null)
continue;
for (var i = 0; i < program.Codes.Count - 1; i += 2)
{
if (program.Codes[i] is RapidMove rapid &&
program.Codes[i + 1] is LinearMove linear)
{
var line = new Geometry.Line(rapid.EndPoint, linear.EndPoint);
if (line.ClosestPointTo(point).DistanceTo(point) <= tolerance)
return cutoff;
}
}
}
return null;