fix(posts): pick Cincinnati CL kerf side from the shared contour winding

The CL post wrote the configured default side (G41) on every contour,
so a contour wound against OpenNest's convention was compensated into
the part. It now takes each feature's side from Core's shared
PostContours winding rule, as the CI Fiber post does: the perimeter is
picked across the part's cut features and hole sub-program contours
are always holes. Correctly wound drawings post unchanged.

Remove the "Default kerf side" post setting; saved configs that still
carry it load as before. Expose PostContours.Flatten and Classify so a
post can classify one part's features itself.

Add CL tests for a square with a hole: the exact inline program, cuts
that match the shared contours on the sheet, G41/G42 keeping the part
out of the kerf inline and through part sub-programs, and identical
output from absolute and incremental part and hole programs. The
replay now records the compensation in force on each cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 15:52:10 -05:00
1 parent cd623ff1aa
commit fa6f664296
12 files changed
+318 -42

No files matched your search

+17 -6
View File
@@ -41,14 +41,25 @@ public static class PostContours
/// </summary>
public static List<PostContour> Extract(Program program, Vector origin)
{
var contours = new List<PostContour>();
Flatten(program, origin, contours);
var contours = Flatten(program, origin);
Classify(contours);
return contours;
}
/// <summary>Picks the perimeter (marks excluded) and each contour's kerf side.</summary>
private static void Classify(List<PostContour> contours)
/// <summary>
/// The program's contours in the <paramref name="origin"/> frame, with no perimeter or kerf
/// side chosen yet. A post that splits one part across several calls gathers them here and
/// classifies the whole part with <see cref="Classify"/>.
/// </summary>
public static List<PostContour> Flatten(Program program, Vector origin)
{
var contours = new List<PostContour>();
FlattenInto(program, origin, contours);
return contours;
}
/// <summary>Picks one part's perimeter (marks excluded) and each contour's kerf side.</summary>
public static void Classify(IReadOnlyList<PostContour> contours)
{
var perimeter = ContourWinding.PerimeterIndex(contours);
for (var i = 0; i < contours.Count; i++)
@@ -68,7 +79,7 @@ public static class PostContours
/// suppressed moves starts a new contour pierced where the run ends, so the gap is crossed
/// with the beam off rather than cut as a chord.
/// </summary>
private static void Flatten(Program program, Vector origin, List<PostContour> contours)
private static void FlattenInto(Program program, Vector origin, List<PostContour> contours)
{
if (program == null)
return;
@@ -118,7 +129,7 @@ public static class PostContours
// A hole call is its own contour.
Flush();
resumeAt = null;
Flatten(call.Program, origin + call.Offset, contours);
FlattenInto(call.Program, origin + call.Offset, contours);
break;
case Motion motion:
@@ -13,7 +13,6 @@ public class CincinnatiFeatureWriterTests
FeatureLineNumberStart = 1,
UseAntiDive = true,
KerfCompensation = KerfMode.ControllerSide,
DefaultKerfSide = KerfSide.Left,
InteriorM47 = M47Mode.Always,
ExteriorM47 = M47Mode.Always,
UseSpeedGas = false,
@@ -112,8 +111,8 @@ public class CincinnatiFeatureWriterTests
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.ControllerSide;
config.DefaultKerfSide = KerfSide.Left;
var ctx = SimpleContext();
ctx.Kerf = KerfType.Left;
var output = WriteFeature(config, ctx);
Assert.Contains("G41", output);
@@ -125,8 +124,8 @@ public class CincinnatiFeatureWriterTests
{
var config = DefaultConfig();
config.KerfCompensation = KerfMode.ControllerSide;
config.DefaultKerfSide = KerfSide.Right;
var ctx = SimpleContext();
ctx.Kerf = KerfType.Right;
var output = WriteFeature(config, ctx);
Assert.Contains("G42", output);
@@ -0,0 +1,200 @@
using OpenNest.CNC;
using OpenNest.CNC.Posting;
using OpenNest.Geometry;
namespace OpenNest.Tests.Cincinnati;
/// <summary>
/// A square with a hole through the CL post: the exact program, where the replayed G-code cuts
/// against Core's shared contours, a kerf side that keeps the part out of the kerf in inline
/// posting, part sub-programs and the hole sub-program, and the same program whichever mode
/// the part and hole programs are stored in.
/// </summary>
public class CincinnatiKerfAndCoordinateTests
{
private static readonly Vector Location = new(1, 2);
/// <summary>
/// A 10x10 square cut CW from (0,0) after a linear lead-in, and a radius-0.5 hole at (5,5)
/// cut CCW as a hole call; <paramref name="reversed"/> winds both the other way.
/// </summary>
private static Nest SquareWithHole(Mode partMode = Mode.Absolute, Mode holeMode = Mode.Incremental,
bool reversed = false)
{
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), Vector.Zero, reversed ? RotationType.CW : RotationType.CCW)
{
Layer = LayerType.Cut,
});
hole.Mode = holeMode;
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(5, 5) });
pgm.Codes.Add(new RapidMove(new Vector(0, -0.5)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Leadin });
var corners = reversed
? new[] { new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), Vector.Zero }
: new[] { new Vector(0, 10), new Vector(10, 10), new Vector(10, 0), Vector.Zero };
foreach (var corner in corners)
pgm.Codes.Add(new LinearMove(corner) { Layer = LayerType.Cut });
pgm.Mode = partMode;
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(new Drawing("square-hole", pgm), Location));
var nest = new Nest("N");
nest.Plates.Add(plate);
return nest;
}
[Fact]
public void SquareWithHole_Inline_PostsTheExactProgram()
{
var output = CincinnatiReplay.Post(SquareWithHole(), partSubprograms: false);
var body = output[output.IndexOf("GOTO1( Goto Feature )", StringComparison.Ordinal)..];
Assert.Equal(
"""
GOTO1( Goto Feature )
N1 G52 X6 Y7
( PART: square-hole )
M98 P200
G52 X0 Y0
M47
N1002 G0 X1 Y1.5
(WARNING: No library found)
G84
G41 G1 X1 Y2 F#126
G1 X1 Y12 F#148
G1 X11 Y12
G1 X11 Y2
G1 X1 Y2
G40
M35
M42
M99 (END OF N.001)
(*****************************************************)
:200
( PART: HOLE )
N1 G0 X0 Y-0.6
(WARNING: No library found)
G84
G41 G1 X0 Y-0.5 F#126
G3 X0 Y-0.5 I0 J0.5 F[#148*#128]
G40
M35
M99 (END OF HOLE)
""",
body);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SquareWithHole_CutsTheSharedContoursOnTheSheet(bool partSubprograms)
{
var nest = SquareWithHole();
var contours = PostContours.Extract(nest.Plates[0].Parts[0]);
var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms));
AssertPoints(contours.Select(c => c.Pierce), run.PierceLog.Select(p => p.Point));
var expected = contours.SelectMany(c => new[] { c.LeadIn }.Concat(c.Cuts)).ToList();
Assert.Equal(expected.Count, run.Moves.Count);
for (var i = 0; i < expected.Count; i++)
{
Assert.True(CincinnatiReplay.Near(expected[i].EndPoint, run.Moves[i].To),
$"Move {i} ends at {run.Moves[i].To}, expected {expected[i].EndPoint}");
if (expected[i] is ArcMove arc)
{
Assert.True(CincinnatiReplay.Near(arc.CenterPoint, run.Moves[i].Center!.Value));
Assert.Equal(arc.Rotation == RotationType.CW, run.Moves[i].Clockwise);
}
}
}
[Theory]
[InlineData(false, false, "G41")]
[InlineData(true, false, "G41")]
[InlineData(false, true, "G42")]
[InlineData(true, true, "G42")]
public void KerfSide_KeepsThePartOutOfTheKerf(bool partSubprograms, bool reversed, string expected)
{
var run = CincinnatiReplay.Run(CincinnatiReplay.Post(SquareWithHole(reversed: reversed), partSubprograms));
Assert.Equal(2, run.Pierces);
Assert.All(run.Moves, move => Assert.Equal(expected, move.Compensation));
// The first move after each pierce is the lead-in, which runs through scrap.
var checkedCuts = 0;
foreach (var move in run.Moves.Where((m, i) => i > 0 && run.Moves[i - 1].Pierce == m.Pierce))
{
AssertPartOnCompensatedSide(move);
checkedCuts++;
}
Assert.Equal(5, checkedCuts);
}
[Theory]
[InlineData(false, Mode.Absolute, Mode.Absolute)]
[InlineData(false, Mode.Incremental, Mode.Absolute)]
[InlineData(false, Mode.Incremental, Mode.Incremental)]
[InlineData(true, Mode.Absolute, Mode.Absolute)]
[InlineData(true, Mode.Incremental, Mode.Absolute)]
[InlineData(true, Mode.Incremental, Mode.Incremental)]
public void AbsoluteAndIncrementalPrograms_PostTheSameProgram(bool partSubprograms, Mode partMode, Mode holeMode)
{
var expected = WithoutDate(CincinnatiReplay.Post(SquareWithHole(Mode.Absolute, Mode.Incremental), partSubprograms));
var actual = WithoutDate(CincinnatiReplay.Post(SquareWithHole(partMode, holeMode), partSubprograms));
Assert.Equal(expected, actual);
}
// The header's third line is the posting time.
private static string WithoutDate(string output) =>
string.Join('\n', output.Split('\n').Where((_, i) => i != 2));
/// <summary>
/// Mid-line (or at an arc's start), the point just right of travel is in the part under G41
/// and the point just left of it under G42; the other side is the kerf's.
/// </summary>
private static void AssertPartOnCompensatedSide(CincinnatiReplay.CutMove move)
{
Vector at, travel;
if (move.Center is { } center)
{
at = move.From;
var radial = move.From - center;
travel = move.Clockwise ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
}
else
{
at = (move.From + move.To) * 0.5;
travel = move.To - move.From;
}
var length = System.Math.Sqrt(travel.X * travel.X + travel.Y * travel.Y);
var right = new Vector(travel.Y / length, -travel.X / length);
var partSide = move.Compensation == "G41" ? right : new Vector(-right.X, -right.Y);
Assert.True(InPart(at + partSide * 0.01), $"Part is not on the compensated side at {at}");
Assert.False(InPart(at - partSide * 0.01), $"Kerf side is in the part at {at}");
}
private static bool InPart(Vector p) =>
p.X > Location.X && p.X < Location.X + 10
&& p.Y > Location.Y && p.Y < Location.Y + 10
&& p.DistanceTo(Location + new Vector(5, 5)) > 0.5;
private static void AssertPoints(IEnumerable<Vector> expected, IEnumerable<Vector> actual)
{
var want = expected.ToList();
var got = actual.ToList();
Assert.Equal(want.Count, got.Count);
for (var i = 0; i < want.Count; i++)
Assert.True(CincinnatiReplay.Near(want[i], got[i]), $"Point {i} is {got[i]}, expected {want[i]}");
}
}
@@ -43,6 +43,12 @@ internal static class CincinnatiReplay
/// <summary>Every cutting move, start to end, in machine coordinates.</summary>
public List<(Vector From, Vector To)> Cuts { get; } = new();
/// <summary>
/// Every cutting move with the pierce it follows (1-based), its arc centre (null for
/// a line), direction and the compensation in force: "G41", "G42" or null after G40.
/// </summary>
public List<CutMove> Moves { get; } = new();
/// <summary>Head raises posted while the head was already raised.</summary>
public int DoubleRaises { get; set; }
@@ -50,6 +56,8 @@ internal static class CincinnatiReplay
public int HeadDownRapids { get; set; }
}
internal sealed record CutMove(int Pierce, Vector From, Vector To, Vector? Center, bool Clockwise, string? Compensation);
/// <summary>
/// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames,
/// G0-G3 under G90 with incremental I/J, and M47 head raises (a block-deleted
@@ -74,6 +82,7 @@ internal static class CincinnatiReplay
var cutSincePierce = false;
string? raise = null; // raise posted since the last pierce
var headDown = false;
string? compensation = null;
var pc = 0;
while (pc < lines.Count)
@@ -89,6 +98,10 @@ internal static class CincinnatiReplay
line = line[1..];
var words = Words(line);
if (line.Contains("G41", System.StringComparison.Ordinal))
compensation = "G41";
else if (line.Contains("G42", System.StringComparison.Ordinal))
compensation = "G42";
if (words.TryGetValue("M", out var m))
{
if (m == "30")
@@ -127,6 +140,9 @@ internal static class CincinnatiReplay
switch (g)
{
case "40":
compensation = null;
break;
case "84":
case "85":
run.Pierces++;
@@ -150,9 +166,11 @@ internal static class CincinnatiReplay
case "3":
{
var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"]));
Vector? arcCenter = null;
if (g is "2" or "3")
{
var center = position + new Vector(Num(words["I"]), Num(words["J"]));
arcCenter = center;
var radius = position.DistanceTo(center);
Assert.True(
System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3,
@@ -167,7 +185,10 @@ internal static class CincinnatiReplay
{
cutSincePierce = true;
if (pierced)
{
run.Cuts.Add((position, target));
run.Moves.Add(new CutMove(run.Pierces, position, target, arcCenter, g == "2", compensation));
}
}
position = target;
break;
@@ -21,6 +21,12 @@ public sealed class FeatureContext
/// <summary>True when this feature cuts the part's exterior (selects ExteriorM47).</summary>
public bool IsExteriorFeature { get; set; }
public bool IsEtch { get; set; }
/// <summary>
/// Compensation that keeps the kerf in the scrap for the way this contour runs
/// (<see cref="FeatureUtils.KerfSides"/>): Left posts G41, Right G42, None neither.
/// </summary>
public KerfType Kerf { get; set; } = KerfType.Left;
public string LibraryFile { get; set; } = "";
public double CutDistance { get; set; }
public double SheetDiagonal { get; set; }
@@ -148,13 +154,9 @@ public sealed class CincinnatiFeatureWriter
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (
!ctx.IsEtch
&& !kerfEmitted
&& _config.KerfCompensation == KerfMode.ControllerSide
)
if (!kerfEmitted && Compensates(ctx))
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
sb.Append(ctx.Kerf == KerfType.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
@@ -188,13 +190,9 @@ public sealed class CincinnatiFeatureWriter
var sb = new StringBuilder();
// Kerf compensation on first cutting move (skip for etch)
if (
!ctx.IsEtch
&& !kerfEmitted
&& _config.KerfCompensation == KerfMode.ControllerSide
)
if (!kerfEmitted && Compensates(ctx))
{
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
sb.Append(ctx.Kerf == KerfType.Left ? "G41 " : "G42 ");
kerfEmitted = true;
}
@@ -303,6 +301,9 @@ public sealed class CincinnatiFeatureWriter
return null;
}
private bool Compensates(FeatureContext ctx) =>
!ctx.IsEtch && ctx.Kerf != KerfType.None && _config.KerfCompensation == KerfMode.ControllerSide;
private Vector FindPiercePoint(List<ICode> codes)
{
foreach (var code in codes)
@@ -42,6 +42,7 @@ public sealed class CincinnatiPartSubprogramWriter
/// <summary>
/// Writes a complete part sub-program for the given normalized program.
/// The program must be in absolute mode with coordinates normalized to origin (0,0).
/// With <paramref name="holes"/> every contour is a hole, as in a hole sub-program.
/// </summary>
public void Write(
TextWriter w,
@@ -50,7 +51,8 @@ public sealed class CincinnatiPartSubprogramWriter
int subNumber,
string cutLibrary,
string etchLibrary,
double sheetDiagonal
double sheetDiagonal,
bool holes = false
)
{
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
@@ -60,6 +62,7 @@ public sealed class CincinnatiPartSubprogramWriter
// Classify and order: etch features first, then cut features
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
var exterior = FeatureUtils.FindExterior(ordered);
var kerf = FeatureUtils.KerfSides(ordered, holes);
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
@@ -90,6 +93,7 @@ public sealed class CincinnatiPartSubprogramWriter
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = isLastFeature,
IsExteriorFeature = exterior[i],
Kerf = kerf[i],
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
@@ -18,18 +18,6 @@ namespace OpenNest.Posts.Cincinnati
PreApplied,
}
/// <summary>
/// Specifies which side of the cut line kerf compensation is applied to.
/// </summary>
public enum KerfSide
{
/// <summary>Kerf applied to the left side of the cut.</summary>
Left,
/// <summary>Kerf applied to the right side of the cut.</summary>
Right,
}
/// <summary>
/// Specifies how the M47 head raise before each rapid is posted.
/// </summary>
@@ -209,11 +197,6 @@ namespace OpenNest.Posts.Cincinnati
[PostSetting(KerfSection, 0)]
public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide;
[DisplayName("Default kerf side")]
[Description("Default side for kerf compensation (Left or Right).")]
[PostSetting(KerfSection, 1)]
public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left;
[DisplayName("Interior M47")]
[Description("How M47 is posted after features other than a part's exterior, such as cutouts and etch.")]
[PostSetting(HeadRaiseSection, 0)]
@@ -250,7 +250,8 @@ namespace OpenNest.Posts.Cincinnati
subNum,
initialCutLibrary,
etchLibrary,
sheetDiagonal
sheetDiagonal,
holes: true
);
}
}
@@ -117,6 +117,7 @@ public sealed class CincinnatiSheetWriter
public List<ICode> Codes { get; init; }
public bool IsEtch { get; init; }
public bool IsExterior { get; init; }
public KerfType Kerf { get; init; }
public PartSubprogram Subprogram { get; init; }
/// <summary>First rapid target: the sub-program corner, else the first pierce.</summary>
@@ -166,6 +167,7 @@ public sealed class CincinnatiSheetWriter
var features = FeatureUtils.SplitAndClassify(part);
var exterior = FeatureUtils.FindExterior(features);
var kerf = FeatureUtils.KerfSides(features);
for (var f = 0; f < features.Count; f++)
{
var (codes, isEtch) = features[f];
@@ -178,6 +180,7 @@ public sealed class CincinnatiSheetWriter
Codes = codes,
IsEtch = isEtch,
IsExterior = exterior[f],
Kerf = kerf[f],
Entry = start,
Start = start,
End = end,
@@ -261,6 +264,7 @@ public sealed class CincinnatiSheetWriter
IsFirstFeatureOfPart = isFirstFeatureOfPart,
IsLastFeatureOnSheet = isLastFeature,
IsExteriorFeature = item.IsExterior,
Kerf = item.Kerf,
IsEtch = item.IsEtch,
LibraryFile = item.IsEtch ? etchLibrary : cutLibrary,
CutDistance = FeatureUtils.ComputeCutDistance(codes),
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.CNC.Posting;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -239,6 +240,52 @@ public static class FeatureUtils
return result;
}
/// <summary>
/// Each feature's kerf side from Core's shared winding rule (<see cref="PostContours"/>):
/// the side that keeps the kerf in the scrap the way the contour actually runs. The part's
/// perimeter is picked across all its cut features; with <paramref name="holes"/> every
/// feature is a hole, as in a hole sub-program. Etch features and hole calls get
/// <see cref="KerfType.None"/>: etch is never compensated and a hole sub-program picks its own.
/// </summary>
public static KerfType[] KerfSides(
IReadOnlyList<(List<ICode> codes, bool isEtch)> features,
bool holes = false
)
{
var contours = new List<PostContour>();
var owners = new List<int>();
for (var i = 0; i < features.Count; i++)
{
var (codes, isEtch) = features[i];
if (isEtch || (codes.Count == 1 && codes[0] is SubProgramCall))
continue;
var program = new Program(Mode.Absolute);
program.Codes.AddRange(codes);
foreach (var contour in PostContours.Flatten(program, Vector.Zero))
{
contours.Add(contour);
owners.Add(i);
}
}
if (holes)
{
foreach (var contour in contours)
contour.Kerf = ContourWinding.CompensationSide(contour, isPerimeter: false);
}
else
{
PostContours.Classify(contours);
}
// A feature is one contour; its first contour decides if it ever splits.
var sides = new KerfType[features.Count];
for (var k = contours.Count - 1; k >= 0; k--)
sides[owners[k]] = contours[k].Kerf;
return sides;
}
private static Box CutBounds(List<ICode> codes)
{
var cut = new Program(Mode.Absolute);
@@ -15,7 +15,6 @@
"UseAntiDive": true,
"UseSmartRapids": false,
"KerfCompensation": "ControllerSide",
"DefaultKerfSide": "Left",
"InteriorM47": "Always",
"ExteriorM47": "Always",
"M47OverrideDistanceThreshold": null,
+7 -1
View File
@@ -104,7 +104,13 @@ block in a sheet or sub-program output follows this order
5. `M130 (ANTI DIVE OFF)` — disable anti-dive, only if configured (§3.130).
6. Contour moves:
- `G41` (left) or `G42` (right) kerf compensation on the first cut move
(§1.41 / §1.42), suppressed for etch features.
(§1.41 / §1.42), suppressed for etch features. The side comes from Core's
shared `PostContours` winding rule, the same one the CI Fiber post uses:
whichever keeps the kerf in the scrap for the way the contour runs. Imported
drawings (perimeter CW, holes CCW) get `G41` throughout; a contour wound the
other way gets `G42`. The perimeter is the part's cut feature with the
largest bounding box; contours in a hole sub-program are always holes.
Paths that enclose no area, such as cut-off lines, keep `G41`.
- `G1 X_ Y_ [F<feedvar>]` — linear cut move (§1.01). Feedrate references a
machine variable such as `#148` and is emitted only when it changes.
- `G2 X_ Y_ I_ J_ [F<feedvar>]` (CW) or `G3` (CCW) — arc (§1.02 / §1.03).