From a368ef0115c704ed515d901f5f4db0144cc9d9c6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 01:53:33 -0400 Subject: [PATCH] refactor(geometry): share cut-direction sampling primitives Extract the pure world-space position/tangent math from the WinForms cut-direction arrow renderer into Core (ContourSampler), keeping screen conversion, arrowheads, and the per-move display policy in the view. Add a contour-wide arclength scheduler (RingMoves) for measurement use: distance carries across segment boundaries, short segments are never omitted, and the closing vertex is not duplicated. Characterization tests pin the arrow policy (counts, short-move skipping, CW/CCW and full-circle sweeps, subprogram offsets, suppressed/rapid moves, incremental mode) and the scheduler's start-vertex invariance. --- OpenNest.Core/Geometry/ContourSampler.cs | 385 +++++++++++++++++ .../Geometry/ContourSamplerTests.cs | 399 ++++++++++++++++++ OpenNest/Controls/CutDirectionArrows.cs | 178 +------- 3 files changed, 800 insertions(+), 162 deletions(-) create mode 100644 OpenNest.Core/Geometry/ContourSampler.cs create mode 100644 OpenNest.Tests/Geometry/ContourSamplerTests.cs 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)