feat(posts): add Cincinnati CI Fiber (TF5200) post-processor
New post-processor plugin OpenNest.Posts.CincinnatiCIFiber for the CI Fiber laser family (nLight CLX / Beckhoff TF5200 / Precitec ProCutter, e.g. the CI Fiber 4020 8kW). Named by machine family, not table size. Emits the machine program contract of the Cincinnati-supplied sample NC (12992-4SS_NEST.nc): V.E.* header, restart jump, per-part V.E.R4 blocks, per-contour N labels with V.E.R3, skippable /L macro lines (L0/L2+G41 interior, L4+G42 exterior, L6 cut-on, ZHSOFF cut-end), G162 incremental arc I/J, trimmed 3-decimal spaceless coordinates, CRLF, M50/M30/%. Contour classification (interior vs exterior) derives from the material side of the closed cut path, not hardcoded winding. SubProgramCall holes are flattened to sheet coordinates (rotation-safe). Arc lead-ins are rejected per TF5200 13.2.4.1 (first motion block after G41/G42 selection must be linear). Table envelope (default 160.25 x 81.25 in) validated. Tests: structure golden on a square-with-hole nest, rotated-hole flatten, coordinate format, arc-lead-in rejection, table validation, suppressed/ scribe skipping, plus a SkippableFact regression against the real sample NC (109 parts, 2071 contours, L2=1962, L4=109, perimeter vertices match within 0.001). Fixtures configure through OpenNest.Tests/test-config.json and the regression skips when absent.
This commit is contained in:
@@ -0,0 +1,283 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.CincinnatiCIFiber;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CincinnatiCIFiber;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output-contract tests for the Cincinnati CI Fiber post, checked against the
|
||||||
|
/// structure of the machine sample NC (12992-4SS_NEST.nc): header block,
|
||||||
|
/// restart jump, N-labelled contour blocks with /L macro lines, G41/G42
|
||||||
|
/// selection, trimmed coordinate format and the M50/M30/% tail.
|
||||||
|
/// </summary>
|
||||||
|
public class CIFiberPostProcessorTests
|
||||||
|
{
|
||||||
|
private static CIFiberPostConfig MakeConfig() =>
|
||||||
|
new() { ConfigurationName = "CI FIBER 8K" };
|
||||||
|
|
||||||
|
private static string Post(Nest nest, CIFiberPostConfig? config = null)
|
||||||
|
{
|
||||||
|
var post = new CIFiberPostProcessor(config ?? MakeConfig());
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
post.Post(nest, ms);
|
||||||
|
return Encoding.UTF8.GetString(ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 10x10 CW square perimeter (closed loop, no leading rapid) at the plate
|
||||||
|
/// location, with one CCW full-circle hole posted as an incremental
|
||||||
|
/// SubProgramCall at (5,5) — the exact shape OpenNest nests produce.
|
||||||
|
/// </summary>
|
||||||
|
private static Nest MakeSquareWithHoleNest(Vector? partLocation = null)
|
||||||
|
{
|
||||||
|
var location = partLocation ?? new Vector(1.0, 2.0);
|
||||||
|
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
|
||||||
|
// Hole sub-program: CCW full circle r=0.5 centred on the call offset,
|
||||||
|
// linear lead-in from below, left absolute then flipped to
|
||||||
|
// incremental — exactly what ContourCuttingStrategy emits for a hole.
|
||||||
|
var hole = new Program(Mode.Absolute);
|
||||||
|
hole.Codes.Add(new RapidMove(new Vector(0, -0.6)));
|
||||||
|
hole.Codes.Add(new LinearMove(new Vector(0, -0.5)) { Layer = LayerType.Leadin });
|
||||||
|
hole.Codes.Add(
|
||||||
|
new ArcMove(new Vector(0, -0.5), new Vector(0, 0), RotationType.CCW)
|
||||||
|
{
|
||||||
|
Layer = LayerType.Cut,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
hole.Mode = Mode.Incremental;
|
||||||
|
pgm.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(5, 5) });
|
||||||
|
|
||||||
|
// Perimeter: linear lead-in into a CW loop starting at (0,0).
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, -0.5)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Leadin });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
|
||||||
|
var drawing = new Drawing("square-hole", pgm);
|
||||||
|
var part = new Part(drawing, location);
|
||||||
|
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
|
||||||
|
var nest = new Nest("Test Nest") { Thickness = 0.06, Material = new Material("Mild Steel") };
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> Lines(string output) =>
|
||||||
|
output.Replace("\r\n", "\n").Split('\n').ToList();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_Structure_GoldenSquareWithHole()
|
||||||
|
{
|
||||||
|
var output = Post(MakeSquareWithHoleNest());
|
||||||
|
var lines = Lines(output);
|
||||||
|
|
||||||
|
// File ends with % and uses CRLF throughout, no BOM.
|
||||||
|
Assert.StartsWith("(", output);
|
||||||
|
Assert.EndsWith("%\r\n", output);
|
||||||
|
foreach (var raw in output.Split('\n').SkipLast(1))
|
||||||
|
Assert.EndsWith("\r", raw);
|
||||||
|
|
||||||
|
// Header block
|
||||||
|
Assert.Equal("( Test Nest )", lines[0]);
|
||||||
|
Assert.Equal("( CONFIGURATION - CI FIBER 8K )", lines[1]);
|
||||||
|
Assert.Matches(@"^\( \w+ \d+, \d{4} \d\d:\d\d (AM|PM) \)$", lines[2]);
|
||||||
|
Assert.Equal("( Material = Mild Steel .060 )", lines[3]);
|
||||||
|
Assert.Equal("V.E.MATERIAL = \"MSN\"", lines[4]);
|
||||||
|
Assert.Equal("V.E.THICKNESS = 0.060", lines[5]);
|
||||||
|
Assert.Equal("V.E.X_SIZE = 120.000", lines[6]);
|
||||||
|
Assert.Equal("V.E.Y_SIZE = 60.000", lines[7]);
|
||||||
|
Assert.Equal("V.E.UNIT = 1", lines[8]);
|
||||||
|
Assert.DoesNotContain("V.E.SHEET_WEIGHT", output);
|
||||||
|
|
||||||
|
// Restart preamble
|
||||||
|
Assert.Equal("G90", lines[9]);
|
||||||
|
Assert.Equal("L PROGRAMSTART.NC", lines[10]);
|
||||||
|
Assert.Equal("P3=V.E.R3", lines[11]);
|
||||||
|
Assert.Equal("$GOTO NP3:", lines[12]);
|
||||||
|
Assert.Equal("N0:", lines[13]);
|
||||||
|
Assert.Equal("( Sheet number - 1 )", lines[14]);
|
||||||
|
|
||||||
|
// Part block
|
||||||
|
Assert.Equal("( Part #1 )", lines[15]);
|
||||||
|
Assert.Equal("( PART:square-hole )", lines[16]);
|
||||||
|
Assert.Equal("V.E.R4=1", lines[17]);
|
||||||
|
|
||||||
|
// Contour 1 = the flattened hole (part program order).
|
||||||
|
Assert.Equal("N1:", lines[18]);
|
||||||
|
Assert.Equal("/L \"L0\"", lines[19]);
|
||||||
|
Assert.Equal("V.E.R3=1", lines[20]);
|
||||||
|
Assert.Equal("G0X6Y6.4", lines[21]); // pierce = part(1,2) + hole(5,5) + (0,-0.6)
|
||||||
|
Assert.Equal("/L \"L2\"", lines[22]);
|
||||||
|
Assert.Equal("G41", lines[23]);
|
||||||
|
Assert.Equal("G1X6Y6.5", lines[24]); // contour start = hole centre + (0,-0.5)
|
||||||
|
Assert.Equal("/L \"L6\"", lines[25]);
|
||||||
|
Assert.Equal("G3X6Y6.5I0J0.5", lines[26]); // I/J incremental from arc start
|
||||||
|
Assert.Equal("/L \"ZHSOFF\"", lines[27]);
|
||||||
|
|
||||||
|
// Contour 2 = perimeter.
|
||||||
|
Assert.Equal("N2:", lines[28]);
|
||||||
|
Assert.Equal("/L \"L0\"", lines[29]);
|
||||||
|
Assert.Equal("V.E.R3=2", lines[30]);
|
||||||
|
Assert.Equal("G0X1Y1.5", lines[31]);
|
||||||
|
Assert.Equal("/L \"L4\"", lines[32]);
|
||||||
|
Assert.Equal("G42", lines[33]);
|
||||||
|
Assert.Equal("G1X1Y2", lines[34]);
|
||||||
|
Assert.Equal("/L \"L6\"", lines[35]);
|
||||||
|
Assert.Equal("G1X1Y12", lines[36]);
|
||||||
|
Assert.Equal("G1X11Y12", lines[37]);
|
||||||
|
Assert.Equal("G1X11Y2", lines[38]);
|
||||||
|
Assert.Equal("G1X1Y2", lines[39]);
|
||||||
|
Assert.Equal("/L \"ZHSOFF\"", lines[40]);
|
||||||
|
|
||||||
|
// Part end and tail
|
||||||
|
Assert.Equal("( PART END )", lines[41]);
|
||||||
|
Assert.Equal("/L \"L0\"", lines[42]);
|
||||||
|
Assert.Equal("L PROGRAMEND.NC", lines[43]);
|
||||||
|
Assert.Equal("M50", lines[44]);
|
||||||
|
Assert.Equal("M30", lines[45]);
|
||||||
|
Assert.Equal("%", lines[46]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_FlattensRotatedPartHole_ToSheetCoords()
|
||||||
|
{
|
||||||
|
// Same nest, but the part is rotated 90 degrees CCW about the origin:
|
||||||
|
// (x,y) -> (-y,x). Location (1,2) -> (-2,1); hole offset (5,5) -> (-5,5).
|
||||||
|
var nest = MakeSquareWithHoleNest();
|
||||||
|
var part = nest.Plates[0].Parts[0];
|
||||||
|
part.Rotate(OpenNest.Math.Angle.ToRadians(90));
|
||||||
|
|
||||||
|
var output = Post(nest);
|
||||||
|
var lines = Lines(output);
|
||||||
|
|
||||||
|
// Hole pierce local (0,-0.6) -> (0.6,0): (-2,1)+(-5,5)+(0.6,0) = (-6.4,6).
|
||||||
|
Assert.Equal("G0X-6.4Y6", lines[21]);
|
||||||
|
// Contour start local (0,-0.5) -> (0.5,0) = (-6.5,6).
|
||||||
|
Assert.Equal("G1X-6.5Y6", lines[24]);
|
||||||
|
// Full CCW circle, centre local (0,0.5) from the start -> (-0.5,0):
|
||||||
|
// centre at (-7,6), so I=-0.5 J=0.
|
||||||
|
Assert.Equal("G3X-6.5Y6I-0.5J0", lines[26]);
|
||||||
|
// Perimeter corner local (0,10) -> (-10,0): pierce (-2,1)+(0.5,0)...
|
||||||
|
// pierce local (0,-0.5)->(0.5,0) = (-1.5,1); corner (-10,1)... check
|
||||||
|
// first cut G1 target = lead-in end = (-2,1).
|
||||||
|
Assert.Equal("G0X-1.5Y1", lines[31]);
|
||||||
|
Assert.Equal("G1X-2Y1", lines[34]);
|
||||||
|
Assert.Equal("G1X-12Y1", lines[36]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_CoordinateFormat_TrimsZerosAndNeverNegativeZero()
|
||||||
|
{
|
||||||
|
// Part placed so a coordinate lands on -0.0004 (rounds to -0) and on
|
||||||
|
// values whose 3-decimal representation keeps trailing digits.
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(-0.0004, 3.5009)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5, 3.5009)) { Layer = LayerType.Leadin });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(5, 4)) { Layer = LayerType.Cut });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(-0.0004, 4)) { Layer = LayerType.Cut });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(-0.0004, 3.5009)) { Layer = LayerType.Cut });
|
||||||
|
|
||||||
|
var drawing = new Drawing("fmt", pgm);
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
plate.Parts.Add(new Part(drawing, Vector.Zero));
|
||||||
|
var nest = new Nest("fmt") { Thickness = 0.06, Material = new Material("Mild Steel") };
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
|
||||||
|
var output = Post(nest);
|
||||||
|
|
||||||
|
Assert.Contains("G0X0Y3.501", output); // -0.0004 -> "0" not "-0"; 3.5009 -> 3.501
|
||||||
|
Assert.Contains("G1X5Y3.501", output);
|
||||||
|
Assert.DoesNotContain("-0Y", output);
|
||||||
|
Assert.DoesNotContain("-0\r", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_RejectsArcLeadIn_PerTf5200LinearFirstRule()
|
||||||
|
{
|
||||||
|
// A contour whose first move after the comp selection is an arc must
|
||||||
|
// be rejected (TF5200 13.2.4.1: first motion block after selection
|
||||||
|
// must be LINEAR).
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new ArcMove(0, 0, 0, 0.5, RotationType.CCW) { Layer = LayerType.Cut });
|
||||||
|
|
||||||
|
var drawing = new Drawing("arcdirect", pgm);
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
plate.Parts.Add(new Part(drawing, Vector.Zero));
|
||||||
|
var nest = new Nest("arc") { Thickness = 0.06, Material = new Material("Mild Steel") };
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => Post(nest));
|
||||||
|
Assert.Contains("13.2.4.1", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_ValidatesTableSize()
|
||||||
|
{
|
||||||
|
var nest = MakeSquareWithHoleNest();
|
||||||
|
|
||||||
|
var tight = MakeConfig();
|
||||||
|
tight.MaxTableX = 100; // plate length is 120
|
||||||
|
Assert.Contains("exceeds maximum table X", Assert.Throws<InvalidOperationException>(() => Post(nest, tight)).Message);
|
||||||
|
|
||||||
|
var wide = MakeConfig();
|
||||||
|
wide.MaxTableX = 160.25;
|
||||||
|
wide.MaxTableY = 81.25;
|
||||||
|
Post(nest, wide); // does not throw
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Post_SkipsSuppressedAndScribeMoves()
|
||||||
|
{
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
|
||||||
|
// Suppressed contour (every move suppressed, as suppression marks a
|
||||||
|
// whole feature) must not appear.
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(30, 30)) { Suppressed = true });
|
||||||
|
pgm.Codes.Add(
|
||||||
|
new LinearMove(new Vector(31, 30)) { Layer = LayerType.Leadin, Suppressed = true }
|
||||||
|
);
|
||||||
|
pgm.Codes.Add(
|
||||||
|
new LinearMove(new Vector(31, 31)) { Layer = LayerType.Cut, Suppressed = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scribe contour skipped by default.
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(40, 40)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(41, 40)) { Layer = LayerType.Leadin });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(41, 41)) { Layer = LayerType.Scribe });
|
||||||
|
|
||||||
|
// Cutting contour that must appear.
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(50, 50)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(51, 50)) { Layer = LayerType.Leadin });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(51, 51)) { Layer = LayerType.Cut });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 51)) { Layer = LayerType.Cut });
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 50)) { Layer = LayerType.Cut });
|
||||||
|
|
||||||
|
var drawing = new Drawing("layers", pgm);
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
plate.Parts.Add(new Part(drawing, Vector.Zero));
|
||||||
|
var nest = new Nest("layers") { Thickness = 0.06, Material = new Material("Mild Steel") };
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
|
||||||
|
var output = Post(nest);
|
||||||
|
var lines = Lines(output);
|
||||||
|
|
||||||
|
// Exactly one contour survived (N0: is the restart-jump label).
|
||||||
|
Assert.Single(lines.Where(l => l.StartsWith("N") && l != "N0:" && l.EndsWith(":")));
|
||||||
|
Assert.DoesNotContain("X30Y30", output);
|
||||||
|
Assert.DoesNotContain("X40Y40", output);
|
||||||
|
Assert.Contains("G0X50Y50", output);
|
||||||
|
Assert.Contains("V.E.R3=1", output);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Sequencing;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Posts.CincinnatiCIFiber;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CincinnatiCIFiber;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression against the real machine program: posts the reconstructed
|
||||||
|
/// 12992-4SS nest (same part + layout as the sample NC) and compares contour
|
||||||
|
/// counts, macro counts and perimeter geometry with 12992-4SS_NEST.nc.
|
||||||
|
///
|
||||||
|
/// The fixture lives outside the repository (/srv/shared). Configure it in
|
||||||
|
/// OpenNest.Tests/test-config.json:
|
||||||
|
/// { "CIFiberSampleNcPath": "/srv/shared/.../12992-4SS_NEST.nc",
|
||||||
|
/// "CIFiberReconstructedNestPath": "/srv/shared/.../12992-4SS_NEST_reconstructed.nest" }
|
||||||
|
/// Both must exist for the test to run; otherwise it skips.
|
||||||
|
/// </summary>
|
||||||
|
public class CIFiberSampleRegressionTests
|
||||||
|
{
|
||||||
|
private const int ExpectedParts = 109;
|
||||||
|
private const int ExpectedContours = 2071;
|
||||||
|
private const int ExpectedL2 = 1962;
|
||||||
|
private const int ExpectedL4 = 109;
|
||||||
|
|
||||||
|
private static (string Nc, string Nest)? ResolveFixture()
|
||||||
|
{
|
||||||
|
var nc = TestConfig.GetExistingPath("CIFiberSampleNcPath");
|
||||||
|
var nest = TestConfig.GetExistingPath("CIFiberReconstructedNestPath");
|
||||||
|
return nc != null && nest != null ? (nc, nest) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Nest LoadAndLeadIn(string nestPath)
|
||||||
|
{
|
||||||
|
var reader = new NestReader(nestPath);
|
||||||
|
var nest = reader.Read();
|
||||||
|
|
||||||
|
var plate = nest.Plates[0];
|
||||||
|
plate.CuttingParameters = new CuttingParameters
|
||||||
|
{
|
||||||
|
// The sample cuts clean holes with a short perpendicular linear
|
||||||
|
// lead (pierce ~0.079 outside the contour), including the circles
|
||||||
|
// (ArcCircleLeadIn). TF5200 13.2.4.1 requires a LINEAR first
|
||||||
|
// motion block after G41/G42, so holes must have linear leads.
|
||||||
|
InternalLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
|
||||||
|
ExternalLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
|
||||||
|
PierceClearance = 0.02,
|
||||||
|
};
|
||||||
|
|
||||||
|
new LeadInAssigner { Sequencer = new LeftSideSequencer() }.Assign(plate);
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PostNest(Nest nest)
|
||||||
|
{
|
||||||
|
var post = new CIFiberPostProcessor(
|
||||||
|
new CIFiberPostConfig { ConfigurationName = "CI FIBER 8K" }
|
||||||
|
);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
post.Post(nest, ms);
|
||||||
|
return Encoding.UTF8.GetString(ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, int> CountMacroLines(string nc, string macro)
|
||||||
|
{
|
||||||
|
var needle = $"/L \"{macro}\"";
|
||||||
|
return new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
[macro] = Regex.Matches(nc, Regex.Escape(needle)).Count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<(double X, double Y)> ParseG1Points(string nc)
|
||||||
|
{
|
||||||
|
var result = new List<(double, double)>();
|
||||||
|
foreach (Match m in Regex.Matches(nc, @"G1X(-?[\d.]+)Y(-?[\d.]+)"))
|
||||||
|
{
|
||||||
|
result.Add(
|
||||||
|
(
|
||||||
|
double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
|
||||||
|
double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Post_ReconstructedNest_MatchesSampleCounts()
|
||||||
|
{
|
||||||
|
var fixture = ResolveFixture();
|
||||||
|
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
|
||||||
|
|
||||||
|
var nest = LoadAndLeadIn(fixture.Value.Nest);
|
||||||
|
var output = PostNest(nest).Replace("\r\n", "\n");
|
||||||
|
var sample = File.ReadAllText(fixture.Value.Nc).Replace("\r\n", "\n");
|
||||||
|
|
||||||
|
// Parts
|
||||||
|
var partCount = Regex.Matches(output, @"^\( Part #\d+ \)$", RegexOptions.Multiline).Count;
|
||||||
|
Assert.Equal(ExpectedParts, partCount);
|
||||||
|
|
||||||
|
// Contour labels (skip the N0: restart label)
|
||||||
|
var labels = Regex
|
||||||
|
.Matches(output, @"^N(\d+):$", RegexOptions.Multiline)
|
||||||
|
.Select(m => int.Parse(m.Groups[1].Value))
|
||||||
|
.ToList();
|
||||||
|
Assert.Equal(
|
||||||
|
ExpectedContours,
|
||||||
|
labels.Where(n => n != 0).Count()
|
||||||
|
);
|
||||||
|
Assert.Equal(
|
||||||
|
Enumerable.Range(1, ExpectedContours),
|
||||||
|
labels.Where(n => n != 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Macro counts. Sample: L0 = contours + 1 tail; the posted file uses
|
||||||
|
// the same one-per-contour + tail pattern.
|
||||||
|
Assert.Equal(ExpectedContours + 1, CountMacroLines(output, "L0")["L0"]);
|
||||||
|
Assert.Equal(ExpectedContours, CountMacroLines(output, "L6")["L6"]);
|
||||||
|
Assert.Equal(ExpectedContours, CountMacroLines(output, "ZHSOFF")["ZHSOFF"]);
|
||||||
|
|
||||||
|
// Interior/exterior split matches the sample: 109 exteriors (one
|
||||||
|
// perimeter per part), the remaining 1962 contours interior.
|
||||||
|
Assert.Equal(ExpectedL4, Regex.Matches(output, @"^/L ""L4""$", RegexOptions.Multiline).Count);
|
||||||
|
Assert.Equal(ExpectedL2, Regex.Matches(output, @"^/L ""L2""$", RegexOptions.Multiline).Count);
|
||||||
|
|
||||||
|
// Sample cross-check: same counts as the real machine program.
|
||||||
|
Assert.Equal(
|
||||||
|
ExpectedContours + 1,
|
||||||
|
CountMacroLines(sample, "L0")["L0"]
|
||||||
|
);
|
||||||
|
Assert.Equal(
|
||||||
|
ExpectedL2,
|
||||||
|
CountMacroLines(sample, "L2")["L2"]
|
||||||
|
);
|
||||||
|
Assert.Equal(
|
||||||
|
ExpectedL4,
|
||||||
|
CountMacroLines(sample, "L4")["L4"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Post_ReconstructedNest_PerimeterGeometryMatchesSample()
|
||||||
|
{
|
||||||
|
var fixture = ResolveFixture();
|
||||||
|
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
|
||||||
|
|
||||||
|
var nest = LoadAndLeadIn(fixture.Value.Nest);
|
||||||
|
var output = PostNest(nest).Replace("\r\n", "\n");
|
||||||
|
var sample = File.ReadAllText(fixture.Value.Nc).Replace("\r\n", "\n");
|
||||||
|
|
||||||
|
// Compare every exterior (L4) contour's CUT endpoints. Each perimeter
|
||||||
|
// is the same closed loop; the lead-in/contour start point may differ
|
||||||
|
// from the original CAM's, so per contour drop one occurrence of the
|
||||||
|
// start point (the lead-in end, which the closed loop re-targets on
|
||||||
|
// closure) and compare the remaining vertex multisets.
|
||||||
|
var samplePts = ExtractExteriorContourCutPoints(sample);
|
||||||
|
var postedPts = ExtractExteriorContourCutPoints(output);
|
||||||
|
|
||||||
|
Assert.NotEmpty(samplePts);
|
||||||
|
Assert.Equal(samplePts.Count, postedPts.Count);
|
||||||
|
|
||||||
|
var sampleKeys = MultisetKeys(samplePts);
|
||||||
|
var postedKeys = MultisetKeys(postedPts);
|
||||||
|
Assert.Equal(sampleKeys.Count, postedKeys.Count);
|
||||||
|
|
||||||
|
foreach (var (key, count) in sampleKeys)
|
||||||
|
Assert.True(
|
||||||
|
postedKeys.TryGetValue(key, out var postedCount) && postedCount == count,
|
||||||
|
$"Vertex {key} x{count} missing from posted output (found {postedKeys.GetValueOrDefault(key)})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Round to 0.001 and count occurrences (tolerance-bucketed multiset).</summary>
|
||||||
|
private static Dictionary<(long, long), int> MultisetKeys(List<(double X, double Y)> pts)
|
||||||
|
{
|
||||||
|
var keys = new Dictionary<(long, long), int>();
|
||||||
|
foreach (var (x, y) in pts)
|
||||||
|
{
|
||||||
|
var key = (
|
||||||
|
(long)System.Math.Round(x * 1000),
|
||||||
|
(long)System.Math.Round(y * 1000)
|
||||||
|
);
|
||||||
|
keys[key] = keys.GetValueOrDefault(key) + 1;
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each contour that used the L4 (exterior) lead-in macro, its cut
|
||||||
|
/// endpoints (after /L "L6"), minus one occurrence of the contour start
|
||||||
|
/// point (the lead-in target, which a closed loop re-hits on closure).
|
||||||
|
/// The result is a rotation-invariant vertex multiset of every perimeter.
|
||||||
|
/// </summary>
|
||||||
|
private static List<(double X, double Y)> ExtractExteriorContourCutPoints(string nc)
|
||||||
|
{
|
||||||
|
var all = new List<(double, double)>();
|
||||||
|
var inExterior = false;
|
||||||
|
var cutOn = false;
|
||||||
|
List<(double X, double Y)> current = null;
|
||||||
|
(double X, double Y)? start = null;
|
||||||
|
|
||||||
|
void FinishContour()
|
||||||
|
{
|
||||||
|
if (current != null && current.Count > 0)
|
||||||
|
{
|
||||||
|
var pts = current;
|
||||||
|
if (start.HasValue)
|
||||||
|
{
|
||||||
|
var idx = pts.FindIndex(
|
||||||
|
p =>
|
||||||
|
System.Math.Abs(p.X - start.Value.X) < 1e-4
|
||||||
|
&& System.Math.Abs(p.Y - start.Value.Y) < 1e-4
|
||||||
|
);
|
||||||
|
if (idx >= 0)
|
||||||
|
pts.RemoveAt(idx);
|
||||||
|
}
|
||||||
|
all.AddRange(pts);
|
||||||
|
}
|
||||||
|
current = null;
|
||||||
|
start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
(double, double)? ParsePoint(string line)
|
||||||
|
{
|
||||||
|
var m = Regex.Match(line, @"^G[0-3]X(-?[\d.]+)Y(-?[\d.]+)");
|
||||||
|
if (!m.Success)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
|
||||||
|
double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var raw in nc.Split('\n'))
|
||||||
|
{
|
||||||
|
var line = raw.TrimEnd('\r');
|
||||||
|
|
||||||
|
if (line == "/L \"L4\"")
|
||||||
|
{
|
||||||
|
FinishContour();
|
||||||
|
inExterior = true;
|
||||||
|
cutOn = false;
|
||||||
|
current = new List<(double, double)>();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line == "/L \"L2\"")
|
||||||
|
{
|
||||||
|
FinishContour();
|
||||||
|
inExterior = false;
|
||||||
|
cutOn = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line == "/L \"L6\"")
|
||||||
|
{
|
||||||
|
cutOn = inExterior;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line == "/L \"ZHSOFF\"")
|
||||||
|
{
|
||||||
|
FinishContour();
|
||||||
|
inExterior = false;
|
||||||
|
cutOn = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inExterior || current == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pt = ParsePoint(line);
|
||||||
|
if (pt == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (cutOn)
|
||||||
|
current.Add(pt.Value);
|
||||||
|
else if (start == null)
|
||||||
|
start = pt; // the linear lead-in move before L6
|
||||||
|
}
|
||||||
|
|
||||||
|
FinishContour();
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
||||||
|
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" />
|
||||||
<ProjectReference Include="..\Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
<ProjectReference Include="..\Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati",
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.CincinnatiCIFiber", "Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj", "{26810693-BFFA-4073-B04D-9E1B526935E8}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}"
|
||||||
@@ -196,6 +198,18 @@ Global
|
|||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -263,6 +277,7 @@ Global
|
|||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||||
|
{26810693-BFFA-4073-B04D-9E1B526935E8} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
|
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
|
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
|
||||||
|
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One contour (pierce + lead-in + cut path) reduced to sheet-absolute
|
||||||
|
/// moves, ready to emit as a TF5200 N-label block.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CIFiberContour
|
||||||
|
{
|
||||||
|
/// <summary>Pierce point (sheet-absolute) — rapid target.</summary>
|
||||||
|
public Vector Pierce { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lead-in move to the contour start (sheet-absolute endpoint), or
|
||||||
|
/// null when the contour begins cutting immediately at the pierce.
|
||||||
|
/// Per TF5200 §13.2.4.1 the first motion block after G41/G42 selection
|
||||||
|
/// must be LINEAR, so callers must reject contours whose lead-in is an
|
||||||
|
/// arc unless the config tolerates it.
|
||||||
|
/// </summary>
|
||||||
|
public Motion LeadIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Additional lead-in layer moves after the first (e.g. the arc that
|
||||||
|
/// follows CleanHoleLeadIn's line). Emitted before the cut layer turns
|
||||||
|
/// on, like the sample's lead block.
|
||||||
|
/// </summary>
|
||||||
|
public List<Motion> LeadInExtra { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Cutting moves (sheet-absolute) executed after the cut layer turns on.</summary>
|
||||||
|
public List<Motion> Cuts { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>True when the contour is an external perimeter (exterior).</summary>
|
||||||
|
public bool IsExterior { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts cut contours for one part in sheet coordinates.
|
||||||
|
/// Circular holes arrive as <see cref="SubProgramCall"/>s in an incremental
|
||||||
|
/// hole-local program; they are flattened here to sheet-absolute moves the
|
||||||
|
/// same way <see cref="NestPolylineExtractor"/> walks them for HPGL output.
|
||||||
|
/// Moves with <see cref="Motion.Suppressed"/> are skipped.
|
||||||
|
/// </summary>
|
||||||
|
public static class CIFiberContourExtractor
|
||||||
|
{
|
||||||
|
public static List<CIFiberContour> Extract(Part part)
|
||||||
|
{
|
||||||
|
if (part == null)
|
||||||
|
throw new ArgumentNullException(nameof(part));
|
||||||
|
|
||||||
|
var contours = new List<CIFiberContour>();
|
||||||
|
Flatten(part.Program, part.Location, Vector.Zero, contours);
|
||||||
|
return contours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walk one program whose coordinates are relative to (partLocation +
|
||||||
|
/// callOffset). Sub-program calls recurse with the extra offset.
|
||||||
|
/// A contour starts at each rapid move; moves before the first rapid
|
||||||
|
/// form a contour whose pierce is the last move's endpoint (closed-loop
|
||||||
|
/// continuation), matching FeatureUtils' synthetic-rapid convention.
|
||||||
|
/// </summary>
|
||||||
|
private static void Flatten(
|
||||||
|
Program program,
|
||||||
|
Vector partLocation,
|
||||||
|
Vector subOffset,
|
||||||
|
List<CIFiberContour> contours
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (program == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var origin = partLocation + subOffset;
|
||||||
|
|
||||||
|
// Work on a clone when conversion is needed so caller programs are
|
||||||
|
// never mutated (the CI sample inlines absolute moves).
|
||||||
|
var codes = program.Codes;
|
||||||
|
if (program.Mode == Mode.Incremental)
|
||||||
|
{
|
||||||
|
var clone = (Program)program.Clone();
|
||||||
|
clone.Mode = Mode.Absolute;
|
||||||
|
codes = clone.Codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
CIFiberContour current = null;
|
||||||
|
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
if (current != null && (current.LeadIn != null || current.Cuts.Count > 0))
|
||||||
|
contours.Add(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (code is Motion suppressed && suppressed.Suppressed)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case RapidMove rapid:
|
||||||
|
Flush();
|
||||||
|
current = new CIFiberContour { Pierce = rapid.EndPoint + origin };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SubProgramCall call:
|
||||||
|
// A hole call is its own contour: flatten into a temp
|
||||||
|
// list, then merge as one contour anchored at the call
|
||||||
|
// offset.
|
||||||
|
Flush();
|
||||||
|
var sub = new List<CIFiberContour>();
|
||||||
|
Flatten(call.Program, partLocation, subOffset + call.Offset, sub);
|
||||||
|
contours.AddRange(sub);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Motion motion:
|
||||||
|
if (current == null)
|
||||||
|
{
|
||||||
|
// Contour with no explicit rapid (program starts
|
||||||
|
// mid-contour): in absolute mode the implicit
|
||||||
|
// start position is the frame origin, which is the
|
||||||
|
// closure point of a closed contour = the pierce.
|
||||||
|
current = new CIFiberContour { Pierce = origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolute = (Motion)motion.Clone();
|
||||||
|
absolute.Offset(origin);
|
||||||
|
|
||||||
|
var layer = LayerOf(motion);
|
||||||
|
if (layer == LayerType.Leadin)
|
||||||
|
{
|
||||||
|
// First lead-in move opens the lead block; further
|
||||||
|
// lead-in moves (CleanHole line+arc) extend it.
|
||||||
|
if (current.LeadIn == null)
|
||||||
|
current.LeadIn = absolute;
|
||||||
|
else
|
||||||
|
current.LeadInExtra.Add(absolute);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Cut, Leadout and Display all cut: OpenNest tags
|
||||||
|
// contour moves Display by default (see
|
||||||
|
// ContourCuttingStrategy.ConvertShapeToMoves),
|
||||||
|
// only Scribe is reliably a mark. Scribe contours
|
||||||
|
// are dropped by the writer when SkipScribe is on.
|
||||||
|
current.Cuts.Add(absolute);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LayerType LayerOf(Motion motion)
|
||||||
|
{
|
||||||
|
return motion switch
|
||||||
|
{
|
||||||
|
LinearMove l => l.Layer,
|
||||||
|
ArcMove a => a.Layer,
|
||||||
|
_ => LayerType.Cut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Formats numeric values for the CI Fiber machine program.
|
||||||
|
/// Coordinates: up to <see cref="PostedAccuracy"/> decimals with trailing
|
||||||
|
/// zeros trimmed ("3.706", "5.01", "0"), never "-0" (sample NC format).
|
||||||
|
/// Header values (V.E.*) use fixed decimals.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CIFiberFormatter
|
||||||
|
{
|
||||||
|
private readonly int _accuracy;
|
||||||
|
private readonly string _trimFormat;
|
||||||
|
private readonly string _fixedFormat;
|
||||||
|
|
||||||
|
public CIFiberFormatter(int accuracy)
|
||||||
|
{
|
||||||
|
_accuracy = System.Math.Max(0, accuracy);
|
||||||
|
_trimFormat = _accuracy == 0 ? "0" : "0." + new string('#', _accuracy);
|
||||||
|
_fixedFormat = _accuracy == 0 ? "0" : "0." + new string('0', _accuracy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variable-trim coordinate/number format: rounded to the accuracy,
|
||||||
|
/// trailing zeros removed, "0" for zero, never "-0".
|
||||||
|
/// </summary>
|
||||||
|
public string Coord(double value)
|
||||||
|
{
|
||||||
|
var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero);
|
||||||
|
if (rounded == 0.0)
|
||||||
|
rounded = 0.0; // collapse -0
|
||||||
|
|
||||||
|
return rounded.ToString(_trimFormat, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fixed-decimal format for the V.E.* header variables.</summary>
|
||||||
|
public string Fixed(double value)
|
||||||
|
{
|
||||||
|
var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero);
|
||||||
|
if (rounded == 0.0)
|
||||||
|
rounded = 0.0;
|
||||||
|
|
||||||
|
return rounded.ToString(_fixedFormat, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CRLF line writing helper matching the machine sample exactly.</summary>
|
||||||
|
public static void Line(System.IO.TextWriter w, string text)
|
||||||
|
{
|
||||||
|
w.Write(text);
|
||||||
|
w.Write("\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the Cincinnati CI Fiber (TF5200 / nLight CLX) post-processor.
|
||||||
|
/// All layer macro names, header variable names and the material code map are
|
||||||
|
/// editable so the post can serve other CI Fiber tables, not only the 4020.
|
||||||
|
/// </summary>
|
||||||
|
public class CIFiberPostConfig
|
||||||
|
{
|
||||||
|
/// <summary>Emitted in the "( CONFIGURATION - ... )" header comment.</summary>
|
||||||
|
[DisplayName("Configuration name")]
|
||||||
|
public string ConfigurationName { get; set; } = "CI FIBER 8K";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decimal places for posted coordinates and header values. The sample
|
||||||
|
/// machine program posts 3 decimals for inch units.
|
||||||
|
/// </summary>
|
||||||
|
[DisplayName("Posted accuracy")]
|
||||||
|
public int PostedAccuracy { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>Value for V.E.UNIT when the nest is in inches.</summary>
|
||||||
|
[DisplayName("Unit code (inches)")]
|
||||||
|
public int InchUnitCode { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Value for V.E.UNIT when the nest is in millimeters. The TF5200
|
||||||
|
/// metric unit code is unconfirmed; adjust if the controller rejects 0.
|
||||||
|
/// </summary>
|
||||||
|
[DisplayName("Unit code (millimeters)")]
|
||||||
|
public int MetricUnitCode { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>Emit the V.E.SHEET_WEIGHT header variable (value 0).</summary>
|
||||||
|
[DisplayName("Emit sheet weight")]
|
||||||
|
public bool EmitSheetWeight { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Skip contours whose moves are all LayerType.Scribe (marks).</summary>
|
||||||
|
[DisplayName("Skip scribe layer")]
|
||||||
|
public bool SkipScribe { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Maximum plate length (X) this configuration drives; 0 disables the check.</summary>
|
||||||
|
[DisplayName("Maximum table X")]
|
||||||
|
public double MaxTableX { get; set; } = 160.25;
|
||||||
|
|
||||||
|
/// <summary>Maximum plate width (Y) this configuration drives; 0 disables the check.</summary>
|
||||||
|
[DisplayName("Maximum table Y")]
|
||||||
|
public double MaxTableY { get; set; } = 81.25;
|
||||||
|
|
||||||
|
/// <summary>Comment text after the "( PART:" prefix for each part.</summary>
|
||||||
|
[DisplayName("Part comment")]
|
||||||
|
public string PartComment { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Skippable global subroutine used to cancel comp / park between features.</summary>
|
||||||
|
[DisplayName("Layer: cancel (L0)")]
|
||||||
|
public string LayerCancel { get; set; } = "L0";
|
||||||
|
|
||||||
|
/// <summary>Skippable global subroutine for interior lead-in plus G41.</summary>
|
||||||
|
[DisplayName("Layer: interior leadin (L2)")]
|
||||||
|
public string LayerInteriorLeadin { get; set; } = "L2";
|
||||||
|
|
||||||
|
/// <summary>Skippable global subroutine for exterior lead-in plus G42.</summary>
|
||||||
|
[DisplayName("Layer: exterior leadin (L4)")]
|
||||||
|
public string LayerExteriorLeadin { get; set; } = "L4";
|
||||||
|
|
||||||
|
/// <summary>Skippable global subroutine that switches the cut layer on.</summary>
|
||||||
|
[DisplayName("Layer: cut on (L6)")]
|
||||||
|
public string LayerCut { get; set; } = "L6";
|
||||||
|
|
||||||
|
/// <summary>Skippable global subroutine at the end of each contour (head separation / comp off).</summary>
|
||||||
|
[DisplayName("Layer: cut end (ZHSOFF)")]
|
||||||
|
public string LayerCutEnd { get; set; } = "ZHSOFF";
|
||||||
|
|
||||||
|
/// <summary>External program called once at program start.</summary>
|
||||||
|
[DisplayName("Start macro")]
|
||||||
|
public string ProgramStartMacro { get; set; } = "PROGRAMSTART.NC";
|
||||||
|
|
||||||
|
/// <summary>External program called after the last sheet.</summary>
|
||||||
|
[DisplayName("End macro")]
|
||||||
|
public string ProgramEndMacro { get; set; } = "PROGRAMEND.NC";
|
||||||
|
|
||||||
|
/// <summary>Material name (case-insensitive) to machine material code map for V.E.MATERIAL.</summary>
|
||||||
|
[DisplayName("Material codes")]
|
||||||
|
public Dictionary<string, string> MaterialCodes { get; set; } =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { ["Mild Steel"] = "MSN" };
|
||||||
|
|
||||||
|
/// <summary>Fallback V.E.MATERIAL code when the material name has no mapping.</summary>
|
||||||
|
[DisplayName("Default material code")]
|
||||||
|
public string DefaultMaterialCode { get; set; } = "MSN";
|
||||||
|
|
||||||
|
public string ResolveMaterialCode(string materialName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(materialName)
|
||||||
|
&& MaterialCodes != null
|
||||||
|
&& MaterialCodes.TryGetValue(materialName.Trim(), out var code)
|
||||||
|
&& !string.IsNullOrWhiteSpace(code))
|
||||||
|
return code;
|
||||||
|
|
||||||
|
return DefaultMaterialCode ?? "MSN";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throws if the plate exceeds the configured table envelope.
|
||||||
|
/// Zero limits disable the check.
|
||||||
|
/// </summary>
|
||||||
|
public void ValidateTableSize(double lengthX, double widthY)
|
||||||
|
{
|
||||||
|
if (MaxTableX > 0 && lengthX > MaxTableX + ToleranceEpsilon)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plate length {lengthX:0.###} exceeds maximum table X {MaxTableX:0.###}."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (MaxTableY > 0 && widthY > MaxTableY + ToleranceEpsilon)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plate width {widthY:0.###} exceeds maximum table Y {MaxTableY:0.###}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const double ToleranceEpsilon = 1e-6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Post-processor for the CI Fiber family (e.g. the CI Fiber 4020 8kW with
|
||||||
|
/// nLight CLX / Beckhoff TF5200 / Precitec ProCutter). Named by machine
|
||||||
|
/// family, not table size: the same post drives other CI Fiber tables via
|
||||||
|
/// the configured table envelope. Emits the TF5200 skippable-macro program
|
||||||
|
/// structure of the machine sample (see <see cref="CIFiberProgramWriter"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CIFiberPostProcessor : IConfigurablePostProcessor
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Name => "Cincinnati CI Fiber";
|
||||||
|
public string Author => "OpenNest";
|
||||||
|
public string Description =>
|
||||||
|
"CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW";
|
||||||
|
|
||||||
|
public CIFiberPostConfig Config { get; }
|
||||||
|
|
||||||
|
object IConfigurablePostProcessor.Config => Config;
|
||||||
|
|
||||||
|
public CIFiberPostProcessor()
|
||||||
|
{
|
||||||
|
var configPath = GetConfigPath();
|
||||||
|
if (File.Exists(configPath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(configPath);
|
||||||
|
Config =
|
||||||
|
JsonSerializer.Deserialize<CIFiberPostConfig>(json, JsonOptions)
|
||||||
|
?? new CIFiberPostConfig();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Config = new CIFiberPostConfig();
|
||||||
|
SaveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CIFiberPostProcessor(CIFiberPostConfig config)
|
||||||
|
{
|
||||||
|
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveConfig()
|
||||||
|
{
|
||||||
|
var configPath = GetConfigPath();
|
||||||
|
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||||
|
File.WriteAllText(configPath, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetConfigPath()
|
||||||
|
{
|
||||||
|
var assemblyPath = typeof(CIFiberPostProcessor).Assembly.Location;
|
||||||
|
var dir = Path.GetDirectoryName(assemblyPath);
|
||||||
|
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||||
|
return Path.Combine(dir, name + ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Post(Nest nest, Stream outputStream)
|
||||||
|
{
|
||||||
|
if (nest == null)
|
||||||
|
throw new ArgumentNullException(nameof(nest));
|
||||||
|
if (outputStream == null)
|
||||||
|
throw new ArgumentNullException(nameof(outputStream));
|
||||||
|
|
||||||
|
// CRLF file, ASCII (UTF-8 without BOM), matching the machine sample.
|
||||||
|
var encoding = new UTF8Encoding(false);
|
||||||
|
using var writer = new StreamWriter(
|
||||||
|
outputStream,
|
||||||
|
encoding,
|
||||||
|
1024,
|
||||||
|
leaveOpen: true
|
||||||
|
);
|
||||||
|
new CIFiberProgramWriter(Config).Write(nest, writer);
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Post(Nest nest, string outputFile)
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(
|
||||||
|
outputFile,
|
||||||
|
FileMode.Create,
|
||||||
|
FileAccess.Write
|
||||||
|
);
|
||||||
|
Post(nest, fs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the CI Fiber (TF5200) machine program: header, restart jump,
|
||||||
|
/// per-sheet / per-part / per-contour blocks and the tail, matching the
|
||||||
|
/// structure of the Cincinnati-supplied sample NC:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// ( <nest name> )
|
||||||
|
/// ( CONFIGURATION - CI FIBER 8K )
|
||||||
|
/// ( August 20, 2026 01:10 PM )
|
||||||
|
/// ( Material = Mild Steel .060 )
|
||||||
|
/// V.E.MATERIAL = "MSN" ... V.E.UNIT = 1
|
||||||
|
/// G90
|
||||||
|
/// L PROGRAMSTART.NC
|
||||||
|
/// P3=V.E.R3
|
||||||
|
/// $GOTO NP3:
|
||||||
|
/// N0:
|
||||||
|
/// ( Sheet number - 1 )
|
||||||
|
/// ( Part #k ) ( PART:... ) V.E.R4=k
|
||||||
|
/// N<n>: /L "L0" V.E.R3=<n> G0X..Y..
|
||||||
|
/// /L "L2" + G41 | /L "L4" + G42
|
||||||
|
/// G1X..Y.. (linear lead-in)
|
||||||
|
/// /L "L6" (cut layer on)
|
||||||
|
/// G1/G2/G3 ...
|
||||||
|
/// /L "ZHSOFF"
|
||||||
|
/// ( PART END )
|
||||||
|
/// /L "L0"
|
||||||
|
/// L PROGRAMEND.NC
|
||||||
|
/// M50
|
||||||
|
/// M30
|
||||||
|
/// %
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Coordinates are spaceless (G1X3.706Y47.488); arcs post I/J incremental
|
||||||
|
/// from the arc start (TF5200 G162 default). The first motion block after
|
||||||
|
/// the G41/G42 selection is always LINEAR (TF5200 §13.2.4.1): contours
|
||||||
|
/// without a lead-in move are rejected with a clear error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CIFiberProgramWriter
|
||||||
|
{
|
||||||
|
private readonly CIFiberPostConfig _config;
|
||||||
|
private readonly CIFiberFormatter _fmt;
|
||||||
|
|
||||||
|
public CIFiberProgramWriter(CIFiberPostConfig config)
|
||||||
|
{
|
||||||
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
_fmt = new CIFiberFormatter(config.PostedAccuracy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(Nest nest, TextWriter w)
|
||||||
|
{
|
||||||
|
if (nest == null)
|
||||||
|
throw new ArgumentNullException(nameof(nest));
|
||||||
|
|
||||||
|
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList();
|
||||||
|
|
||||||
|
WriteHeader(nest, w);
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, "G90");
|
||||||
|
CIFiberFormatter.Line(w, $"L {_config.ProgramStartMacro}");
|
||||||
|
CIFiberFormatter.Line(w, "P3=V.E.R3");
|
||||||
|
CIFiberFormatter.Line(w, "$GOTO NP3:");
|
||||||
|
CIFiberFormatter.Line(w, "N0:");
|
||||||
|
|
||||||
|
var contourNumber = 0;
|
||||||
|
for (var s = 0; s < plates.Count; s++)
|
||||||
|
{
|
||||||
|
var plate = plates[s];
|
||||||
|
_config.ValidateTableSize(plate.Size.Length, plate.Size.Width);
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, $"( Sheet number - {s + 1} )");
|
||||||
|
contourNumber = WriteSheet(plate, w, contourNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteTail(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteHeader(Nest nest, TextWriter w)
|
||||||
|
{
|
||||||
|
CIFiberFormatter.Line(w, $"( {nest.Name ?? ""} )");
|
||||||
|
CIFiberFormatter.Line(w, $"( CONFIGURATION - {_config.ConfigurationName} )");
|
||||||
|
CIFiberFormatter.Line(
|
||||||
|
w,
|
||||||
|
"( "
|
||||||
|
+ DateTime.Now.ToString("MMMM d, yyyy hh:mm tt", CultureInfo.InvariantCulture)
|
||||||
|
+ " )"
|
||||||
|
);
|
||||||
|
|
||||||
|
var materialName = nest.Material?.Name ?? "";
|
||||||
|
var thickness = _fmt.Fixed(nest.Thickness);
|
||||||
|
if (thickness.StartsWith("0"))
|
||||||
|
thickness = thickness.Substring(1); // sample posts ".060"
|
||||||
|
CIFiberFormatter.Line(w, $"( Material = {materialName} {thickness} )".TrimEnd());
|
||||||
|
|
||||||
|
var code = _config.ResolveMaterialCode(materialName);
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.MATERIAL = \"{code}\"");
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.THICKNESS = {_fmt.Fixed(nest.Thickness)}");
|
||||||
|
|
||||||
|
// Multi-plate nests run one sheet after another in this program;
|
||||||
|
// the header size describes the first sheet (the machine sample
|
||||||
|
// carries a single sheet).
|
||||||
|
var firstPlate = nest.Plates.FirstOrDefault();
|
||||||
|
var xSize = firstPlate?.Size.Length ?? 0.0;
|
||||||
|
var ySize = firstPlate?.Size.Width ?? 0.0;
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.X_SIZE = {_fmt.Fixed(xSize)}");
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.Y_SIZE = {_fmt.Fixed(ySize)}");
|
||||||
|
|
||||||
|
if (_config.EmitSheetWeight)
|
||||||
|
CIFiberFormatter.Line(w, "V.E.SHEET_WEIGHT = 0");
|
||||||
|
|
||||||
|
var unit = nest.Units == Units.Millimeters
|
||||||
|
? _config.MetricUnitCode
|
||||||
|
: _config.InchUnitCode;
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.UNIT = {unit}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteTail(TextWriter w)
|
||||||
|
{
|
||||||
|
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
|
||||||
|
CIFiberFormatter.Line(w, $"L {_config.ProgramEndMacro}");
|
||||||
|
CIFiberFormatter.Line(w, "M50");
|
||||||
|
CIFiberFormatter.Line(w, "M30");
|
||||||
|
CIFiberFormatter.Line(w, "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int WriteSheet(Plate plate, TextWriter w, int contourNumber)
|
||||||
|
{
|
||||||
|
var partNumber = 0;
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
partNumber++;
|
||||||
|
contourNumber = WritePart(part, partNumber, w, contourNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contourNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int WritePart(Part part, int partNumber, TextWriter w, int contourNumber)
|
||||||
|
{
|
||||||
|
CIFiberFormatter.Line(w, $"( Part #{partNumber} )");
|
||||||
|
|
||||||
|
var partComment = ResolvePartComment(part);
|
||||||
|
CIFiberFormatter.Line(w, $"( PART:{partComment} )");
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.R4={partNumber}");
|
||||||
|
|
||||||
|
var contours = CIFiberContourExtractor.Extract(part);
|
||||||
|
if (_config.SkipScribe)
|
||||||
|
contours = contours.Where(c => !IsScribeContour(c)).ToList();
|
||||||
|
|
||||||
|
foreach (var contour in contours)
|
||||||
|
{
|
||||||
|
contourNumber++;
|
||||||
|
WriteContour(contour, contourNumber, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, "( PART END )");
|
||||||
|
return contourNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteContour(CIFiberContour contour, int contourNumber, TextWriter w)
|
||||||
|
{
|
||||||
|
var isExterior = contour.IsExterior || CIFiberWinding.IsExterior(contour);
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, $"N{contourNumber}:");
|
||||||
|
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
|
||||||
|
CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}");
|
||||||
|
CIFiberFormatter.Line(w, $"G0X{Fmt(contour.Pierce.X)}Y{Fmt(contour.Pierce.Y)}");
|
||||||
|
|
||||||
|
if (contour.LeadIn == null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Contour {contourNumber} has no lead-in move. TF5200 13.2.4.1 "
|
||||||
|
+ "requires a LINEAR motion block immediately after G41/G42 "
|
||||||
|
+ "selection; assign lead-ins before posting."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contour.LeadIn is not LinearMove)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Contour {contourNumber} has an arc lead-in. TF5200 13.2.4.1 "
|
||||||
|
+ "requires the first motion block after G41/G42 selection "
|
||||||
|
+ "to be LINEAR."
|
||||||
|
);
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(
|
||||||
|
w,
|
||||||
|
SkippableLine(
|
||||||
|
isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CIFiberFormatter.Line(
|
||||||
|
w,
|
||||||
|
isExterior ? "G42" : "G41"
|
||||||
|
);
|
||||||
|
|
||||||
|
var prev = contour.LeadIn.EndPoint;
|
||||||
|
CIFiberFormatter.Line(
|
||||||
|
w,
|
||||||
|
$"G1X{Fmt(contour.LeadIn.EndPoint.X)}Y{Fmt(contour.LeadIn.EndPoint.Y)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Additional lead-layer moves (e.g. CleanHole arc) still run
|
||||||
|
// before the cut layer turns on.
|
||||||
|
foreach (var extra in contour.LeadInExtra)
|
||||||
|
{
|
||||||
|
CIFiberFormatter.Line(w, FormatMotion(extra, prev));
|
||||||
|
prev = extra.EndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCut));
|
||||||
|
|
||||||
|
foreach (var cut in contour.Cuts)
|
||||||
|
{
|
||||||
|
CIFiberFormatter.Line(w, FormatMotion(cut, prev));
|
||||||
|
prev = cut.EndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolvePartComment(Part part)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_config.PartComment))
|
||||||
|
return _config.PartComment;
|
||||||
|
|
||||||
|
var name = part.BaseDrawing?.Name ?? "";
|
||||||
|
var source = part.BaseDrawing?.Source?.Path;
|
||||||
|
if (!string.IsNullOrEmpty(source))
|
||||||
|
return Path.GetFileNameWithoutExtension(source);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsScribeContour(CIFiberContour contour)
|
||||||
|
{
|
||||||
|
var hasAny = false;
|
||||||
|
foreach (var cut in contour.Cuts)
|
||||||
|
{
|
||||||
|
var layer = LayerOf(cut);
|
||||||
|
if (layer != LayerType.Scribe)
|
||||||
|
return false;
|
||||||
|
hasAny = true;
|
||||||
|
}
|
||||||
|
return hasAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LayerType LayerOf(Motion motion) =>
|
||||||
|
motion switch
|
||||||
|
{
|
||||||
|
LinearMove l => l.Layer,
|
||||||
|
ArcMove a => a.Layer,
|
||||||
|
_ => LayerType.Cut,
|
||||||
|
};
|
||||||
|
|
||||||
|
private string Fmt(double value) => _fmt.Coord(value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One motion line: G0/G1/G2/G3 with spaceless X/Y, plus I/J for arcs.
|
||||||
|
/// I/J are incremental from the arc START (TF5200 G162/basic default,
|
||||||
|
/// verified against the machine sample): center minus arc start point.
|
||||||
|
/// </summary>
|
||||||
|
private string FormatMotion(Motion motion, Vector arcStart)
|
||||||
|
{
|
||||||
|
var prefix = MovePrefix(motion);
|
||||||
|
var line = $"{prefix}X{Fmt(motion.EndPoint.X)}Y{Fmt(motion.EndPoint.Y)}";
|
||||||
|
|
||||||
|
if (motion is ArcMove arc)
|
||||||
|
{
|
||||||
|
var i = arc.CenterPoint.X - arcStart.X;
|
||||||
|
var j = arc.CenterPoint.Y - arcStart.Y;
|
||||||
|
line += $"I{Fmt(i)}J{Fmt(j)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SkippableLine(string macroName) => $"/L \"{macroName}\"";
|
||||||
|
|
||||||
|
private static string MovePrefix(Motion motion) =>
|
||||||
|
motion switch
|
||||||
|
{
|
||||||
|
RapidMove => "G0",
|
||||||
|
ArcMove a => a.Rotation == RotationType.CW ? "G2" : "G3",
|
||||||
|
_ => "G1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.CincinnatiCIFiber
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies a flattened contour as interior or exterior from the
|
||||||
|
/// material side of its cut path, so the post picks the G41 (inside, kerf
|
||||||
|
/// left of travel) vs G42 (outside, kerf right of travel) lead-in macro to
|
||||||
|
/// keep the kerf on the scrap side, matching the machine sample (holes cut
|
||||||
|
/// CCW + G41, perimeter cut CCW + G42).
|
||||||
|
///
|
||||||
|
/// OpenNest contours all keep material on the left of travel (see
|
||||||
|
/// OffsetSide / ContourCuttingStrategy): the perimeter runs clockwise
|
||||||
|
/// (negative shoelace area) and holes run counter-clockwise (positive).
|
||||||
|
/// A contour path that is not closed classifies as interior — a defensive
|
||||||
|
/// default; open paths should not reach the post.
|
||||||
|
/// </summary>
|
||||||
|
public static class CIFiberWinding
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Signed shoelace area of the contour path (arcs sampled at their
|
||||||
|
/// arc-mid point). 0 for degenerate paths.
|
||||||
|
/// </summary>
|
||||||
|
public static double SignedArea(CIFiberContour contour)
|
||||||
|
{
|
||||||
|
var pts = SamplePoints(contour);
|
||||||
|
if (pts.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var sum = 0.0;
|
||||||
|
for (var i = 0; i < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var a = pts[i];
|
||||||
|
var b = pts[(i + 1) % pts.Count];
|
||||||
|
sum += a.X * b.Y - b.X * a.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum / 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the contour is a closed CW path — an external perimeter
|
||||||
|
/// under the OpenNest material-left convention.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsExterior(CIFiberContour contour)
|
||||||
|
{
|
||||||
|
if (!IsClosed(contour))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return SignedArea(contour) < 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the cut path returns to its start point.</summary>
|
||||||
|
public static bool IsClosed(CIFiberContour contour, double tolerance = 1e-6)
|
||||||
|
{
|
||||||
|
var pts = SamplePoints(contour);
|
||||||
|
if (pts.Count < 3)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return pts[0].DistanceTo(pts[^1]) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered points along the path: lead-in endpoint, then each cut move
|
||||||
|
/// endpoint, with arc interior samples at <=90-degree steps (so full
|
||||||
|
/// circles produce a well-formed polygon). Consecutive duplicates are
|
||||||
|
/// dropped so zero-length lead-ins do not skew the sample.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Vector> SamplePoints(CIFiberContour contour)
|
||||||
|
{
|
||||||
|
var pts = new List<Vector>();
|
||||||
|
|
||||||
|
void Add(Vector v)
|
||||||
|
{
|
||||||
|
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
|
||||||
|
pts.Add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
|
||||||
|
Add(prev);
|
||||||
|
|
||||||
|
foreach (var cut in contour.Cuts)
|
||||||
|
{
|
||||||
|
if (cut is ArcMove arc)
|
||||||
|
{
|
||||||
|
foreach (var mid in ArcSamplePoints(prev, arc))
|
||||||
|
Add(mid);
|
||||||
|
}
|
||||||
|
Add(cut.EndPoint);
|
||||||
|
prev = cut.EndPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interior points on the arc from <paramref name="prev"/> to the arc
|
||||||
|
/// endpoint at no more than ~90-degree sweep intervals. A full circle
|
||||||
|
/// yields four interior points, giving the shoelace a non-degenerate
|
||||||
|
/// polygon whose sign is the circle's true winding.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
|
||||||
|
{
|
||||||
|
var points = new List<Vector>();
|
||||||
|
var center = arc.CenterPoint;
|
||||||
|
var radius = center.DistanceTo(arc.EndPoint);
|
||||||
|
if (radius < 1e-12)
|
||||||
|
return points;
|
||||||
|
|
||||||
|
var a0 = System.Math.Atan2(prev.Y - center.Y, prev.X - center.X);
|
||||||
|
var a1 = System.Math.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
|
||||||
|
|
||||||
|
double sweep;
|
||||||
|
if (arc.Rotation == RotationType.CW)
|
||||||
|
{
|
||||||
|
sweep = a0 - a1;
|
||||||
|
if (sweep <= 0)
|
||||||
|
sweep += 2.0 * System.Math.PI;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sweep = a1 - a0;
|
||||||
|
if (sweep <= 0)
|
||||||
|
sweep += 2.0 * System.Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full circle (start == end) reads as zero sweep above.
|
||||||
|
if (
|
||||||
|
sweep < 1e-9
|
||||||
|
&& System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9
|
||||||
|
&& System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9
|
||||||
|
)
|
||||||
|
sweep = 2.0 * System.Math.PI;
|
||||||
|
|
||||||
|
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
|
||||||
|
var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0)));
|
||||||
|
|
||||||
|
for (var k = 1; k <= steps; k++)
|
||||||
|
{
|
||||||
|
var angle = a0 + direction * sweep * k / (steps + 1);
|
||||||
|
points.Add(
|
||||||
|
new Vector(
|
||||||
|
center.X + radius * System.Math.Cos(angle),
|
||||||
|
center.Y + radius * System.Math.Sin(angle)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>OpenNest.Posts.CincinnatiCIFiber</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="OpenNest.Posts.CincinnatiCIFiber.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PostsDir>..\..\OpenNest\bin\$(Configuration)\net8.0-windows\Posts\</PostsDir>
|
||||||
|
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.CincinnatiCIFiber.json</ConfigJson>
|
||||||
|
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.CincinnatiCIFiber.json</DeployedConfigJson>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MakeDir Directories="$(PostsDir)" />
|
||||||
|
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||||
|
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"ConfigurationName": "CI FIBER 8K",
|
||||||
|
"PostedAccuracy": 3,
|
||||||
|
"InchUnitCode": 1,
|
||||||
|
"MetricUnitCode": 0,
|
||||||
|
"EmitSheetWeight": false,
|
||||||
|
"SkipScribe": true,
|
||||||
|
"MaxTableX": 160.25,
|
||||||
|
"MaxTableY": 81.25,
|
||||||
|
"PartComment": "",
|
||||||
|
"LayerCancel": "L0",
|
||||||
|
"LayerInteriorLeadin": "L2",
|
||||||
|
"LayerExteriorLeadin": "L4",
|
||||||
|
"LayerCut": "L6",
|
||||||
|
"LayerCutEnd": "ZHSOFF",
|
||||||
|
"ProgramStartMacro": "PROGRAMSTART.NC",
|
||||||
|
"ProgramEndMacro": "PROGRAMEND.NC",
|
||||||
|
"MaterialCodes": {
|
||||||
|
"Mild Steel": "MSN"
|
||||||
|
},
|
||||||
|
"DefaultMaterialCode": "MSN"
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ Layouts are validated (bounds, spacing, quantity, rotation, stock match); invali
|
|||||||
| **OpenNest.Gpu** | GPU-accelerated pair evaluation (ILGPU) |
|
| **OpenNest.Gpu** | GPU-accelerated pair evaluation (ILGPU) |
|
||||||
| **OpenNest.Benchmark** | Head-to-head engine comparison |
|
| **OpenNest.Benchmark** | Head-to-head engine comparison |
|
||||||
| **OpenNest.Mcp** | MCP server for AI tool integration |
|
| **OpenNest.Mcp** | MCP server for AI tool integration |
|
||||||
| **Posts/** | Post-processor plugins (Cincinnati CL lasers, Gravograph IS8000) |
|
| **Posts/** | Post-processor plugins (Cincinnati CL / CI Fiber lasers, Gravograph IS8000) |
|
||||||
| **\*.Tests** | Cross-platform suites; WinForms tests are Windows-only |
|
| **\*.Tests** | Cross-platform suites; WinForms tests are Windows-only |
|
||||||
|
|
||||||
## Nesting Engines
|
## Nesting Engines
|
||||||
|
|||||||
Reference in New Issue
Block a user