diff --git a/OpenNest.Core/Geometry/ContourSampler.cs b/OpenNest.Core/Geometry/ContourSampler.cs
new file mode 100644
index 0000000..a3acce1
--- /dev/null
+++ b/OpenNest.Core/Geometry/ContourSampler.cs
@@ -0,0 +1,385 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.CNC;
+using OpenNest.Math;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// One world-space point on a cut contour together with the cut direction there.
+ /// Produced by for rendering (cut-direction arrows)
+ /// and for measurement (contour alignment); neither caller may mutate it.
+ ///
+ public readonly struct ContourSample
+ {
+ /// Point on the contour in world coordinates.
+ public Vector Position { get; }
+
+ /// Unit vector pointing in the direction of travel along the contour.
+ public Vector Direction { get; }
+
+ ///
+ /// World-frame tangent angle in radians (atan2 of ).
+ /// Screen-space conversion is the renderer's job.
+ ///
+ public double Tangent { get; }
+
+ ///
+ /// Arclength of this sample from the start of the contour walk it came from:
+ /// cumulative distance along non-rapid, non-suppressed moves for a program
+ /// walk, and from the ring's first vertex for a ring walk.
+ ///
+ public double At { get; }
+
+ public ContourSample(Vector position, Vector direction, double tangent, double at)
+ {
+ Position = position;
+ Direction = direction;
+ Tangent = tangent;
+ At = at;
+ }
+ }
+
+ ///
+ /// Pure contour sampling math shared by the cut-direction arrow renderer and by
+ /// contour alignment. All positions and tangents are world-space; no screen
+ /// conversion, no arrowheads, and no view state appear here.
+ ///
+ /// Two scheduling policies live here deliberately:
+ /// and implement the arrow
+ /// renderer's established display policy (skip moves shorter than half the
+ /// spacing, place max(1, trunc(len/spacing)) arrows strictly inside each
+ /// move, resetting per move), while is a contour-wide
+ /// arclength scheduler for measurement: distance is carried across segment
+ /// boundaries, no segment is omitted, and the closing vertex of a ring is never
+ /// duplicated. Alignment must use the ring policy; display zoom must never
+ /// change an alignment result because alignment spacing comes from the model,
+ /// not the view.
+ ///
+ ///
+ public static class ContourSampler
+ {
+ ///
+ /// Samples one bounded line move using the arrow display policy: no samples
+ /// when the move is shorter than half the spacing or degenerate; otherwise
+ /// max(1, (int)(length / spacing)) samples strictly between the
+ /// endpoints at uniform spacing. Appends to .
+ ///
+ public static void LineMoves(
+ Vector start,
+ Vector end,
+ double spacing,
+ List output
+ )
+ {
+ var dx = end.X - start.X;
+ var dy = end.Y - start.Y;
+ var length = System.Math.Sqrt(dx * dx + dy * dy);
+ if (length < spacing * 0.5)
+ return;
+
+ var dirX = dx / length;
+ var dirY = dy / length;
+ var tangent = System.Math.Atan2(dirY, dirX);
+
+ var count = System.Math.Max(1, (int)(length / spacing));
+ var step = length / (count + 1);
+
+ for (var i = 1; i <= count; i++)
+ {
+ var t = step * i;
+ var pt = new Vector(start.X + dirX * t, start.Y + dirY * t);
+ output.Add(new ContourSample(pt, new Vector(dirX, dirY), tangent, t));
+ }
+ }
+
+ ///
+ /// Samples one bounded arc move using the arrow display policy. The sweep is
+ /// taken in the requested rotation direction and always in (0, 2*PI], so a
+ /// full circle (equal endpoints) yields a full turn. No samples when the arc
+ /// is shorter than half the spacing or the radius is degenerate. Tangents
+ /// follow the direction of travel: +90 degrees from the radius for CCW,
+ /// -90 degrees for CW. Appends to .
+ ///
+ public static void ArcMoves(
+ Vector start,
+ Vector end,
+ Vector center,
+ RotationType rotation,
+ double spacing,
+ List output
+ )
+ {
+ var radius = center.DistanceTo(start);
+ if (radius < Tolerance.Epsilon)
+ return;
+
+ var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
+ var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
+
+ double sweep;
+ if (rotation == RotationType.CCW)
+ {
+ sweep = endAngle - startAngle;
+ if (sweep <= 0)
+ sweep += 2 * System.Math.PI;
+ }
+ else
+ {
+ sweep = startAngle - endAngle;
+ if (sweep <= 0)
+ sweep += 2 * System.Math.PI;
+ }
+
+ var arcLength = radius * System.Math.Abs(sweep);
+ if (arcLength < spacing * 0.5)
+ return;
+
+ var count = System.Math.Max(1, (int)(arcLength / spacing));
+ var stepAngle = sweep / (count + 1);
+
+ for (var i = 1; i <= count; i++)
+ {
+ double angle;
+ if (rotation == RotationType.CCW)
+ angle = startAngle + stepAngle * i;
+ else
+ angle = startAngle - stepAngle * i;
+
+ var pt = new Vector(
+ center.X + radius * System.Math.Cos(angle),
+ center.Y + radius * System.Math.Sin(angle)
+ );
+
+ double tangent;
+ if (rotation == RotationType.CCW)
+ tangent = angle + System.Math.PI / 2;
+ else
+ tangent = angle - System.Math.PI / 2;
+
+ var dir = new Vector(System.Math.Cos(tangent), System.Math.Sin(tangent));
+ var at = radius * System.Math.Abs(stepAngle * i);
+ output.Add(new ContourSample(pt, dir, tangent, at));
+ }
+ }
+
+ ///
+ /// Walks a CNC program in world space with the same traversal policy the cut
+ /// direction renderer has always used: absolute endpoints are relative to
+ /// , incremental endpoints and arc centers are
+ /// relative to the current position, suppressed moves and rapids advance the
+ /// pen but produce no samples, and each sub-program call executes at
+ /// basePos + Offset against a shared program (callers own the shared
+ /// program; this method only reads it). Suppressed sub-program content is
+ /// filtered inside the sub-program itself.
+ ///
+ /// The pen position after the program, so callers keep the
+ /// reference semantics of the renderer's by-ref position.
+ public static Vector ProgramMoves(
+ Program pgm,
+ Vector basePos,
+ Vector pos,
+ double spacing,
+ List output
+ )
+ {
+ var at = 0.0;
+ WalkProgram(pgm, basePos, ref pos, spacing, output, ref at);
+ return pos;
+ }
+
+ private static void WalkProgram(
+ Program pgm,
+ Vector basePos,
+ ref Vector pos,
+ double spacing,
+ List output,
+ ref double at
+ )
+ {
+ for (var i = 0; i < pgm.Length; ++i)
+ {
+ var code = pgm[i];
+
+ if (code.Type == CodeType.SubProgramCall)
+ {
+ var subpgm = (SubProgramCall)code;
+ if (subpgm.Program != null)
+ {
+ var holeBase = basePos + subpgm.Offset;
+ pos = holeBase;
+ WalkProgram(
+ subpgm.Program,
+ holeBase,
+ ref pos,
+ spacing,
+ output,
+ ref at
+ );
+ }
+ continue;
+ }
+
+ if (code is not Motion motion)
+ continue;
+
+ var endpt =
+ pgm.Mode == Mode.Incremental
+ ? motion.EndPoint + pos
+ : motion.EndPoint + basePos;
+
+ if (code.Type == CodeType.LinearMove)
+ {
+ var line = (LinearMove)code;
+ if (!line.Suppressed)
+ {
+ var before = output.Count;
+ LineMoves(pos, endpt, spacing, output);
+ Relocate(output, before, at);
+ at += Distance(pos, endpt);
+ }
+ }
+ else if (code.Type == CodeType.ArcMove)
+ {
+ var arc = (ArcMove)code;
+ if (!arc.Suppressed)
+ {
+ var center =
+ pgm.Mode == Mode.Incremental
+ ? arc.CenterPoint + pos
+ : arc.CenterPoint + basePos;
+ var before = output.Count;
+ ArcMoves(pos, endpt, center, arc.Rotation, spacing, output);
+ Relocate(output, before, at);
+ at += ArcDistance(pos, endpt, center, arc.Rotation);
+ }
+ }
+
+ pos = endpt;
+ }
+ }
+
+ ///
+ /// Resamples a closed ring at near-uniform arclength for measurement. The
+ /// distance counter is carried across segment boundaries, no segment is
+ /// omitted, and the closing vertex is not duplicated: samples sit at
+ /// arclength i * step for i in [0, count) where
+ /// step = perimeter / count divides the perimeter exactly, so the
+ /// sample set is invariant to where the ring's start vertex sits as long as
+ /// the caller quantizes consistently. A duplicated explicit closing vertex
+ /// is accepted and ignored.
+ ///
+ ///
+ /// The ring has fewer than three distinct vertices, nonfinite coordinates,
+ /// or a zero perimeter.
+ ///
+ /// is not finite or not positive.
+ public static void RingMoves(IList ring, double spacing, List output)
+ {
+ if (ring == null)
+ throw new ArgumentNullException(nameof(ring));
+ if (!(spacing > 0) || double.IsInfinity(spacing) || double.IsNaN(spacing))
+ throw new ArgumentOutOfRangeException(nameof(spacing));
+
+ var n = ring.Count;
+ if (n > 1 && ring[0] == ring[n - 1])
+ n--; // ignore an explicit closing vertex; the ring closes implicitly
+ if (n < 3)
+ throw new ArgumentException("Ring needs at least 3 distinct vertices.", nameof(ring));
+
+ var perimeter = 0.0;
+ for (var i = 0; i < n; i++)
+ {
+ var a = ring[i];
+ var b = ring[(i + 1) % n];
+ if (double.IsNaN(a.X) || double.IsNaN(a.Y) || double.IsNaN(b.X) || double.IsNaN(b.Y))
+ throw new ArgumentException("Ring contains nonfinite coordinates.", nameof(ring));
+ perimeter += Distance(a, b);
+ }
+ if (!(perimeter > Tolerance.Epsilon))
+ throw new ArgumentException("Ring has zero perimeter.", nameof(ring));
+
+ var count = System.Math.Max(1, (int)System.Math.Round(perimeter / spacing));
+ var step = perimeter / count;
+
+ var seg = 0;
+ var segStart = 0.0; // cumulative arclength at the start of segment seg
+ for (var k = 0; k < count; k++)
+ {
+ var s = step * k;
+
+ // Carry the walk across segment boundaries; short segments advance
+ // the arclength counter without ever being skipped.
+ var a = ring[seg];
+ var b = ring[(seg + 1) % n];
+ var segLen = Distance(a, b);
+ while (s > segStart + segLen && seg + 1 < n)
+ {
+ segStart += segLen;
+ seg++;
+ a = ring[seg];
+ b = ring[(seg + 1) % n];
+ segLen = Distance(a, b);
+ }
+
+ var local = segLen > 0 ? (s - segStart) / segLen : 0.0;
+ var dir = SegmentDirection(a, b);
+ var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local);
+ output.Add(new ContourSample(pt, dir, System.Math.Atan2(dir.Y, dir.X), s));
+ }
+ }
+
+ private static Vector SegmentDirection(Vector a, Vector b)
+ {
+ var dx = b.X - a.X;
+ var dy = b.Y - a.Y;
+ var len = System.Math.Sqrt(dx * dx + dy * dy);
+ return len > 0 ? new Vector(dx / len, dy / len) : new Vector(1, 0);
+ }
+
+ private static double Distance(Vector a, Vector b)
+ {
+ var dx = b.X - a.X;
+ var dy = b.Y - a.Y;
+ return System.Math.Sqrt(dx * dx + dy * dy);
+ }
+
+ private static double ArcDistance(
+ Vector start,
+ Vector end,
+ Vector center,
+ RotationType rotation
+ )
+ {
+ var radius = center.DistanceTo(start);
+ if (radius < Tolerance.Epsilon)
+ return 0.0;
+
+ // Same sweep convention as ArcMoves: always in (0, 2*PI], so a full
+ // circle counts its whole circumference toward the walk's arclength.
+ var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
+ var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
+ var sweep =
+ rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle;
+ if (sweep <= 0)
+ sweep += 2 * System.Math.PI;
+ return radius * sweep;
+ }
+
+ private static void Relocate(List output, int from, double baseAt)
+ {
+ if (baseAt == 0.0)
+ return;
+ for (var i = from; i < output.Count; i++)
+ {
+ var s = output[i];
+ output[i] = new ContourSample(
+ s.Position,
+ s.Direction,
+ s.Tangent,
+ baseAt + s.At
+ );
+ }
+ }
+ }
+}
diff --git a/OpenNest.Tests/Geometry/ContourSamplerTests.cs b/OpenNest.Tests/Geometry/ContourSamplerTests.cs
new file mode 100644
index 0000000..0f499c8
--- /dev/null
+++ b/OpenNest.Tests/Geometry/ContourSamplerTests.cs
@@ -0,0 +1,399 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Geometry;
+
+///
+/// Characterization tests for the pure sampling math extracted from
+/// CutDirectionArrows: they pin the arrow display policy (per-move counts,
+/// short-move skipping, sweep conventions) and the new contour-wide arclength
+/// scheduler used for alignment measurements.
+///
+public class ContourSamplerTests
+{
+ private const double Tol = 1e-9;
+
+ private static void AssertVector(Vector actual, double x, double y, string what = "")
+ {
+ Assert.True(
+ System.Math.Abs(actual.X - x) < Tol && System.Math.Abs(actual.Y - y) < Tol,
+ $"{what} expected ({x},{y}) but was ({actual.X},{actual.Y})"
+ );
+ }
+
+ // ---------- line policy (mirrors DrawLineArrows) ----------
+
+ [Fact]
+ public void LineMoves_EstablishedCountsAndInteriorPositions()
+ {
+ var samples = new List();
+ ContourSampler.LineMoves(new Vector(0, 0), new Vector(10, 0), 3.0, samples);
+
+ // count = max(1, (int)(10/3)) = 3, step = 10/(3+1) = 2.5
+ Assert.Equal(3, samples.Count);
+ AssertVector(samples[0].Position, 2.5, 0, "sample 0");
+ AssertVector(samples[1].Position, 5.0, 0, "sample 1");
+ AssertVector(samples[2].Position, 7.5, 0, "sample 2");
+ foreach (var s in samples)
+ {
+ AssertVector(s.Direction, 1, 0, "direction");
+ Assert.Equal(0.0, s.Tangent, 9);
+ }
+ Assert.Equal(2.5, samples[0].At, 9);
+ Assert.Equal(5.0, samples[1].At, 9);
+ Assert.Equal(7.5, samples[2].At, 9);
+ }
+
+ [Fact]
+ public void LineMoves_SkipsMovesShorterThanHalfSpacing()
+ {
+ var samples = new List();
+ ContourSampler.LineMoves(new Vector(0, 0), new Vector(1.4, 0), 3.0, samples);
+ Assert.Empty(samples);
+ }
+
+ [Fact]
+ public void LineMoves_AlwaysAtLeastOneArrowWhenKept()
+ {
+ var samples = new List();
+ ContourSampler.LineMoves(new Vector(0, 0), new Vector(2, 0), 3.0, samples);
+ Assert.Single(samples);
+ AssertVector(samples[0].Position, 1.0, 0, "single arrow");
+ }
+
+ [Fact]
+ public void LineMoves_ZeroLengthProducesNoSamples()
+ {
+ var samples = new List();
+ ContourSampler.LineMoves(new Vector(3, 3), new Vector(3, 3), 3.0, samples);
+ Assert.Empty(samples);
+ }
+
+ // ---------- arc policy (mirrors DrawArcArrows) ----------
+
+ [Fact]
+ public void ArcMoves_CcwFullCircle_FullSweepAndTangents()
+ {
+ var samples = new List();
+ ContourSampler.ArcMoves(
+ new Vector(5, 0),
+ new Vector(5, 0),
+ new Vector(0, 0),
+ RotationType.CCW,
+ 4.0,
+ samples
+ );
+
+ // radius 5, sweep 2*PI -> arcLength ~31.4159, count = (int)(31.4159/4) = 7
+ Assert.Equal(7, samples.Count);
+ for (var i = 0; i < samples.Count; i++)
+ {
+ var angle = (2 * System.Math.PI * (i + 1)) / (samples.Count + 1);
+ AssertVector(samples[i].Position, 5 * System.Math.Cos(angle), 5 * System.Math.Sin(angle), $"ccw sample {i}");
+ // CCW tangent is the radius angle + 90 degrees
+ AssertVector(
+ samples[i].Direction,
+ System.Math.Cos(angle + System.Math.PI / 2),
+ System.Math.Sin(angle + System.Math.PI / 2),
+ $"ccw tangent {i}"
+ );
+ Assert.Equal(5 * angle, samples[i].At, 7);
+ }
+ }
+
+ [Fact]
+ public void ArcMoves_CwFullCircle_MirrorsCcwPositions()
+ {
+ var cw = new List();
+ ContourSampler.ArcMoves(
+ new Vector(5, 0),
+ new Vector(5, 0),
+ new Vector(0, 0),
+ RotationType.CW,
+ 4.0,
+ cw
+ );
+ var ccw = new List();
+ ContourSampler.ArcMoves(
+ new Vector(5, 0),
+ new Vector(5, 0),
+ new Vector(0, 0),
+ RotationType.CCW,
+ 4.0,
+ ccw
+ );
+
+ Assert.Equal(ccw.Count, cw.Count);
+ for (var i = 0; i < cw.Count; i++)
+ {
+ // CW runs the negative angle family: mirror of the CCW sample across the X axis.
+ AssertVector(cw[i].Position, ccw[i].Position.X, -ccw[i].Position.Y, $"cw sample {i}");
+ AssertVector(cw[i].Direction, ccw[i].Direction.X, -ccw[i].Direction.Y, $"cw dir {i}");
+ }
+ }
+
+ [Fact]
+ public void ArcMoves_HalfTurnSweepIsPositivePi()
+ {
+ var samples = new List();
+ ContourSampler.ArcMoves(
+ new Vector(5, 0),
+ new Vector(-5, 0),
+ new Vector(0, 0),
+ RotationType.CCW,
+ 1.0,
+ samples
+ );
+
+ // sweep = PI, arcLength = 5*PI ~ 15.708, count = 15, stepAngle = PI/16
+ Assert.Equal(15, samples.Count);
+ var stepAngle = System.Math.PI / 16;
+ AssertVector(
+ samples[0].Position,
+ 5 * System.Math.Cos(stepAngle),
+ 5 * System.Math.Sin(stepAngle),
+ "first half-turn sample"
+ );
+ Assert.True(samples[^1].Position.X < 0 && samples[^1].Position.Y > 0);
+ }
+
+ [Fact]
+ public void ArcMoves_DegenerateRadiusProducesNoSamples()
+ {
+ var samples = new List();
+ ContourSampler.ArcMoves(
+ new Vector(0, 0),
+ new Vector(1, 0),
+ new Vector(0, 0),
+ RotationType.CCW,
+ 0.1,
+ samples
+ );
+ Assert.Empty(samples);
+ }
+
+ // ---------- program walk ----------
+
+ private static Program Triangle(double side)
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new LinearMove(0, 0));
+ pgm.Codes.Add(new LinearMove(side, 0));
+ pgm.Codes.Add(new LinearMove(side, side));
+ pgm.Codes.Add(new LinearMove(0, 0));
+ return pgm;
+ }
+
+ [Fact]
+ public void ProgramMoves_AbsoluteEndpointsAreRelativeToBasePos()
+ {
+ var samples = new List();
+ var end = ContourSampler.ProgramMoves(
+ Triangle(10),
+ new Vector(100, 200),
+ new Vector(),
+ 4.0,
+ samples
+ );
+
+ Assert.NotEmpty(samples);
+ // first side runs (100,200)->(110,200): count=(int)(10/4)=2, step=10/3
+ Assert.Contains(samples, s => System.Math.Abs(s.Position.X - (100 + 10.0 / 3)) < Tol && System.Math.Abs(s.Position.Y - 200) < Tol);
+ Assert.Contains(samples, s => System.Math.Abs(s.Position.X - (100 + 20.0 / 3)) < Tol && System.Math.Abs(s.Position.Y - 200) < Tol);
+ AssertVector(end, 100, 200, "pen returns to start of closed triangle");
+ }
+
+ [Fact]
+ public void ProgramMoves_RapidAndSuppressedMovesAdvancePenWithoutSamples()
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new RapidMove(50, 50));
+ var suppressed = new LinearMove(100, 50) { Suppressed = true };
+ pgm.Codes.Add(suppressed);
+ pgm.Codes.Add(new LinearMove(100, 100));
+
+ var samples = new List();
+ var end = ContourSampler.ProgramMoves(pgm, new Vector(), new Vector(), 4.0, samples);
+
+ AssertVector(end, 100, 100, "pen after suppressed move");
+ Assert.All(samples, s => Assert.True(s.Position.X >= 100 - Tol)); // only the final visible line
+ Assert.NotEmpty(samples);
+ }
+
+ [Fact]
+ public void ProgramMoves_SubProgramExecutesAtBasePlusOffset()
+ {
+ var hole = new Program();
+ hole.Codes.Add(new LinearMove(0, 0));
+ hole.Codes.Add(new LinearMove(2, 0));
+ hole.Codes.Add(new LinearMove(0, 0));
+
+ var main = new Program();
+ main.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(10, 0) });
+
+ var samples = new List();
+ ContourSampler.ProgramMoves(main, new Vector(5, 5), new Vector(), 0.5, samples);
+
+ Assert.NotEmpty(samples);
+ // hole geometry lives around x=15, y=5 (basePos + offset), never at origin
+ Assert.All(samples, s => Assert.True(s.Position.X >= 15 - Tol && System.Math.Abs(s.Position.Y - 5) < Tol));
+ }
+
+ [Fact]
+ public void ProgramMoves_IncrementalEndpointsAccumulate()
+ {
+ var pgm = new Program(Mode.Incremental);
+ pgm.Codes.Add(new LinearMove(10, 0));
+ pgm.Codes.Add(new LinearMove(0, 10));
+
+ var samples = new List();
+ var end = ContourSampler.ProgramMoves(
+ pgm,
+ new Vector(),
+ new Vector(),
+ 4.0,
+ samples
+ );
+
+ AssertVector(end, 10, 10, "incremental pen");
+ Assert.Contains(samples, s => s.Position.Y > 0); // second move exists in world space
+ }
+
+ [Fact]
+ public void ProgramMoves_ArcWalkAccumulatesArclengthAcrossMoves()
+ {
+ // quarter circle CCW radius 10 from (10,0) to (0,10)
+ var pgm = new Program();
+ pgm.Codes.Add(new LinearMove(10, 0));
+ pgm.Codes.Add(new ArcMove(0, 10, 0, 0, RotationType.CCW));
+
+ var samples = new List();
+ ContourSampler.ProgramMoves(pgm, new Vector(), new Vector(), 2.0, samples);
+
+ Assert.NotEmpty(samples);
+ var arcSamples = samples.FindAll(s => s.Position.Y > Tol);
+ Assert.NotEmpty(arcSamples);
+ // arc arclength continues after the visible first side; radii hold
+ Assert.All(arcSamples, s => Assert.True(System.Math.Abs(s.Position.DistanceTo(new Vector()) - 10) < 1e-6));
+ var lineSamples = samples.FindAll(s => s.Position.Y <= Tol);
+ Assert.NotEmpty(lineSamples);
+ Assert.All(lineSamples, s => Assert.True(s.At < 10 + Tol, "line samples carry the walk's arclength origin"));
+ }
+
+ // ---------- ring scheduler ----------
+
+ private static readonly Vector[] Square =
+ [
+ new(0, 0), new(10, 0), new(10, 10), new(0, 10),
+ ];
+
+ [Fact]
+ public void RingMoves_UniformArclengthSamples()
+ {
+ var samples = new List();
+ ContourSampler.RingMoves(Square, 2.5, samples);
+
+ Assert.Equal(16, samples.Count); // perimeter 40 / 2.5
+ for (var i = 0; i < samples.Count; i++)
+ {
+ Assert.Equal(2.5 * i, samples[i].At, 7);
+ Assert.True(
+ System.Math.Abs(System.Math.Sqrt(
+ samples[i].Direction.X * samples[i].Direction.X
+ + samples[i].Direction.Y * samples[i].Direction.Y
+ ) - 1) < Tol,
+ "unit direction"
+ );
+ }
+ AssertVector(samples[0].Position, 0, 0, "first sample at ring start");
+ }
+
+ [Fact]
+ public void RingMoves_IgnoresExplicitClosingVertex()
+ {
+ var withClose = new List();
+ ContourSampler.RingMoves([.. Square, new Vector(0, 0)], 2.5, withClose);
+
+ var withoutClose = new List();
+ ContourSampler.RingMoves(Square, 2.5, withoutClose);
+
+ Assert.Equal(withoutClose.Count, withClose.Count);
+ for (var i = 0; i < withClose.Count; i++)
+ {
+ AssertVector(withClose[i].Position, withoutClose[i].Position.X, withoutClose[i].Position.Y);
+ Assert.Equal(withClose[i].At, withoutClose[i].At, 9);
+ }
+ }
+
+ [Fact]
+ public void RingMoves_StartVertexShiftByWholeStepsKeepsSampleSet()
+ {
+ // same square started one vertex along (perimeter shift 10 = 4 steps of 2.5)
+ var shifted = new[] { Square[1], Square[2], Square[3], Square[0] };
+
+ var a = new List();
+ ContourSampler.RingMoves(Square, 2.5, a);
+ var b = new List();
+ ContourSampler.RingMoves(shifted, 2.5, b);
+
+ Assert.Equal(a.Count, b.Count);
+ var setA = a
+ .Select(s => (X: System.Math.Round(s.Position.X, 6), Y: System.Math.Round(s.Position.Y, 6)))
+ .ToHashSet();
+ var setB = b
+ .Select(s => (X: System.Math.Round(s.Position.X, 6), Y: System.Math.Round(s.Position.Y, 6)))
+ .ToHashSet();
+ Assert.True(setA.SetEquals(setB));
+ }
+
+ [Fact]
+ public void RingMoves_ShortSegmentsAdvanceArclengthNotSamplesPerMove()
+ {
+ // one tiny edge among long edges: every sample still sits on the contour,
+ // arclength is strictly increasing, and nothing is omitted
+ var ring = new[]
+ {
+ new Vector(0, 0), new Vector(10, 0), new Vector(10, 0.1), new Vector(0, 0.1),
+ };
+ var samples = new List();
+ ContourSampler.RingMoves(ring, 1.0, samples);
+
+ var perimeter = 10 + 0.1 + 10 + 0.1;
+ Assert.Equal((int)System.Math.Round(perimeter), samples.Count);
+ for (var i = 1; i < samples.Count; i++)
+ Assert.True(samples[i].At > samples[i - 1].At);
+ }
+
+ [Fact]
+ public void RingMoves_RejectsInvalidInput()
+ {
+ Assert.Throws(
+ () => ContourSampler.RingMoves([new Vector(0, 0), new Vector(1, 1)], 1, new())
+ );
+ Assert.Throws(
+ () =>
+ ContourSampler.RingMoves(
+ [new Vector(0, 0), new Vector(double.NaN, 1), new Vector(1, 1)],
+ 1,
+ new()
+ )
+ );
+ Assert.Throws(
+ () =>
+ ContourSampler.RingMoves(
+ [new Vector(1, 1), new Vector(1, 1), new Vector(1, 1)],
+ 1,
+ new()
+ )
+ );
+ Assert.Throws(
+ () => ContourSampler.RingMoves(Square, 0, new())
+ );
+ Assert.Throws(
+ () => ContourSampler.RingMoves(Square, double.NaN, new())
+ );
+ Assert.Throws(
+ () => ContourSampler.RingMoves(null!, 1, new())
+ );
+ }
+}
diff --git a/OpenNest/Controls/CutDirectionArrows.cs b/OpenNest/Controls/CutDirectionArrows.cs
index 9418cee..6d24064 100644
--- a/OpenNest/Controls/CutDirectionArrows.cs
+++ b/OpenNest/Controls/CutDirectionArrows.cs
@@ -1,10 +1,16 @@
+using System.Collections.Generic;
using System.Drawing;
using OpenNest.CNC;
using OpenNest.Geometry;
-using OpenNest.Math;
namespace OpenNest.Controls
{
+ ///
+ /// Draws the cut-direction arrows for a CNC program. The position and tangent
+ /// math lives in (OpenNest.Core); this class owns
+ /// the display side only: screen conversion, arrowheads, and the per-move
+ /// spacing policy the sampler's arrow schedule implements.
+ ///
internal static class CutDirectionArrows
{
public static void DrawProgram(
@@ -31,170 +37,18 @@ namespace OpenNest.Controls
float arrowSize
)
{
- for (var i = 0; i < pgm.Length; ++i)
+ var samples = new List();
+ var end = ContourSampler.ProgramMoves(pgm, basePos, pos, spacing, samples);
+
+ foreach (var sample in samples)
{
- var code = pgm[i];
-
- if (code.Type == CodeType.SubProgramCall)
- {
- var subpgm = (SubProgramCall)code;
- if (subpgm.Program != null)
- {
- var holeBase = basePos + subpgm.Offset;
- pos = holeBase;
- DrawProgram(
- g,
- view,
- subpgm.Program,
- holeBase,
- ref pos,
- pen,
- spacing,
- arrowSize
- );
- }
- continue;
- }
-
- if (code is not Motion motion)
- continue;
-
- var endpt =
- pgm.Mode == Mode.Incremental
- ? motion.EndPoint + pos
- : motion.EndPoint + basePos;
-
- if (code.Type == CodeType.LinearMove)
- {
- var line = (LinearMove)code;
- if (!line.Suppressed)
- DrawLineArrows(g, view, pos, endpt, pen, spacing, arrowSize);
- }
- else if (code.Type == CodeType.ArcMove)
- {
- var arc = (ArcMove)code;
- if (!arc.Suppressed)
- {
- var center =
- pgm.Mode == Mode.Incremental
- ? arc.CenterPoint + pos
- : arc.CenterPoint + basePos;
- DrawArcArrows(
- g,
- view,
- pos,
- endpt,
- center,
- arc.Rotation,
- pen,
- spacing,
- arrowSize
- );
- }
- }
-
- pos = endpt;
- }
- }
-
- private static void DrawLineArrows(
- Graphics g,
- DrawControl view,
- Vector start,
- Vector end,
- Pen pen,
- double spacing,
- float arrowSize
- )
- {
- var dx = end.X - start.X;
- var dy = end.Y - start.Y;
- var length = System.Math.Sqrt(dx * dx + dy * dy);
- if (length < spacing * 0.5)
- return;
-
- var dirX = dx / length;
- var dirY = dy / length;
-
- var count = System.Math.Max(1, (int)(length / spacing));
- var step = length / (count + 1);
-
- for (var i = 1; i <= count; i++)
- {
- var t = step * i;
- var pt = new Vector(start.X + dirX * t, start.Y + dirY * t);
- var screenPt = view.PointWorldToGraph(pt);
- var angle = System.Math.Atan2(-dirY, dirX);
- DrawArrowHead(g, pen, screenPt, angle, arrowSize);
- }
- }
-
- private static void DrawArcArrows(
- Graphics g,
- DrawControl view,
- Vector start,
- Vector end,
- Vector center,
- RotationType rotation,
- Pen pen,
- double spacing,
- float arrowSize
- )
- {
- var radius = center.DistanceTo(start);
- if (radius < Tolerance.Epsilon)
- return;
-
- var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
- var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
-
- double sweep;
- if (rotation == RotationType.CCW)
- {
- sweep = endAngle - startAngle;
- if (sweep <= 0)
- sweep += 2 * System.Math.PI;
- }
- else
- {
- sweep = startAngle - endAngle;
- if (sweep <= 0)
- sweep += 2 * System.Math.PI;
- }
-
- var arcLength = radius * System.Math.Abs(sweep);
- if (arcLength < spacing * 0.5)
- return;
-
- var count = System.Math.Max(1, (int)(arcLength / spacing));
- var stepAngle = sweep / (count + 1);
-
- for (var i = 1; i <= count; i++)
- {
- double angle;
- if (rotation == RotationType.CCW)
- angle = startAngle + stepAngle * i;
- else
- angle = startAngle - stepAngle * i;
-
- var pt = new Vector(
- center.X + radius * System.Math.Cos(angle),
- center.Y + radius * System.Math.Sin(angle)
- );
- var screenPt = view.PointWorldToGraph(pt);
-
- double tangent;
- if (rotation == RotationType.CCW)
- tangent = angle + System.Math.PI / 2;
- else
- tangent = angle - System.Math.PI / 2;
-
- var screenAngle = System.Math.Atan2(
- -System.Math.Sin(tangent),
- System.Math.Cos(tangent)
- );
+ var screenPt = view.PointWorldToGraph(sample.Position);
+ // Screen space flips Y, so the screen angle mirrors the world tangent.
+ var screenAngle = System.Math.Atan2(-sample.Direction.Y, sample.Direction.X);
DrawArrowHead(g, pen, screenPt, screenAngle, arrowSize);
}
+
+ pos = end;
}
private static void DrawArrowHead(Graphics g, Pen pen, PointF tip, double angle, float size)