diff --git a/OpenNest.Core/CNC/Posting/ContourWinding.cs b/OpenNest.Core/CNC/Posting/ContourWinding.cs
new file mode 100644
index 00000000..4317046d
--- /dev/null
+++ b/OpenNest.Core/CNC/Posting/ContourWinding.cs
@@ -0,0 +1,163 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.Posting;
+
+///
+/// Which posted contour is the perimeter, and which compensation side keeps the kerf in the
+/// scrap. Imported drawings are wound perimeter CW and holes CCW
+/// (), which puts the part right of travel and gives
+/// G41 throughout; a contour wound the other way (built in code or edited by hand) needs G42.
+/// Paths that enclose no area (open break cuts) get G41, a defensive default.
+///
+public static class ContourWinding
+{
+ /// Signed shoelace area of the cut path; negative when clockwise, 0 when degenerate.
+ public static double SignedArea(PostContour 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;
+ }
+
+ ///
+ /// Index of the perimeter: the material contour enclosing the largest bounding box, as
+ /// picks it. -1 when none encloses any area.
+ ///
+ public static int PerimeterIndex(IReadOnlyList contours)
+ {
+ var best = -1;
+ var bestArea = 0.0;
+ for (var i = 0; i < contours.Count; i++)
+ {
+ if (contours[i].IsMark || !EnclosesArea(contours[i]))
+ continue;
+
+ var area = BoundingBoxArea(SamplePoints(contours[i]));
+ if (best < 0 || area > bestArea)
+ {
+ best = i;
+ bestArea = area;
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// (G41, part right of travel) for a CW perimeter, a CCW hole or
+ /// a path enclosing no area; otherwise (G42).
+ ///
+ public static KerfType CompensationSide(PostContour contour, bool isPerimeter)
+ {
+ if (!EnclosesArea(contour))
+ return KerfType.Left;
+
+ var clockwise = SignedArea(contour) < 0.0;
+ return clockwise == isPerimeter ? KerfType.Left : KerfType.Right;
+ }
+
+ private static bool EnclosesArea(PostContour 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);
+ }
+
+ ///
+ /// Ordered points along the path: the contour start, then each cut endpoint, with arc
+ /// interior samples at no more than 90-degree steps so full circles give a well-formed
+ /// polygon. Consecutive duplicates are dropped so zero-length lead-ins do not skew it.
+ ///
+ private static List SamplePoints(PostContour contour)
+ {
+ var pts = new List();
+
+ 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;
+ }
+
+ ///
+ /// Interior points on the arc from to its endpoint at no more than
+ /// ~90-degree steps. A full circle yields four, so its shoelace sign is its true winding.
+ ///
+ private static List ArcSamplePoints(Vector prev, ArcMove arc)
+ {
+ var points = new List();
+ 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);
+
+ var sweep = arc.Rotation == RotationType.CW ? a0 - a1 : 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;
+ }
+}
diff --git a/OpenNest.Core/CNC/Posting/PostContour.cs b/OpenNest.Core/CNC/Posting/PostContour.cs
new file mode 100644
index 00000000..9a112348
--- /dev/null
+++ b/OpenNest.Core/CNC/Posting/PostContour.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.Posting;
+
+///
+/// One contour of a posted part: pierce, lead-in and cut path in a single absolute frame
+/// (sheet coordinates from ). Arc moves keep their
+/// absolute centres; each post formats I/J its own way.
+///
+public sealed class PostContour
+{
+ /// Pierce point: the rapid target.
+ public Vector Pierce { get; set; }
+
+ ///
+ /// First lead-in move, ending on the contour, or null when the contour starts cutting at
+ /// the pierce.
+ ///
+ public Motion LeadIn { get; set; }
+
+ /// Lead-in moves after the first (e.g. the arc after a clean-hole line).
+ public List LeadInExtra { get; } = new();
+
+ /// Moves made with the cut layer on: cut, lead-out, display and scribe.
+ public List Cuts { get; } = new();
+
+ ///
+ /// Head-height lock changes in a cut-off's overcut: each comes before the cut at
+ /// Before (.Count: after the last cut), locking when Lock.
+ ///
+ public List<(int Before, bool Lock)> HeadLocks { get; } = new();
+
+ /// True for the part's outside edge; false for holes, marks and cut-offs.
+ public bool IsPerimeter { get; set; }
+
+ ///
+ /// The compensation that keeps the kerf in the scrap: (G41)
+ /// leaves the part right of travel, (G42) left of it.
+ /// for marks and cut-offs, which have no inside.
+ ///
+ public KerfType Kerf { get; set; }
+
+ /// True when every cut is on the scribe layer: a mark, not material.
+ public bool IsMark
+ {
+ get
+ {
+ if (Cuts.Count == 0)
+ return false;
+ foreach (var cut in Cuts)
+ {
+ if (PostContours.LayerOf(cut) != LayerType.Scribe)
+ return false;
+ }
+ return true;
+ }
+ }
+}
diff --git a/OpenNest.Core/CNC/Posting/PostContours.cs b/OpenNest.Core/CNC/Posting/PostContours.cs
new file mode 100644
index 00000000..ac85ddd0
--- /dev/null
+++ b/OpenNest.Core/CNC/Posting/PostContours.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.Posting;
+
+///
+/// Reduces a part program to the contours a post writes: hole calls and incremental programs
+/// flattened to one absolute frame, each contour split into pierce, lead-in and cuts, the
+/// perimeter picked out and the kerf side chosen. Posts format these; they do not redo the
+/// geometry. Caller programs are never modified.
+///
+public static class PostContours
+{
+ ///
+ /// The part's contours in sheet coordinates. A display perimeter's tail is never cut and an
+ /// unplanned one is refused (). Cut-off contours
+ /// are open lines on the beam centreline: no perimeter and no kerf.
+ ///
+ public static List Extract(Part part)
+ {
+ if (part == null)
+ throw new ArgumentNullException(nameof(part));
+
+ var program = DisplayPerimeterTail.ForPosting(part.Program, DisplayPerimeterTail.PartName(part));
+ var contours = Extract(program, part.Location);
+ if (part.BaseDrawing?.IsCutOff == true)
+ {
+ foreach (var contour in contours)
+ {
+ contour.IsPerimeter = false;
+ contour.Kerf = KerfType.None;
+ }
+ }
+ return contours;
+ }
+
+ ///
+ /// The program's contours with added to every point: the part
+ /// location for sheet coordinates, for the part's own frame.
+ ///
+ public static List Extract(Program program, Vector origin)
+ {
+ var contours = new List();
+ Flatten(program, origin, contours);
+ Classify(contours);
+ return contours;
+ }
+
+ /// Picks the perimeter (marks excluded) and each contour's kerf side.
+ private static void Classify(List contours)
+ {
+ var perimeter = ContourWinding.PerimeterIndex(contours);
+ for (var i = 0; i < contours.Count; i++)
+ {
+ var contour = contours[i];
+ contour.IsPerimeter = i == perimeter;
+ contour.Kerf = contour.IsMark
+ ? KerfType.None
+ : ContourWinding.CompensationSide(contour, contour.IsPerimeter);
+ }
+ }
+
+ ///
+ /// Walks one program whose coordinates are relative to ; hole calls
+ /// recurse with their offset added. A contour starts at each rapid. Moves before the first
+ /// rapid start at the frame origin, where an absolute program begins. A cut after a run of
+ /// 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.
+ ///
+ private static void Flatten(Program program, Vector origin, List contours)
+ {
+ if (program == null)
+ return;
+
+ var codes = program.Codes;
+ if (program.Mode == Mode.Incremental)
+ {
+ var clone = (Program)program.Clone();
+ clone.Mode = Mode.Absolute;
+ codes = clone.Codes;
+ }
+
+ PostContour current = null;
+ Vector? resumeAt = 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: true } suppressed)
+ {
+ resumeAt = suppressed.EndPoint;
+ continue;
+ }
+
+ if (HeadHeightLock.IsLock(code) || HeadHeightLock.IsUnlock(code))
+ {
+ current ??= new PostContour { Pierce = origin };
+ current.HeadLocks.Add((current.Cuts.Count, HeadHeightLock.IsLock(code)));
+ continue;
+ }
+
+ switch (code)
+ {
+ case RapidMove rapid:
+ Flush();
+ resumeAt = null;
+ current = new PostContour { Pierce = rapid.EndPoint + origin };
+ break;
+
+ case SubProgramCall call:
+ // A hole call is its own contour.
+ Flush();
+ resumeAt = null;
+ Flatten(call.Program, origin + call.Offset, contours);
+ break;
+
+ case Motion motion:
+ if (resumeAt.HasValue)
+ {
+ Flush();
+ current = new PostContour { Pierce = resumeAt.Value + origin };
+ resumeAt = null;
+ }
+ current ??= new PostContour { Pierce = origin };
+
+ var absolute = (Motion)motion.Clone();
+ absolute.Offset(origin);
+
+ if (LayerOf(motion) == LayerType.Leadin)
+ {
+ if (current.LeadIn == null)
+ current.LeadIn = absolute;
+ else
+ current.LeadInExtra.Add(absolute);
+ }
+ else
+ {
+ // Contour moves are tagged Display by default and still cut.
+ current.Cuts.Add(absolute);
+ }
+ break;
+ }
+ }
+
+ Flush();
+ }
+
+ internal static LayerType LayerOf(Motion motion) =>
+ motion switch
+ {
+ LinearMove linear => linear.Layer,
+ ArcMove arc => arc.Layer,
+ _ => LayerType.Cut,
+ };
+}
diff --git a/OpenNest.Tests/CNC/PostContoursTests.cs b/OpenNest.Tests/CNC/PostContoursTests.cs
new file mode 100644
index 00000000..615a9667
--- /dev/null
+++ b/OpenNest.Tests/CNC/PostContoursTests.cs
@@ -0,0 +1,276 @@
+using OpenNest.CNC;
+using OpenNest.CNC.Posting;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CNC;
+
+///
+/// The geometry every post writes from: a square with a hole flattened to sheet coordinates,
+/// the same contours whichever mode the part and hole programs are stored in, and a kerf side
+/// that keeps the part out of the kerf.
+///
+public class PostContoursTests
+{
+ private static readonly Vector Location = new(1, 2);
+
+ ///
+ /// 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, the way the cutting strategy writes them. Built absolute, then
+ /// switched to the requested modes.
+ ///
+ private static Program SquareWithHole(Mode partMode = Mode.Absolute, Mode holeMode = Mode.Incremental,
+ bool reversed = false)
+ {
+ var holeRotation = reversed ? RotationType.CW : RotationType.CCW;
+ 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, holeRotation) { 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;
+ return pgm;
+ }
+
+ private static Part Place(Program program) => new(new Drawing("square-hole", program), Location);
+
+ [Fact]
+ public void SquareWithHole_PostsInSheetCoordinates()
+ {
+ var contours = PostContours.Extract(Place(SquareWithHole()));
+
+ Assert.Equal(2, contours.Count);
+
+ // The hole: part location + call offset + hole-local points.
+ var hole = contours[0];
+ Assert.False(hole.IsPerimeter);
+ AssertPoint(new Vector(6, 6.4), hole.Pierce);
+ AssertPoint(new Vector(6, 6.5), hole.LeadIn.EndPoint);
+ var arc = Assert.IsType(Assert.Single(hole.Cuts));
+ AssertPoint(new Vector(6, 6.5), arc.EndPoint);
+ AssertPoint(new Vector(6, 7), arc.CenterPoint);
+ Assert.Equal(RotationType.CCW, arc.Rotation);
+
+ var perimeter = contours[1];
+ Assert.True(perimeter.IsPerimeter);
+ AssertPoint(new Vector(1, 1.5), perimeter.Pierce);
+ AssertPoint(new Vector(1, 2), perimeter.LeadIn.EndPoint);
+ Assert.Equal(
+ new[] { new Vector(1, 12), new Vector(11, 12), new Vector(11, 2), new Vector(1, 2) },
+ perimeter.Cuts.Select(c => Round(c.EndPoint)));
+ }
+
+ [Theory]
+ [InlineData(Mode.Absolute, Mode.Absolute)]
+ [InlineData(Mode.Absolute, Mode.Incremental)]
+ [InlineData(Mode.Incremental, Mode.Absolute)]
+ [InlineData(Mode.Incremental, Mode.Incremental)]
+ public void AbsoluteAndIncrementalPrograms_PostTheSameContours(Mode partMode, Mode holeMode)
+ {
+ var expected = PostContours.Extract(Place(SquareWithHole(Mode.Absolute, Mode.Absolute)));
+ var program = SquareWithHole(partMode, holeMode);
+
+ var actual = PostContours.Extract(Place(program));
+
+ AssertSameContours(expected, actual);
+ Assert.Equal(partMode, program.Mode);
+ }
+
+ [Fact]
+ public void RotatedPart_RotatesEveryPointAndKeepsTheKerfSide()
+ {
+ var expected = PostContours.Extract(Place(SquareWithHole()));
+ var part = Place(SquareWithHole());
+ part.Rotate(OpenNest.Math.Angle.ToRadians(90));
+
+ var actual = PostContours.Extract(part);
+
+ // A 90-degree CCW turn about the origin: (x, y) -> (-y, x).
+ static Vector Turn(Vector v) => new(-v.Y, v.X);
+ Assert.Equal(expected.Count, actual.Count);
+ for (var i = 0; i < expected.Count; i++)
+ {
+ AssertPoint(Turn(expected[i].Pierce), actual[i].Pierce);
+ AssertPoint(Turn(expected[i].LeadIn.EndPoint), actual[i].LeadIn.EndPoint);
+ Assert.Equal(expected[i].Cuts.Count, actual[i].Cuts.Count);
+ for (var c = 0; c < expected[i].Cuts.Count; c++)
+ AssertPoint(Turn(expected[i].Cuts[c].EndPoint), actual[i].Cuts[c].EndPoint);
+ Assert.Equal(expected[i].Kerf, actual[i].Kerf);
+ Assert.Equal(expected[i].IsPerimeter, actual[i].IsPerimeter);
+ }
+ }
+
+ [Theory]
+ [InlineData(false, KerfType.Left)]
+ [InlineData(true, KerfType.Right)]
+ public void KerfSide_KeepsThePartOutOfTheKerf(bool reversed, KerfType expected)
+ {
+ var contours = PostContours.Extract(Place(SquareWithHole(reversed: reversed)));
+
+ Assert.Equal(2, contours.Count);
+ foreach (var contour in contours)
+ {
+ Assert.Equal(expected, contour.Kerf);
+ AssertPartOnCompensatedSide(contour);
+ }
+ }
+
+ [Fact]
+ public void PartFrame_LeavesThePartLocationOut()
+ {
+ var sheet = PostContours.Extract(Place(SquareWithHole()));
+ var local = PostContours.Extract(SquareWithHole(), Vector.Zero);
+
+ Assert.Equal(sheet.Count, local.Count);
+ for (var i = 0; i < sheet.Count; i++)
+ {
+ AssertPoint(sheet[i].Pierce - Location, local[i].Pierce);
+ for (var c = 0; c < sheet[i].Cuts.Count; c++)
+ AssertPoint(sheet[i].Cuts[c].EndPoint - Location, local[i].Cuts[c].EndPoint);
+ }
+ }
+
+ [Fact]
+ public void SuppressedSide_IsCrossedWithTheBeamOffNotCutAsAChord()
+ {
+ var pgm = new Program(Mode.Absolute);
+ 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)) { Suppressed = true });
+ pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+
+ var contours = PostContours.Extract(pgm, Vector.Zero);
+
+ Assert.Equal(2, contours.Count);
+ Assert.Equal(new[] { new Vector(0, 10) }, contours[0].Cuts.Select(c => Round(c.EndPoint)));
+ AssertPoint(new Vector(10, 10), contours[1].Pierce);
+ Assert.Equal(
+ new[] { new Vector(10, 0), new Vector(0, 0) },
+ contours[1].Cuts.Select(c => Round(c.EndPoint)));
+ }
+
+ [Fact]
+ public void ScribeMark_IsNeverThePerimeterAndHasNoKerf()
+ {
+ var pgm = SquareWithHole();
+ // A mark wider than the part.
+ pgm.Codes.Add(new RapidMove(new Vector(-5, -5)));
+ pgm.Codes.Add(new LinearMove(new Vector(20, -5)) { Layer = LayerType.Scribe });
+ pgm.Codes.Add(new LinearMove(new Vector(20, 20)) { Layer = LayerType.Scribe });
+ pgm.Codes.Add(new LinearMove(new Vector(-5, -5)) { Layer = LayerType.Scribe });
+
+ var contours = PostContours.Extract(pgm, Vector.Zero);
+
+ var mark = Assert.Single(contours, c => c.IsMark);
+ Assert.False(mark.IsPerimeter);
+ Assert.Equal(KerfType.None, mark.Kerf);
+ Assert.True(contours[1].IsPerimeter);
+ }
+
+ [Fact]
+ public void CutOff_HasNoPerimeterOrKerf()
+ {
+ var plate = new Plate(60, 120);
+ var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
+ cutOff.Regenerate(plate, new CutOffSettings());
+
+ var contours = PostContours.Extract(new Part(cutOff.Drawing, Vector.Zero));
+
+ Assert.NotEmpty(contours);
+ Assert.All(contours, c =>
+ {
+ Assert.False(c.IsPerimeter);
+ Assert.Equal(KerfType.None, c.Kerf);
+ });
+ }
+
+ [Fact]
+ public void Extract_LeavesThePartProgramUnchanged()
+ {
+ var program = SquareWithHole(Mode.Incremental, Mode.Incremental);
+ var hole = ((SubProgramCall)program.Codes[0]).Program;
+ var before = program.Codes.Select(c => c.ToString()).ToList();
+
+ PostContours.Extract(Place(program));
+
+ Assert.Equal(Mode.Incremental, program.Mode);
+ Assert.Equal(Mode.Incremental, hole.Mode);
+ Assert.Equal(before, program.Codes.Select(c => c.ToString()));
+ }
+
+ ///
+ /// On the first cut (mid-line, away from corners), the point just right of travel is in the
+ /// part for G41 and the point just left of it is in the part for G42; the other side is the
+ /// kerf's.
+ ///
+ private static void AssertPartOnCompensatedSide(PostContour contour)
+ {
+ var from = contour.LeadIn.EndPoint;
+ var cut = contour.Cuts[0];
+ var start = cut is ArcMove ? from : (from + cut.EndPoint) * 0.5;
+ var travel = cut is ArcMove arc
+ ? Tangent(from, arc)
+ : cut.EndPoint - 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 = contour.Kerf == KerfType.Left ? right : new Vector(-right.X, -right.Y);
+
+ Assert.True(InPart(start + partSide * 0.01), $"Part is not on the compensated side at {start}");
+ Assert.False(InPart(start - partSide * 0.01), $"Kerf side is in the part at {start}");
+ }
+
+ private static Vector Tangent(Vector start, ArcMove arc)
+ {
+ var radial = start - arc.CenterPoint;
+ return arc.Rotation == RotationType.CCW
+ ? new Vector(-radial.Y, radial.X)
+ : new Vector(radial.Y, -radial.X);
+ }
+
+ 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 AssertSameContours(IReadOnlyList expected, IReadOnlyList actual)
+ {
+ Assert.Equal(expected.Count, actual.Count);
+ for (var i = 0; i < expected.Count; i++)
+ {
+ AssertPoint(expected[i].Pierce, actual[i].Pierce);
+ AssertPoint(expected[i].LeadIn.EndPoint, actual[i].LeadIn.EndPoint);
+ Assert.Equal(expected[i].Cuts.Count, actual[i].Cuts.Count);
+ for (var c = 0; c < expected[i].Cuts.Count; c++)
+ {
+ var want = expected[i].Cuts[c];
+ var got = actual[i].Cuts[c];
+ Assert.Equal(want.GetType(), got.GetType());
+ AssertPoint(want.EndPoint, got.EndPoint);
+ if (want is ArcMove wantArc)
+ {
+ var gotArc = (ArcMove)got;
+ AssertPoint(wantArc.CenterPoint, gotArc.CenterPoint);
+ Assert.Equal(wantArc.Rotation, gotArc.Rotation);
+ }
+ }
+ Assert.Equal(expected[i].IsPerimeter, actual[i].IsPerimeter);
+ Assert.Equal(expected[i].Kerf, actual[i].Kerf);
+ }
+ }
+
+ private static void AssertPoint(Vector expected, Vector actual) =>
+ Assert.True(expected.DistanceTo(actual) < 1e-9, $"Expected {expected}, got {actual}");
+
+ private static Vector Round(Vector v) => new(System.Math.Round(v.X, 9), System.Math.Round(v.Y, 9));
+}
diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs
deleted file mode 100644
index ee1a27b2..00000000
--- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs
+++ /dev/null
@@ -1,184 +0,0 @@
-using System;
-using System.Collections.Generic;
-using OpenNest.CNC;
-using OpenNest.Geometry;
-
-namespace OpenNest.Posts.CincinnatiCIFiber
-{
- ///
- /// One contour (pierce + lead-in + cut path) reduced to sheet-absolute
- /// moves, ready to emit as a TF5200 N-label block.
- ///
- public sealed class CIFiberContour
- {
- /// Pierce point (sheet-absolute) — rapid target.
- public Vector Pierce { get; set; }
-
- ///
- /// 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.
- ///
- public Motion LeadIn { get; set; }
-
- ///
- /// 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.
- ///
- public List LeadInExtra { get; } = new();
-
- /// Cutting moves (sheet-absolute) executed after the cut layer turns on.
- public List Cuts { get; } = new();
-
- /// True when the contour is an external perimeter (exterior).
- public bool IsExterior { get; set; }
-
- ///
- /// Head-height lock changes in a cut-off's overcut: each is written before the cut at
- /// Before (.Count: after the last cut), locking when
- /// Lock is true and unlocking otherwise.
- ///
- public List<(int Before, bool Lock)> HeadLocks { get; } = new();
- }
-
- ///
- /// Extracts cut contours for one part in sheet coordinates.
- /// Circular holes arrive as s in an incremental
- /// hole-local program; they are flattened here to sheet-absolute moves the
- /// same way walks them for HPGL output.
- /// Moves with are skipped.
- ///
- public static class CIFiberContourExtractor
- {
- public static List Extract(Part part)
- {
- if (part == null)
- throw new ArgumentNullException(nameof(part));
-
- var contours = new List();
- // A display perimeter's tail is never cut; an unplanned one is refused.
- var program = DisplayPerimeterTail.ForPosting(part.Program, DisplayPerimeterTail.PartName(part));
- Flatten(program, part.Location, Vector.Zero, contours);
- return contours;
- }
-
- ///
- /// 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.
- ///
- private static void Flatten(
- Program program,
- Vector partLocation,
- Vector subOffset,
- List 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;
-
- if (HeadHeightLock.IsLock(code) || HeadHeightLock.IsUnlock(code))
- {
- current ??= new CIFiberContour { Pierce = origin };
- current.HeadLocks.Add((current.Cuts.Count, HeadHeightLock.IsLock(code)));
- 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();
- 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,
- };
- }
- }
-}
diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
index 9072b9c0..00ac9f38 100644
--- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
+++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs
@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.CNC;
+using OpenNest.CNC.Posting;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
@@ -220,26 +221,25 @@ namespace OpenNest.Posts.CincinnatiCIFiber
CIFiberFormatter.Line(w, $"V.E.R4={partNumber}");
- var contours = CIFiberContourExtractor.Extract(part);
+ var contours = PostContours.Extract(part);
if (_config.SkipScribe)
- contours = contours.Where(c => !IsScribeContour(c)).ToList();
+ contours = contours.Where(c => !c.IsMark).ToList();
var isCutOff = part.BaseDrawing.IsCutOff;
- var perimeter = CIFiberWinding.PerimeterIndex(contours);
- for (var i = 0; i < contours.Count; i++)
+ foreach (var contour in contours)
{
contourNumber++;
if (isCutOff)
- WriteCutOffContour(contours[i], contourNumber, w);
+ WriteCutOffContour(contour, contourNumber, w);
else
- WriteContour(contours[i], contours[i].IsExterior || i == perimeter, contourNumber, w);
+ WriteContour(contour, contourNumber, w);
}
CIFiberFormatter.Line(w, "( PART END )");
return contourNumber;
}
- private void WriteContour(CIFiberContour contour, bool isExterior, int contourNumber, TextWriter w)
+ private void WriteContour(PostContour contour, int contourNumber, TextWriter w)
{
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
@@ -263,16 +263,13 @@ namespace OpenNest.Posts.CincinnatiCIFiber
CIFiberFormatter.Line(
w,
SkippableLine(
- isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin
+ contour.IsPerimeter ? _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,
- CIFiberWinding.CompensatesLeft(contour, isExterior) ? "G41" : "G42"
- );
+ // handbook); the contour's kerf side puts the kerf in the scrap. A
+ // posted scribe mark has none and keeps G41.
+ CIFiberFormatter.Line(w, contour.Kerf == KerfType.Right ? "G42" : "G41");
var prev = contour.LeadIn.EndPoint;
CIFiberFormatter.Line(
@@ -306,7 +303,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// and the §13.2.4.1 linear-lead-in rule does not apply. The exterior
/// lead layer still runs so the pierce sequence matches a perimeter.
///
- private void WriteCutOffContour(CIFiberContour contour, int contourNumber, TextWriter w)
+ private void WriteCutOffContour(PostContour contour, int contourNumber, TextWriter w)
{
CIFiberFormatter.Line(w, $"N{contourNumber}:");
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel));
@@ -353,27 +350,6 @@ namespace OpenNest.Posts.CincinnatiCIFiber
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);
///
diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs
deleted file mode 100644
index ef6125ac..00000000
--- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs
+++ /dev/null
@@ -1,198 +0,0 @@
-using System;
-using System.Collections.Generic;
-using OpenNest.CNC;
-using OpenNest.Geometry;
-
-namespace OpenNest.Posts.CincinnatiCIFiber
-{
- ///
- /// 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.
- ///
- /// 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
- {
- ///
- /// Signed shoelace area of the contour path (arcs sampled at their
- /// arc-mid point). 0 for degenerate paths.
- ///
- 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;
- }
-
- ///
- /// 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 int PerimeterIndex(IReadOnlyList contours)
- {
- var best = -1;
- var bestArea = 0.0;
- for (var i = 0; i < contours.Count; i++)
- {
- if (!EnclosesArea(contours[i]))
- continue;
-
- 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.
- 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;
- }
-
- ///
- /// 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.
- ///
- public static List SamplePoints(CIFiberContour contour)
- {
- var pts = new List();
-
- 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;
- }
-
- ///
- /// Interior points on the arc from 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.
- ///
- public static List ArcSamplePoints(Vector prev, ArcMove arc)
- {
- var points = new List();
- 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;
- }
- }
-}
diff --git a/docs/cincinnati-ci-fiber-post-output.md b/docs/cincinnati-ci-fiber-post-output.md
index c5f7e519..dd2a2018 100644
--- a/docs/cincinnati-ci-fiber-post-output.md
+++ b/docs/cincinnati-ci-fiber-post-output.md
@@ -55,8 +55,12 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API:
`G1` toward the origin. Both blocks are blank by default, and a nest with a
head-locked cut-off is refused while either is blank. Whether `ZHSOFF` and the
next contour's `L0` keep a locked head down for the rapid back is unconfirmed.
-- Hole subprogram geometry is inlined. Suppressed moves and, by default,
- wholly scribe contours are omitted.
+- Contour geometry comes from Core's shared `PostContours` (`OpenNest.CNC.Posting`):
+ hole subprograms and incremental programs are flattened to sheet coordinates,
+ and it picks the perimeter (never a scribe mark) and each contour's kerf side.
+ Suppressed moves are omitted; a cut that resumes after them starts a new contour
+ pierced where they end, which this post refuses without a lead-in rather than
+ cutting a chord across the gap. By default, wholly scribe contours are omitted.
- Files use UTF-8 without a BOM and CRLF lines. Default accuracy is three decimal
places: coordinates trim trailing zeros; dimensional header values retain them.