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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 15:36:44 -05:00
1 parent 4670577110
commit cd623ff1aa
8 files changed
+678 -420

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.Posting;
/// <summary>
/// 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
/// (<see cref="ShapeProfile.NormalizeWinding"/>), 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.
/// </summary>
public static class ContourWinding
{
/// <summary>Signed shoelace area of the cut path; negative when clockwise, 0 when degenerate.</summary>
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;
}
/// <summary>
/// Index of the perimeter: the material contour enclosing the largest bounding box, as
/// <see cref="ShapeProfile"/> picks it. -1 when none encloses any area.
/// </summary>
public static int PerimeterIndex(IReadOnlyList<PostContour> 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;
}
/// <summary>
/// <see cref="KerfType.Left"/> (G41, part right of travel) for a CW perimeter, a CCW hole or
/// a path enclosing no area; otherwise <see cref="KerfType.Right"/> (G42).
/// </summary>
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<Vector> 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);
}
/// <summary>
/// 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.
/// </summary>
private static List<Vector> SamplePoints(PostContour contour)
{
var pts = new List<Vector>();
void Add(Vector v)
{
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
pts.Add(v);
}
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
Add(prev);
foreach (var cut in contour.Cuts)
{
if (cut is ArcMove arc)
{
foreach (var mid in ArcSamplePoints(prev, arc))
Add(mid);
}
Add(cut.EndPoint);
prev = cut.EndPoint;
}
return pts;
}
/// <summary>
/// Interior points on the arc from <paramref name="prev"/> to its endpoint at no more than
/// ~90-degree steps. A full circle yields four, so its shoelace sign is its true winding.
/// </summary>
private static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
{
var points = new List<Vector>();
var center = arc.CenterPoint;
var radius = center.DistanceTo(arc.EndPoint);
if (radius < 1e-12)
return points;
var a0 = System.Math.Atan2(prev.Y - center.Y, prev.X - center.X);
var a1 = System.Math.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
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;
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.Posting;
/// <summary>
/// One contour of a posted part: pierce, lead-in and cut path in a single absolute frame
/// (sheet coordinates from <see cref="PostContours.Extract(Part)"/>). Arc moves keep their
/// absolute centres; each post formats I/J its own way.
/// </summary>
public sealed class PostContour
{
/// <summary>Pierce point: the rapid target.</summary>
public Vector Pierce { get; set; }
/// <summary>
/// First lead-in move, ending on the contour, or null when the contour starts cutting at
/// the pierce.
/// </summary>
public Motion LeadIn { get; set; }
/// <summary>Lead-in moves after the first (e.g. the arc after a clean-hole line).</summary>
public List<Motion> LeadInExtra { get; } = new();
/// <summary>Moves made with the cut layer on: cut, lead-out, display and scribe.</summary>
public List<Motion> Cuts { get; } = new();
/// <summary>
/// Head-height lock changes in a cut-off's overcut: each comes before the cut at
/// <c>Before</c> (<see cref="Cuts"/>.Count: after the last cut), locking when <c>Lock</c>.
/// </summary>
public List<(int Before, bool Lock)> HeadLocks { get; } = new();
/// <summary>True for the part's outside edge; false for holes, marks and cut-offs.</summary>
public bool IsPerimeter { get; set; }
/// <summary>
/// The compensation that keeps the kerf in the scrap: <see cref="KerfType.Left"/> (G41)
/// leaves the part right of travel, <see cref="KerfType.Right"/> (G42) left of it.
/// <see cref="KerfType.None"/> for marks and cut-offs, which have no inside.
/// </summary>
public KerfType Kerf { get; set; }
/// <summary>True when every cut is on the scribe layer: a mark, not material.</summary>
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;
}
}
}
+162
View File
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.Posting;
/// <summary>
/// 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.
/// </summary>
public static class PostContours
{
/// <summary>
/// The part's contours in sheet coordinates. A display perimeter's tail is never cut and an
/// unplanned one is refused (<see cref="DisplayPerimeterTail.ForPosting"/>). Cut-off contours
/// are open lines on the beam centreline: no perimeter and no kerf.
/// </summary>
public static List<PostContour> 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;
}
/// <summary>
/// The program's contours with <paramref name="origin"/> added to every point: the part
/// location for sheet coordinates, <see cref="Vector.Zero"/> for the part's own frame.
/// </summary>
public static List<PostContour> Extract(Program program, Vector origin)
{
var contours = new List<PostContour>();
Flatten(program, origin, contours);
Classify(contours);
return contours;
}
/// <summary>Picks the perimeter (marks excluded) and each contour's kerf side.</summary>
private static void Classify(List<PostContour> 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);
}
}
/// <summary>
/// Walks one program whose coordinates are relative to <paramref name="origin"/>; 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.
/// </summary>
private static void Flatten(Program program, Vector origin, List<PostContour> 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,
};
}
+276
View File
@@ -0,0 +1,276 @@
using OpenNest.CNC;
using OpenNest.CNC.Posting;
using OpenNest.Geometry;
namespace OpenNest.Tests.CNC;
/// <summary>
/// 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.
/// </summary>
public class PostContoursTests
{
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, the way the cutting strategy writes them. Built absolute, then
/// switched to the requested modes.
/// </summary>
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<ArcMove>(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()));
}
/// <summary>
/// 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.
/// </summary>
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<PostContour> expected, IReadOnlyList<PostContour> 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));
}
@@ -1,184 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// One contour (pierce + lead-in + cut path) reduced to sheet-absolute
/// moves, ready to emit as a TF5200 N-label block.
/// </summary>
public sealed class CIFiberContour
{
/// <summary>Pierce point (sheet-absolute) — rapid target.</summary>
public Vector Pierce { get; set; }
/// <summary>
/// Lead-in move to the contour start (sheet-absolute endpoint), or
/// null when the contour begins cutting immediately at the pierce.
/// Per TF5200 §13.2.4.1 the first motion block after G41/G42 selection
/// must be LINEAR, so callers must reject contours whose lead-in is an
/// arc unless the config tolerates it.
/// </summary>
public Motion LeadIn { get; set; }
/// <summary>
/// Additional lead-in layer moves after the first (e.g. the arc that
/// follows CleanHoleLeadIn's line). Emitted before the cut layer turns
/// on, like the sample's lead block.
/// </summary>
public List<Motion> LeadInExtra { get; } = new();
/// <summary>Cutting moves (sheet-absolute) executed after the cut layer turns on.</summary>
public List<Motion> Cuts { get; } = new();
/// <summary>True when the contour is an external perimeter (exterior).</summary>
public bool IsExterior { get; set; }
/// <summary>
/// Head-height lock changes in a cut-off's overcut: each is written before the cut at
/// <c>Before</c> (<see cref="Cuts"/>.Count: after the last cut), locking when
/// <c>Lock</c> is true and unlocking otherwise.
/// </summary>
public List<(int Before, bool Lock)> HeadLocks { get; } = new();
}
/// <summary>
/// Extracts cut contours for one part in sheet coordinates.
/// Circular holes arrive as <see cref="SubProgramCall"/>s in an incremental
/// hole-local program; they are flattened here to sheet-absolute moves the
/// same way <see cref="NestPolylineExtractor"/> walks them for HPGL output.
/// Moves with <see cref="Motion.Suppressed"/> are skipped.
/// </summary>
public static class CIFiberContourExtractor
{
public static List<CIFiberContour> Extract(Part part)
{
if (part == null)
throw new ArgumentNullException(nameof(part));
var contours = new List<CIFiberContour>();
// 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;
}
/// <summary>
/// Walk one program whose coordinates are relative to (partLocation +
/// callOffset). Sub-program calls recurse with the extra offset.
/// A contour starts at each rapid move; moves before the first rapid
/// form a contour whose pierce is the last move's endpoint (closed-loop
/// continuation), matching FeatureUtils' synthetic-rapid convention.
/// </summary>
private static void Flatten(
Program program,
Vector partLocation,
Vector subOffset,
List<CIFiberContour> contours
)
{
if (program == null)
return;
var origin = partLocation + subOffset;
// Work on a clone when conversion is needed so caller programs are
// never mutated (the CI sample inlines absolute moves).
var codes = program.Codes;
if (program.Mode == Mode.Incremental)
{
var clone = (Program)program.Clone();
clone.Mode = Mode.Absolute;
codes = clone.Codes;
}
CIFiberContour current = null;
void Flush()
{
if (current != null && (current.LeadIn != null || current.Cuts.Count > 0))
contours.Add(current);
current = null;
}
foreach (var code in codes)
{
if (code is Motion suppressed && suppressed.Suppressed)
continue;
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<CIFiberContour>();
Flatten(call.Program, partLocation, subOffset + call.Offset, sub);
contours.AddRange(sub);
break;
case Motion motion:
if (current == null)
{
// Contour with no explicit rapid (program starts
// mid-contour): in absolute mode the implicit
// start position is the frame origin, which is the
// closure point of a closed contour = the pierce.
current = new CIFiberContour { Pierce = origin };
}
var absolute = (Motion)motion.Clone();
absolute.Offset(origin);
var layer = LayerOf(motion);
if (layer == LayerType.Leadin)
{
// First lead-in move opens the lead block; further
// lead-in moves (CleanHole line+arc) extend it.
if (current.LeadIn == null)
current.LeadIn = absolute;
else
current.LeadInExtra.Add(absolute);
}
else
{
// Cut, Leadout and Display all cut: OpenNest tags
// contour moves Display by default (see
// ContourCuttingStrategy.ConvertShapeToMoves),
// only Scribe is reliably a mark. Scribe contours
// are dropped by the writer when SkipScribe is on.
current.Cuts.Add(absolute);
}
break;
}
}
Flush();
}
private static LayerType LayerOf(Motion motion)
{
return motion switch
{
LinearMove l => l.Layer,
ArcMove a => a.Layer,
_ => LayerType.Cut,
};
}
}
}
@@ -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.
/// </summary>
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);
/// <summary>
@@ -1,198 +0,0 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.CincinnatiCIFiber
{
/// <summary>
/// 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.
/// </summary>
public static class CIFiberWinding
{
/// <summary>
/// Signed shoelace area of the contour path (arcs sampled at their
/// arc-mid point). 0 for degenerate paths.
/// </summary>
public static double SignedArea(CIFiberContour contour)
{
var pts = SamplePoints(contour);
if (pts.Count < 3)
return 0.0;
var sum = 0.0;
for (var i = 0; i < pts.Count; i++)
{
var a = pts[i];
var b = pts[(i + 1) % pts.Count];
sum += a.X * b.Y - b.X * a.Y;
}
return sum / 2.0;
}
/// <summary>
/// Index of the part's perimeter: the contour enclosing the largest
/// bounding box, as ShapeProfile picks it. -1 when no contour encloses
/// any area.
/// </summary>
public static int PerimeterIndex(IReadOnlyList<CIFiberContour> 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;
}
/// <summary>
/// True when G41 (product right of travel) keeps the kerf in the scrap:
/// a CW perimeter or a CCW hole. False calls for G42.
/// </summary>
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<Vector> 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);
}
/// <summary>True when the cut path returns to its start point.</summary>
public static bool IsClosed(CIFiberContour contour, double tolerance = 1e-6)
{
var pts = SamplePoints(contour);
if (pts.Count < 3)
return false;
return pts[0].DistanceTo(pts[^1]) <= tolerance;
}
/// <summary>
/// Ordered points along the path: lead-in endpoint, then each cut move
/// endpoint, with arc interior samples at <=90-degree steps (so full
/// circles produce a well-formed polygon). Consecutive duplicates are
/// dropped so zero-length lead-ins do not skew the sample.
/// </summary>
public static List<Vector> SamplePoints(CIFiberContour contour)
{
var pts = new List<Vector>();
void Add(Vector v)
{
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
pts.Add(v);
}
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
Add(prev);
foreach (var cut in contour.Cuts)
{
if (cut is ArcMove arc)
{
foreach (var mid in ArcSamplePoints(prev, arc))
Add(mid);
}
Add(cut.EndPoint);
prev = cut.EndPoint;
}
return pts;
}
/// <summary>
/// Interior points on the arc from <paramref name="prev"/> to the arc
/// endpoint at no more than ~90-degree sweep intervals. A full circle
/// yields four interior points, giving the shoelace a non-degenerate
/// polygon whose sign is the circle's true winding.
/// </summary>
public static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
{
var points = new List<Vector>();
var center = arc.CenterPoint;
var radius = center.DistanceTo(arc.EndPoint);
if (radius < 1e-12)
return points;
var a0 = System.Math.Atan2(prev.Y - center.Y, prev.X - center.X);
var a1 = System.Math.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
double sweep;
if (arc.Rotation == RotationType.CW)
{
sweep = a0 - a1;
if (sweep <= 0)
sweep += 2.0 * System.Math.PI;
}
else
{
sweep = a1 - a0;
if (sweep <= 0)
sweep += 2.0 * System.Math.PI;
}
// Full circle (start == end) reads as zero sweep above.
if (
sweep < 1e-9
&& System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9
&& System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9
)
sweep = 2.0 * System.Math.PI;
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0)));
for (var k = 1; k <= steps; k++)
{
var angle = a0 + direction * sweep * k / (steps + 1);
points.Add(
new Vector(
center.X + radius * System.Math.Cos(angle),
center.Y + radius * System.Math.Sin(angle)
)
);
}
return points;
}
}
}
+6 -2
View File
@@ -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.