mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-12 17:01:09 -05:00
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:
1 parent
4670577110
commit
cd623ff1aa
8 files changed
+678
-420
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user