diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs
index 3891e256..b6614f11 100644
--- a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs
+++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs
@@ -1,9 +1,14 @@
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.Posts.CincinnatiCIFiber;
@@ -12,7 +17,7 @@ namespace OpenNest.Tests.CincinnatiCIFiber;
///
/// 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
+/// restart jump, N-labelled contour blocks with /L macro lines, G41
/// selection, trimmed coordinate format and the M50/M30/% tail.
///
public class CIFiberPostProcessorTests
@@ -131,7 +136,8 @@ public class CIFiberPostProcessorTests
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]);
+ // CW perimeter: the part is right of travel, so the kerf goes left.
+ Assert.Equal("G41", lines[33]);
Assert.Equal("G1X1Y2", lines[34]);
Assert.Equal("/L \"L6\"", lines[35]);
Assert.Equal("G1X1Y12", lines[36]);
@@ -149,6 +155,101 @@ public class CIFiberPostProcessorTests
Assert.Equal("%", lines[46]);
}
+ ///
+ /// End to end through the real cutting strategy, with a drawing wound
+ /// against OpenNest's convention (perimeter CCW, hole CW, as a drawing built
+ /// in code may be): the compensation must still keep the product on the side
+ /// the controller leaves it (G41 right of travel, G42 left), for the
+ /// perimeter and the hole alike, and each must get its own lead-in macro.
+ /// The wrong side under- or oversizes the cut by a kerf width.
+ ///
+ [Fact]
+ public void Post_Compensation_KeepsPartOnProductSide_ForPerimeterAndHole()
+ {
+ var pgm = new Program(Mode.Absolute);
+ // Square drawn CCW, the opposite of OpenNest's CW perimeter convention.
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ // Hole r=1 at (5,5), drawn CW, the opposite of OpenNest's CCW hole convention.
+ pgm.Codes.Add(new RapidMove(new Vector(6, 5)));
+ pgm.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
+
+ var location = new Vector(3, 4);
+ var plate = new Plate(60, 120);
+ plate.Parts.Add(new Part(new Drawing("rect-hole", pgm), location));
+ plate.CuttingParameters = new CuttingParameters
+ {
+ InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
+ ExternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
+ };
+ new LeadInAssigner { Sequencer = new LeftSideSequencer() }.Assign(plate);
+
+ var nest = new Nest("kerf") { Thickness = 0.06, Material = new Material("Mild Steel") };
+ nest.Plates.Add(plate);
+ var lines = Lines(Post(nest));
+
+ var holeCenter = location + new Vector(5, 5);
+ bool InMaterial(Vector p) =>
+ p.X > location.X && p.X < location.X + 10
+ && p.Y > location.Y && p.Y < location.Y + 10
+ && p.DistanceTo(holeCenter) > 1.0;
+
+ var checkedContours = 0;
+ for (var i = 0; i < lines.Count; i++)
+ {
+ if (lines[i] != "G41" && lines[i] != "G42")
+ continue;
+
+ // The lead-in ends on the contour; the first cut after /L "L6" sets
+ // the direction of travel there.
+ var start = ParseXY(lines[i + 1]);
+ var onPerimeter = start.DistanceTo(holeCenter) > 1.5;
+ Assert.Equal(onPerimeter ? "/L \"L4\"" : "/L \"L2\"", lines[i - 1]);
+ Assert.Equal("/L \"L6\"", lines[i + 2]);
+ var cut = lines[i + 3];
+ Vector travel;
+ if (cut.StartsWith("G1"))
+ {
+ travel = ParseXY(cut) - start;
+ }
+ else
+ {
+ var m = Regex.Match(cut, @"I(-?[\d.]+)J(-?[\d.]+)");
+ var radial = new Vector(
+ -double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
+ -double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
+ ); // centre -> start
+ travel = cut.StartsWith("G3")
+ ? new Vector(-radial.Y, radial.X)
+ : new Vector(radial.Y, -radial.X);
+ }
+
+ var length = System.Math.Sqrt(travel.X * travel.X + travel.Y * travel.Y);
+ var right = new Vector(travel.Y / length, -travel.X / length);
+ if (lines[i] == "G42")
+ right = new Vector(-right.X, -right.Y); // G42 keeps the product on the left
+ Assert.True(InMaterial(start + right * 0.01), $"Part is not right of travel at {lines[i + 1]}");
+ Assert.False(InMaterial(start - right * 0.01), $"Kerf side is not scrap at {lines[i + 1]}");
+ checkedContours++;
+ }
+
+ Assert.Equal(2, checkedContours);
+ }
+
+ private static Vector ParseXY(string line)
+ {
+ var m = Regex.Match(line, @"X(-?[\d.]+)Y(-?[\d.]+)");
+ Assert.True(m.Success, line);
+ return new Vector(
+ double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
+ double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
+ );
+ }
+
[Fact]
public void Post_FlattensRotatedPartHole_ToSheetCoords()
{
diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
index f35492cc..6855a7df 100644
--- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
+++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
@@ -29,7 +29,8 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// ... (single-program mode only: /L "L0", pallet change, next sheet)
/// ( Part #k ) ( PART:... ) V.E.R4=k
/// N<n>: /L "L0" V.E.R3=<n> G0X..Y..
- /// /L "L2" + G41 | /L "L4" + G42
+ /// /L "L2" (hole) | /L "L4" (perimeter), then G41 | G42
+ /// (whichever puts the kerf in the scrap)
/// G1X..Y.. (linear lead-in)
/// /L "L6" (cut layer on)
/// G1/G2/G3 ...
@@ -222,23 +223,22 @@ namespace OpenNest.Posts.CincinnatiCIFiber
contours = contours.Where(c => !IsScribeContour(c)).ToList();
var isCutOff = part.BaseDrawing.IsCutOff;
- foreach (var contour in contours)
+ var perimeter = CIFiberWinding.PerimeterIndex(contours);
+ for (var i = 0; i < contours.Count; i++)
{
contourNumber++;
if (isCutOff)
- WriteCutOffContour(contour, contourNumber, w);
+ WriteCutOffContour(contours[i], contourNumber, w);
else
- WriteContour(contour, contourNumber, w);
+ WriteContour(contours[i], contours[i].IsExterior || i == perimeter, contourNumber, w);
}
CIFiberFormatter.Line(w, "( PART END )");
return contourNumber;
}
- private void WriteContour(CIFiberContour contour, int contourNumber, TextWriter w)
+ private void WriteContour(CIFiberContour contour, bool isExterior, 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}");
@@ -264,9 +264,12 @@ namespace OpenNest.Posts.CincinnatiCIFiber
isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin
)
);
+ // G41 leaves the product right of travel, G42 left (Cincinnati CNC
+ // handbook). Pick the side that puts the kerf in the scrap for the
+ // way this contour actually runs.
CIFiberFormatter.Line(
w,
- isExterior ? "G42" : "G41"
+ CIFiberWinding.CompensatesLeft(contour, isExterior) ? "G41" : "G42"
);
var prev = contour.LeadIn.EndPoint;
diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs
index 46448aa5..ef6125ac 100644
--- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs
+++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs
@@ -6,17 +6,16 @@ using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
///
- /// 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).
+ /// Works out, from a part's flattened contours, which one is the perimeter
+ /// (L4 lead-in macro, the rest are holes on L2) and which compensation
+ /// side keeps the kerf in the scrap.
///
- /// 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.
+ /// G41 leaves the product right of travel. Imported drawings are wound
+ /// perimeter CW and holes CCW (ShapeProfile.NormalizeWinding), which puts
+ /// the part on the right and gives G41 throughout; a drawing that reaches
+ /// the post wound the other way (built in code or edited by hand) needs G42.
+ /// Paths that enclose no area (open break cuts) count as holes with G41,
+ /// a defensive default.
///
public static class CIFiberWinding
{
@@ -42,15 +41,59 @@ namespace OpenNest.Posts.CincinnatiCIFiber
}
///
- /// True when the contour is a closed CW path — an external perimeter
- /// under the OpenNest material-left convention.
+ /// Index of the part's perimeter: the contour enclosing the largest
+ /// bounding box, as ShapeProfile picks it. -1 when no contour encloses
+ /// any area.
///
- public static bool IsExterior(CIFiberContour contour)
+ public static int PerimeterIndex(IReadOnlyList contours)
{
- if (!IsClosed(contour))
- return false;
+ var best = -1;
+ var bestArea = 0.0;
+ for (var i = 0; i < contours.Count; i++)
+ {
+ if (!EnclosesArea(contours[i]))
+ continue;
- return SignedArea(contour) < 0.0;
+ var area = BoundingBoxArea(SamplePoints(contours[i]));
+ if (best < 0 || area > bestArea)
+ {
+ best = i;
+ bestArea = area;
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// True when G41 (product right of travel) keeps the kerf in the scrap:
+ /// a CW perimeter or a CCW hole. False calls for G42.
+ ///
+ public static bool CompensatesLeft(CIFiberContour contour, bool isPerimeter)
+ {
+ if (!EnclosesArea(contour))
+ return true;
+
+ var clockwise = SignedArea(contour) < 0.0;
+ return clockwise == isPerimeter;
+ }
+
+ private static bool EnclosesArea(CIFiberContour contour) =>
+ System.Math.Abs(SignedArea(contour)) > 1e-9;
+
+ private static double BoundingBoxArea(List pts)
+ {
+ double minX = double.MaxValue, minY = double.MaxValue;
+ double maxX = double.MinValue, maxY = double.MinValue;
+ foreach (var p in pts)
+ {
+ minX = System.Math.Min(minX, p.X);
+ minY = System.Math.Min(minY, p.Y);
+ maxX = System.Math.Max(maxX, p.X);
+ maxY = System.Math.Max(maxY, p.Y);
+ }
+
+ return (maxX - minX) * (maxY - minY);
}
/// True when the cut path returns to its start point.
diff --git a/docs/cincinnati-ci-fiber-post-output.md b/docs/cincinnati-ci-fiber-post-output.md
index 6e84bc48..aff3bb86 100644
--- a/docs/cincinnati-ci-fiber-post-output.md
+++ b/docs/cincinnati-ci-fiber-post-output.md
@@ -6,7 +6,9 @@ manual or a machine-operation guide. For the separate CL-series post, see
Controller reference used during development: Beckhoff *TF5200 | TwinCAT 3 CNC
Programming manual*, version 1.33, May 19, 2026
-(`TF5200_programming_manual_en.pdf`). Obtain the applicable documentation from
+(`TF5200_programming_manual_en.pdf`), and Cincinnati's *CNC Codes Handbook*
+(Programming Manual, rev 1.0, January 12, 2023) with LaserNST CI FIBER 8K sample
+programs, supplied by Cincinnati on October 19, 2026. Obtain the applicable documentation from
Beckhoff and Cincinnati. Vendor PDFs and full-text extracts stay outside source
control; redistribution permission has not been established.
@@ -21,9 +23,18 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API:
- Parts follow plate order. `V.E.R4` identifies the part within its sheet;
numbered contour labels and `V.E.R3` count up through the program for restart
lookup (they restart at 1 in each per-sheet program).
-- Each contour: `/L "L0"`, restart number, rapid to pierce, then interior
- `/L "L2"` with `G41` or exterior `/L "L4"` with `G42`; linear lead-in,
+- Each contour: `/L "L0"`, restart number, rapid to pierce, then `/L "L2"`
+ for a hole or `/L "L4"` for the perimeter, `G41` or `G42`, linear lead-in,
`/L "L6"`, cutting moves, and `/L "ZHSOFF"`.
+- The perimeter is the part's contour with the largest bounding box, as
+ `ShapeProfile` picks it; the others are holes. Compensation follows the
+ Cincinnati CNC Codes Handbook (rev 1.0, G-codes): `G41` leaves the product
+ right of travel and `G42` leaves it left. The post picks whichever puts the
+ kerf in the scrap for the way each contour runs: imported drawings (perimeter
+ CW, holes CCW) get `G41` throughout, and a contour wound the other way gets
+ `G42`. Cincinnati's LaserNST samples cut both perimeter and holes CCW, so they
+ use `G42` on the perimeter and `G41` on holes, the same kerf sides. Paths that
+ enclose no area count as holes with `G41`.
- Tail: `/L "L0"`, `L PROGRAMEND.NC`, the pallet-change code (`M50`), `M30`,
and `%`.
- Motion endpoints are sheet-absolute XY. Arc `I`/`J` are offsets from the arc
@@ -86,7 +97,8 @@ validated and saved only when OK is pressed.
## Verification
[Output-contract tests](../OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs)
-cover a square with a hole, coordinate transforms/formatting, lead-in rejection,
+cover a square with a hole, kerf side for either winding, coordinate
+transforms/formatting, lead-in rejection,
cut-off sequencing and output, table bounds, suppression/scribe handling, and
multi-sheet output in both modes. Run: