mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 15:58:51 -04:00
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.
This commit is contained in:
1 parent
62fd42346e
commit
a368ef0115
3 files changed
+800
-162
No files matched your search
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// One world-space point on a cut contour together with the cut direction there.
|
||||
/// Produced by <see cref="ContourSampler"/> for rendering (cut-direction arrows)
|
||||
/// and for measurement (contour alignment); neither caller may mutate it.
|
||||
/// </summary>
|
||||
public readonly struct ContourSample
|
||||
{
|
||||
/// <summary>Point on the contour in world coordinates.</summary>
|
||||
public Vector Position { get; }
|
||||
|
||||
/// <summary>Unit vector pointing in the direction of travel along the contour.</summary>
|
||||
public Vector Direction { get; }
|
||||
|
||||
/// <summary>
|
||||
/// World-frame tangent angle in radians (atan2 of <see cref="Direction"/>).
|
||||
/// Screen-space conversion is the renderer's job.
|
||||
/// </summary>
|
||||
public double Tangent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double At { get; }
|
||||
|
||||
public ContourSample(Vector position, Vector direction, double tangent, double at)
|
||||
{
|
||||
Position = position;
|
||||
Direction = direction;
|
||||
Tangent = tangent;
|
||||
At = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// Two scheduling policies live here deliberately:
|
||||
/// <see cref="LineMoves"/> and <see cref="ArcMoves"/> implement the arrow
|
||||
/// renderer's established display policy (skip moves shorter than half the
|
||||
/// spacing, place <c>max(1, trunc(len/spacing))</c> arrows strictly inside each
|
||||
/// move, resetting per move), while <see cref="RingMoves"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ContourSampler
|
||||
{
|
||||
/// <summary>
|
||||
/// Samples one bounded line move using the arrow display policy: no samples
|
||||
/// when the move is shorter than half the spacing or degenerate; otherwise
|
||||
/// <c>max(1, (int)(length / spacing))</c> samples strictly between the
|
||||
/// endpoints at uniform spacing. Appends to <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void LineMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
double spacing,
|
||||
List<ContourSample> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="output"/>.
|
||||
/// </summary>
|
||||
public static void ArcMoves(
|
||||
Vector start,
|
||||
Vector end,
|
||||
Vector center,
|
||||
RotationType rotation,
|
||||
double spacing,
|
||||
List<ContourSample> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a CNC program in world space with the same traversal policy the cut
|
||||
/// direction renderer has always used: absolute endpoints are relative to
|
||||
/// <paramref name="basePos"/>, 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
|
||||
/// <c>basePos + Offset</c> 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.
|
||||
/// </summary>
|
||||
/// <returns>The pen position after the program, so callers keep the
|
||||
/// reference semantics of the renderer's by-ref position.</returns>
|
||||
public static Vector ProgramMoves(
|
||||
Program pgm,
|
||||
Vector basePos,
|
||||
Vector pos,
|
||||
double spacing,
|
||||
List<ContourSample> 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<ContourSample> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>i * step</c> for <c>i in [0, count)</c> where
|
||||
/// <c>step = perimeter / count</c> 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.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The ring has fewer than three distinct vertices, nonfinite coordinates,
|
||||
/// or a zero perimeter.
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="spacing"/> is not finite or not positive.</exception>
|
||||
public static void RingMoves(IList<Vector> ring, double spacing, List<ContourSample> 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<ContourSample> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization tests for the pure sampling math extracted from
|
||||
/// <c>CutDirectionArrows</c>: 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.
|
||||
/// </summary>
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
ContourSampler.ArcMoves(
|
||||
new Vector(5, 0),
|
||||
new Vector(5, 0),
|
||||
new Vector(0, 0),
|
||||
RotationType.CW,
|
||||
4.0,
|
||||
cw
|
||||
);
|
||||
var ccw = new List<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ContourSample>();
|
||||
ContourSampler.RingMoves([.. Square, new Vector(0, 0)], 2.5, withClose);
|
||||
|
||||
var withoutClose = new List<ContourSample>();
|
||||
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<ContourSample>();
|
||||
ContourSampler.RingMoves(Square, 2.5, a);
|
||||
var b = new List<ContourSample>();
|
||||
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<ContourSample>();
|
||||
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<ArgumentException>(
|
||||
() => ContourSampler.RingMoves([new Vector(0, 0), new Vector(1, 1)], 1, new())
|
||||
);
|
||||
Assert.Throws<ArgumentException>(
|
||||
() =>
|
||||
ContourSampler.RingMoves(
|
||||
[new Vector(0, 0), new Vector(double.NaN, 1), new Vector(1, 1)],
|
||||
1,
|
||||
new()
|
||||
)
|
||||
);
|
||||
Assert.Throws<ArgumentException>(
|
||||
() =>
|
||||
ContourSampler.RingMoves(
|
||||
[new Vector(1, 1), new Vector(1, 1), new Vector(1, 1)],
|
||||
1,
|
||||
new()
|
||||
)
|
||||
);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => ContourSampler.RingMoves(Square, 0, new())
|
||||
);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => ContourSampler.RingMoves(Square, double.NaN, new())
|
||||
);
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => ContourSampler.RingMoves(null!, 1, new())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws the cut-direction arrows for a CNC program. The position and tangent
|
||||
/// math lives in <see cref="ContourSampler"/> (OpenNest.Core); this class owns
|
||||
/// the display side only: screen conversion, arrowheads, and the per-move
|
||||
/// spacing policy the sampler's arrow schedule implements.
|
||||
/// </summary>
|
||||
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<ContourSample>();
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user