From cd623ff1aa7843841c527b8435aa6737ea0e3226 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 15:36:44 -0500 Subject: [PATCH] refactor(posts): share contour geometry between posts through Core Move the CI Fiber post's contour extraction and winding into Core as OpenNest.CNC.Posting.PostContours and ContourWinding, so every post can take sheet- or part-frame contours with the perimeter and kerf side already chosen instead of re-deriving them. CI Fiber now writes from the shared model; its golden output is unchanged. Scribe marks are never picked as the perimeter and get no kerf. A cut that resumes after suppressed moves starts a new contour where they end, so it is no longer cut as a chord across the gap. Add tests for a square with a hole in sheet coordinates, identical contours from absolute and incremental part/hole programs, rotation, and the kerf side for both windings. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/CNC/Posting/ContourWinding.cs | 163 +++++++++++ OpenNest.Core/CNC/Posting/PostContour.cs | 59 ++++ OpenNest.Core/CNC/Posting/PostContours.cs | 162 ++++++++++ OpenNest.Tests/CNC/PostContoursTests.cs | 276 ++++++++++++++++++ .../CIFiberContourExtractor.cs | 184 ------------ .../CIFiberProgramWriter.cs | 48 +-- .../CIFiberWinding.cs | 198 ------------- docs/cincinnati-ci-fiber-post-output.md | 8 +- 8 files changed, 678 insertions(+), 420 deletions(-) create mode 100644 OpenNest.Core/CNC/Posting/ContourWinding.cs create mode 100644 OpenNest.Core/CNC/Posting/PostContour.cs create mode 100644 OpenNest.Core/CNC/Posting/PostContours.cs create mode 100644 OpenNest.Tests/CNC/PostContoursTests.cs delete mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs delete mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs 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.