Compare commits
27 Commits
f46bcd4e4b
...
a34811bb6d
| Author | SHA1 | Date | |
|---|---|---|---|
| a34811bb6d | |||
| 9b460f77e5 | |||
| 85bf779f21 | |||
| 641c1cd461 | |||
| 4a5ed1b9c0 | |||
| c40941ed35 | |||
| d6184fdc8f | |||
| d61ec1747a | |||
| 7b815c9579 | |||
| 5568789902 | |||
| fd93cc9db2 | |||
| 740fd79adc | |||
| e1b6752ede | |||
| 18d9bbadfa | |||
| e27def388f | |||
| 356b989424 | |||
| c6652f7707 | |||
| df008081d1 | |||
| 0a294934ae | |||
| f711a2e4d6 | |||
| a4df4027f1 | |||
| 278bbe54ba | |||
| ca5eb53bc1 | |||
| bbc02f6f3f | |||
| 12173204d1 | |||
| cbabf5e9d1 | |||
| 1aac03c9ef |
@@ -0,0 +1,217 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class EllipseConverter
|
||||
{
|
||||
private const int MaxSubdivisionDepth = 12;
|
||||
private const int DeviationSamples = 20;
|
||||
|
||||
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
|
||||
{
|
||||
var x = semiMajor * System.Math.Cos(t);
|
||||
var y = semiMinor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
center.X + x * cos - y * sin,
|
||||
center.Y + x * sin + y * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
var tx = -semiMajor * System.Math.Sin(t);
|
||||
var ty = semiMinor * System.Math.Cos(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
tx * cos - ty * sin,
|
||||
tx * sin + ty * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
|
||||
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
|
||||
var nx = -semiMinor * System.Math.Cos(t);
|
||||
var ny = -semiMajor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
nx * cos - ny * sin,
|
||||
nx * sin + ny * cos);
|
||||
}
|
||||
|
||||
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
|
||||
{
|
||||
// Solve: p1 + s*n1 = p2 + t*n2
|
||||
var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return Vector.Invalid;
|
||||
|
||||
var dx = p2.X - p1.X;
|
||||
var dy = p2.Y - p1.Y;
|
||||
var s = (dx * (-n2.Y) - dy * (-n2.X)) / det;
|
||||
|
||||
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
|
||||
}
|
||||
|
||||
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
||||
{
|
||||
if (tolerance <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += Angle.TwoPI;
|
||||
|
||||
// True circle — emit a single arc (or two for full circle)
|
||||
if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon)
|
||||
return ConvertCircle(center, semiMajor, rotation, startParam, endParam);
|
||||
|
||||
var splits = GetInitialSplits(startParam, endParam);
|
||||
|
||||
var entities = new List<Entity>();
|
||||
for (var i = 0; i < splits.Count - 1; i++)
|
||||
FitSegment(center, semiMajor, semiMinor, rotation,
|
||||
splits[i], splits[i + 1], tolerance, entities, 0);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> ConvertCircle(Vector center, double radius,
|
||||
double rotation, double startParam, double endParam)
|
||||
{
|
||||
var sweep = endParam - startParam;
|
||||
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
|
||||
|
||||
if (isFull)
|
||||
{
|
||||
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
|
||||
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
|
||||
var endAngle2 = startAngle1;
|
||||
|
||||
return new List<Entity>
|
||||
{
|
||||
new Arc(center, radius, startAngle1, midAngle, false),
|
||||
new Arc(center, radius, midAngle, endAngle2, false)
|
||||
};
|
||||
}
|
||||
|
||||
var sa = Angle.NormalizeRad(startParam + rotation);
|
||||
var ea = Angle.NormalizeRad(endParam + rotation);
|
||||
return new List<Entity> { new Arc(center, radius, sa, ea, false) };
|
||||
}
|
||||
|
||||
private static List<double> GetInitialSplits(double startParam, double endParam)
|
||||
{
|
||||
var splits = new List<double> { startParam };
|
||||
|
||||
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
|
||||
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
|
||||
{
|
||||
if (q > startParam + 1e-10 && q < endParam - 1e-10)
|
||||
splits.Add(q);
|
||||
}
|
||||
|
||||
splits.Add(endParam);
|
||||
return splits;
|
||||
}
|
||||
|
||||
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
|
||||
|
||||
if (p0.DistanceTo(p1) < 1e-10)
|
||||
return;
|
||||
|
||||
var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0);
|
||||
var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1);
|
||||
|
||||
var arcCenter = IntersectNormals(p0, n0, p1, n1);
|
||||
|
||||
if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth)
|
||||
{
|
||||
results.Add(new Line(p0, p1));
|
||||
return;
|
||||
}
|
||||
|
||||
var radius = p0.DistanceTo(arcCenter);
|
||||
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
|
||||
t0, t1, arcCenter, radius);
|
||||
|
||||
if (maxDev <= tolerance)
|
||||
{
|
||||
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
|
||||
}
|
||||
else
|
||||
{
|
||||
var tMid = (t0 + t1) / 2.0;
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, Vector arcCenter, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i <= DeviationSamples; i++)
|
||||
{
|
||||
var t = t0 + (t1 - t0) * i / DeviationSamples;
|
||||
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
|
||||
var dist = p.DistanceTo(arcCenter);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector arcCenter, double radius,
|
||||
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
|
||||
double t0, double t1)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
||||
|
||||
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
|
||||
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||
|
||||
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,9 @@ namespace OpenNest.Geometry
|
||||
if (line1 == line2)
|
||||
return false;
|
||||
|
||||
if (line1.Layer?.Name != line2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (!line1.IsCollinearTo(line2))
|
||||
return false;
|
||||
|
||||
@@ -113,9 +116,9 @@ namespace OpenNest.Geometry
|
||||
var b = b1 < b2 ? b1 : b2;
|
||||
|
||||
if (!line1.IsVertical() && line1.Slope() < 0)
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b));
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
|
||||
else
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t));
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -127,6 +130,9 @@ namespace OpenNest.Geometry
|
||||
if (arc1 == arc2)
|
||||
return false;
|
||||
|
||||
if (arc1.Layer?.Name != arc2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (arc1.Center != arc2.Center)
|
||||
return false;
|
||||
|
||||
@@ -161,7 +167,7 @@ namespace OpenNest.Geometry
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
public class ArcCandidate
|
||||
{
|
||||
public int ShapeIndex { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int EndIndex { get; set; }
|
||||
public int LineCount => EndIndex - StartIndex + 1;
|
||||
public Arc FittedArc { get; set; }
|
||||
public double MaxDeviation { get; set; }
|
||||
public Box BoundingBox { get; set; }
|
||||
public bool IsSelected { get; set; } = true;
|
||||
/// <summary>First point of the original line segments this candidate covers.</summary>
|
||||
public Vector FirstPoint { get; set; }
|
||||
/// <summary>Last point of the original line segments this candidate covers.</summary>
|
||||
public Vector LastPoint { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mirror axis defined by a point on the axis and a unit direction vector.
|
||||
/// </summary>
|
||||
public class MirrorAxisResult
|
||||
{
|
||||
public static readonly MirrorAxisResult None = new(Vector.Invalid, Vector.Invalid, 0);
|
||||
|
||||
public Vector Point { get; }
|
||||
public Vector Direction { get; }
|
||||
public double Score { get; }
|
||||
public bool IsValid => Point.IsValid();
|
||||
|
||||
public MirrorAxisResult(Vector point, Vector direction, double score)
|
||||
{
|
||||
Point = point;
|
||||
Direction = direction;
|
||||
Score = score;
|
||||
}
|
||||
|
||||
/// <summary>Reflects a point across this axis.</summary>
|
||||
public Vector Reflect(Vector p)
|
||||
{
|
||||
var dx = p.X - Point.X;
|
||||
var dy = p.Y - Point.Y;
|
||||
var dot = dx * Direction.X + dy * Direction.Y;
|
||||
return new Vector(
|
||||
p.X - 2 * (dx - dot * Direction.X),
|
||||
p.Y - 2 * (dy - dot * Direction.Y));
|
||||
}
|
||||
}
|
||||
|
||||
public class GeometrySimplifier
|
||||
{
|
||||
public double Tolerance { get; set; } = 0.004;
|
||||
public int MinLines { get; set; } = 3;
|
||||
|
||||
public List<ArcCandidate> Analyze(Shape shape)
|
||||
{
|
||||
var candidates = new List<ArcCandidate>();
|
||||
var entities = shape.Entities;
|
||||
var i = 0;
|
||||
|
||||
while (i < entities.Count)
|
||||
{
|
||||
if (entities[i] is not Line and not Arc)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var runStart = i;
|
||||
var layerName = entities[i].Layer?.Name;
|
||||
var lineCount = 0;
|
||||
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
|
||||
{
|
||||
if (entities[i] is Line) lineCount++;
|
||||
i++;
|
||||
}
|
||||
var runEnd = i - 1;
|
||||
|
||||
if (lineCount >= MinLines)
|
||||
FindCandidatesInRun(entities, runStart, runEnd, candidates);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
|
||||
{
|
||||
var selected = candidates
|
||||
.Where(c => c.IsSelected)
|
||||
.OrderBy(c => c.StartIndex)
|
||||
.ToList();
|
||||
|
||||
var newEntities = new List<Entity>();
|
||||
var i = 0;
|
||||
|
||||
foreach (var candidate in selected)
|
||||
{
|
||||
while (i < candidate.StartIndex)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
newEntities.Add(candidate.FittedArc);
|
||||
i = candidate.EndIndex + 1;
|
||||
}
|
||||
|
||||
while (i < shape.Entities.Count)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
var result = new Shape();
|
||||
result.Entities.AddRange(newEntities);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the mirror axis of a shape by testing candidate axes through the
|
||||
/// centroid. Uses PCA to find principal directions, then also tests horizontal
|
||||
/// and vertical. Works for shapes rotated at any angle.
|
||||
/// </summary>
|
||||
public static MirrorAxisResult DetectMirrorAxis(Shape shape)
|
||||
{
|
||||
var midpoints = new List<Vector>();
|
||||
foreach (var e in shape.Entities)
|
||||
midpoints.Add(e.BoundingBox.Center);
|
||||
|
||||
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
||||
|
||||
// Centroid
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
|
||||
cx /= midpoints.Count;
|
||||
cy /= midpoints.Count;
|
||||
var centroid = new Vector(cx, cy);
|
||||
|
||||
// Covariance matrix for PCA
|
||||
var cxx = 0.0;
|
||||
var cxy = 0.0;
|
||||
var cyy = 0.0;
|
||||
foreach (var p in midpoints)
|
||||
{
|
||||
var dx = p.X - cx;
|
||||
var dy = p.Y - cy;
|
||||
cxx += dx * dx;
|
||||
cxy += dx * dy;
|
||||
cyy += dy * dy;
|
||||
}
|
||||
|
||||
// Eigenvectors of 2x2 symmetric matrix via analytic formula
|
||||
var trace = cxx + cyy;
|
||||
var det = cxx * cyy - cxy * cxy;
|
||||
var disc = System.Math.Sqrt(System.Math.Max(0, trace * trace / 4 - det));
|
||||
var lambda1 = trace / 2 + disc;
|
||||
var lambda2 = trace / 2 - disc;
|
||||
|
||||
var candidates = new List<Vector>();
|
||||
|
||||
// PCA eigenvectors (major and minor axes)
|
||||
if (System.Math.Abs(cxy) > 1e-10)
|
||||
{
|
||||
candidates.Add(Normalize(new Vector(lambda1 - cyy, cxy)));
|
||||
candidates.Add(Normalize(new Vector(lambda2 - cyy, cxy)));
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
}
|
||||
|
||||
// Also always test pure horizontal and vertical
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
|
||||
// Score each candidate axis
|
||||
var bestResult = MirrorAxisResult.None;
|
||||
foreach (var dir in candidates)
|
||||
{
|
||||
var score = MirrorMatchScore(midpoints, centroid, dir);
|
||||
if (score > bestResult.Score)
|
||||
bestResult = new MirrorAxisResult(centroid, dir, score);
|
||||
}
|
||||
|
||||
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
|
||||
}
|
||||
|
||||
private static Vector Normalize(Vector v)
|
||||
{
|
||||
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
|
||||
}
|
||||
|
||||
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
|
||||
{
|
||||
var matchTol = 0.1;
|
||||
var matched = 0;
|
||||
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
var p = points[i];
|
||||
|
||||
// Distance from point to axis
|
||||
var dx = p.X - axisPoint.X;
|
||||
var dy = p.Y - axisPoint.Y;
|
||||
var dot = dx * axisDir.X + dy * axisDir.Y;
|
||||
var perpX = dx - dot * axisDir.X;
|
||||
var perpY = dy - dot * axisDir.Y;
|
||||
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
|
||||
|
||||
// Points on the axis count as matched
|
||||
if (dist < matchTol)
|
||||
{
|
||||
matched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reflect across axis and look for partner
|
||||
var mx = p.X - 2 * perpX;
|
||||
var my = p.Y - 2 * perpY;
|
||||
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
|
||||
(points[j].Y - my) * (points[j].Y - my));
|
||||
if (d < matchTol)
|
||||
{
|
||||
matched++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (double)matched / points.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pairs candidates across a mirror axis and forces each pair to use
|
||||
/// the same arc (mirrored). The candidate with more lines or lower
|
||||
/// deviation is kept as the source.
|
||||
/// </summary>
|
||||
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
|
||||
{
|
||||
if (!axis.IsValid || candidates.Count < 2) return;
|
||||
|
||||
var paired = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (paired.Contains(i)) continue;
|
||||
|
||||
var ci = candidates[i];
|
||||
var ciCenter = ci.BoundingBox.Center;
|
||||
|
||||
// Distance from candidate center to axis
|
||||
var dx = ciCenter.X - axis.Point.X;
|
||||
var dy = ciCenter.Y - axis.Point.Y;
|
||||
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
|
||||
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
|
||||
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
|
||||
if (perpDist < 0.1) continue; // on the axis
|
||||
|
||||
var mirrorCenter = axis.Reflect(ciCenter);
|
||||
|
||||
var bestJ = -1;
|
||||
var bestDist = double.MaxValue;
|
||||
for (var j = i + 1; j < candidates.Count; j++)
|
||||
{
|
||||
if (paired.Contains(j)) continue;
|
||||
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
|
||||
if (d < bestDist)
|
||||
{
|
||||
bestDist = d;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
|
||||
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
|
||||
if (bestJ < 0 || bestDist > matchTol) continue;
|
||||
|
||||
paired.Add(i);
|
||||
paired.Add(bestJ);
|
||||
|
||||
var cj = candidates[bestJ];
|
||||
var sourceIdx = i;
|
||||
var targetIdx = bestJ;
|
||||
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
|
||||
{
|
||||
sourceIdx = bestJ;
|
||||
targetIdx = i;
|
||||
}
|
||||
|
||||
var source = candidates[sourceIdx];
|
||||
var target = candidates[targetIdx];
|
||||
var mirrored = MirrorArc(source.FittedArc, axis);
|
||||
|
||||
// Only apply the mirrored arc if its endpoints are close enough to the
|
||||
// target's actual boundary points. Otherwise the mirror introduces gaps.
|
||||
var mirroredStart = mirrored.StartPoint();
|
||||
var mirroredEnd = mirrored.EndPoint();
|
||||
var startDist = mirroredStart.DistanceTo(target.FirstPoint);
|
||||
var endDist = mirroredEnd.DistanceTo(target.LastPoint);
|
||||
|
||||
if (startDist <= Tolerance && endDist <= Tolerance)
|
||||
{
|
||||
target.FittedArc = mirrored;
|
||||
target.MaxDeviation = source.MaxDeviation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Arc MirrorArc(Arc arc, MirrorAxisResult axis)
|
||||
{
|
||||
var mirrorCenter = axis.Reflect(arc.Center);
|
||||
|
||||
// Reflect start and end points, then compute new angles
|
||||
var sp = arc.StartPoint();
|
||||
var ep = arc.EndPoint();
|
||||
var mirrorSp = axis.Reflect(sp);
|
||||
var mirrorEp = axis.Reflect(ep);
|
||||
|
||||
// Mirroring reverses winding — swap start/end to preserve arc direction
|
||||
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
|
||||
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X);
|
||||
|
||||
// Normalize to [0, 2pi)
|
||||
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
|
||||
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
|
||||
|
||||
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
||||
result.Layer = arc.Layer;
|
||||
result.Color = arc.Color;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
|
||||
{
|
||||
var j = runStart;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (j <= runEnd - MinLines + 1)
|
||||
{
|
||||
var result = TryFitArcAt(entities, j, runEnd, chainedTangent);
|
||||
if (result == null)
|
||||
{
|
||||
j++;
|
||||
chainedTangent = Vector.Invalid;
|
||||
continue;
|
||||
}
|
||||
|
||||
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
||||
candidates.Add(new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = result.Points[0],
|
||||
LastPoint = result.Points[^1],
|
||||
});
|
||||
|
||||
j = result.EndIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
|
||||
|
||||
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
|
||||
{
|
||||
var k = start + MinLines - 1;
|
||||
if (k > runEnd) return null;
|
||||
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var (center, radius, dev) = TryFit(points, startTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
points = extPoints;
|
||||
}
|
||||
|
||||
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
|
||||
// that happen to fit a huge circle. Applied after extension so that many small
|
||||
// segments can accumulate enough sweep to qualify.
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, points));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
|
||||
{
|
||||
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given direction at the
|
||||
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
|
||||
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
|
||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var bx = -dy / chordLen;
|
||||
var by = dx / chordLen;
|
||||
|
||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
if (tLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var nx = -tangent.Y / tLen;
|
||||
var ny = tangent.X / tLen;
|
||||
|
||||
var det = nx * by - ny * bx;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||
|
||||
var cx = p1.X + t * nx;
|
||||
var cy = p1.Y + t * ny;
|
||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
/// </summary>
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
if (totalAngle >= 0)
|
||||
return new Vector(-ry, rx);
|
||||
else
|
||||
return new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc using the mirror axis approach. The center is constrained
|
||||
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
|
||||
/// passes exactly through both endpoints. Golden section search optimizes position.
|
||||
/// </summary>
|
||||
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var nx = -dy / chordLen;
|
||||
var ny = dx / chordLen;
|
||||
|
||||
var maxSagitta = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
|
||||
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
|
||||
maxSagitta = proj;
|
||||
}
|
||||
if (System.Math.Abs(maxSagitta) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
|
||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||
|
||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||
|
||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
||||
{
|
||||
var phi = (System.Math.Sqrt(5) - 1) / 2;
|
||||
for (var iter = 0; iter < 30; iter++)
|
||||
{
|
||||
var d1 = high - phi * (high - low);
|
||||
var d2 = low + phi * (high - low);
|
||||
if (eval(d1) < eval(d2))
|
||||
high = d2;
|
||||
else
|
||||
low = d1;
|
||||
if (high - low < 1e-6)
|
||||
break;
|
||||
}
|
||||
return (low + high) / 2;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
|
||||
for (var i = start; i <= end; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Line line:
|
||||
if (i == start)
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
if (i == start)
|
||||
points.Add(arc.StartPoint());
|
||||
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
|
||||
var arcPoints = arc.ToPoints(segments);
|
||||
for (var j = 1; j < arcPoints.Count; j++)
|
||||
points.Add(arcPoints[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
// Normalize to [0, 2pi)
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
arc.Layer = sourceEntity.Layer;
|
||||
arc.Color = sourceEntity.Color;
|
||||
return arc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Max deviation of intermediate points (excluding endpoints) from a circle.
|
||||
/// </summary>
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var px = points[i].X - cx;
|
||||
var py = points[i].Y - cy;
|
||||
var dist = System.Math.Sqrt(px * px + py * py);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the maximum distance from sampled points along the fitted arc
|
||||
/// back to the original line segments. This catches cases where points lie
|
||||
/// on a large circle but the arc bulges far from the original straight geometry.
|
||||
/// </summary>
|
||||
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
|
||||
{
|
||||
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
|
||||
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
|
||||
|
||||
var sweep = endAngle - startAngle;
|
||||
if (isReversed)
|
||||
{
|
||||
if (sweep > 0) sweep -= Angle.TwoPI;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sweep < 0) sweep += Angle.TwoPI;
|
||||
}
|
||||
|
||||
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
|
||||
sampleCount = System.Math.Min(sampleCount, 100);
|
||||
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < sampleCount; i++)
|
||||
{
|
||||
var t = (double)i / sampleCount;
|
||||
var angle = startAngle + sweep * t;
|
||||
var px = center.X + radius * System.Math.Cos(angle);
|
||||
var py = center.Y + radius * System.Math.Sin(angle);
|
||||
var arcPt = new Vector(px, py);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
for (var j = 0; j < points.Count - 1; j++)
|
||||
{
|
||||
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
if (minDist > maxDev) maxDev = minDist;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static double DistanceToSegment(Vector p, Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
var lenSq = dx * dx + dy * dy;
|
||||
if (lenSq < 1e-20)
|
||||
return System.Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
|
||||
|
||||
var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq;
|
||||
t = System.Math.Max(0, System.Math.Min(1, t));
|
||||
var projX = a.X + t * dx;
|
||||
var projY = a.Y + t * dy;
|
||||
return System.Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class SplineConverter
|
||||
{
|
||||
private const int MinPointsForArc = 3;
|
||||
|
||||
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
|
||||
{
|
||||
if (points == null || points.Count < 2)
|
||||
return new List<Entity>();
|
||||
|
||||
var entities = new List<Entity>();
|
||||
var i = 0;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (i < points.Count - 1)
|
||||
{
|
||||
var result = TryFitArc(points, i, chainedTangent, tolerance);
|
||||
if (result != null)
|
||||
{
|
||||
entities.Add(result.Arc);
|
||||
chainedTangent = result.EndTangent;
|
||||
i = result.EndIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
entities.Add(new Line(points[i], points[i + 1]));
|
||||
chainedTangent = Vector.Invalid;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static ArcFitResult TryFitArc(List<Vector> points, int start,
|
||||
Vector chainedTangent, double tolerance)
|
||||
{
|
||||
var minEnd = start + MinPointsForArc - 1;
|
||||
if (minEnd >= points.Count)
|
||||
return null;
|
||||
|
||||
var hasTangent = chainedTangent.IsValid();
|
||||
|
||||
var subPoints = points.GetRange(start, MinPointsForArc);
|
||||
var (center, radius, dev) = hasTangent
|
||||
? FitWithStartTangent(subPoints, chainedTangent)
|
||||
: FitCircumscribed(subPoints);
|
||||
|
||||
if (!center.IsValid() || dev > tolerance)
|
||||
return null;
|
||||
|
||||
var endIdx = minEnd;
|
||||
while (endIdx + 1 < points.Count)
|
||||
{
|
||||
var extPoints = points.GetRange(start, endIdx + 1 - start + 1);
|
||||
var (nc, nr, nd) = hasTangent
|
||||
? FitWithStartTangent(extPoints, chainedTangent)
|
||||
: FitCircumscribed(extPoints);
|
||||
|
||||
if (!nc.IsValid() || nd > tolerance)
|
||||
break;
|
||||
|
||||
endIdx++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
}
|
||||
|
||||
var finalPoints = points.GetRange(start, endIdx - start + 1);
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
var arc = CreateArc(center, radius, finalPoints);
|
||||
var endTangent = ComputeEndTangent(center, finalPoints);
|
||||
|
||||
return new ArcFitResult(arc, endTangent, endIdx);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitCircumscribed(
|
||||
List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p0 = points[0];
|
||||
var pMid = points[points.Count / 2];
|
||||
var pEnd = points[^1];
|
||||
|
||||
// Find circumcenter by intersecting perpendicular bisectors of two chords
|
||||
var (center, radius) = Circumcenter(p0, pMid, pEnd);
|
||||
if (!center.IsValid())
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static (Vector center, double radius) Circumcenter(Vector a, Vector b, Vector c)
|
||||
{
|
||||
// Perpendicular bisector of chord a-b
|
||||
var m1x = (a.X + b.X) / 2;
|
||||
var m1y = (a.Y + b.Y) / 2;
|
||||
var d1x = -(b.Y - a.Y);
|
||||
var d1y = b.X - a.X;
|
||||
|
||||
// Perpendicular bisector of chord b-c
|
||||
var m2x = (b.X + c.X) / 2;
|
||||
var m2y = (b.Y + c.Y) / 2;
|
||||
var d2x = -(c.Y - b.Y);
|
||||
var d2y = c.X - b.X;
|
||||
|
||||
var det = d1x * d2y - d1y * d2x;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
var t = ((m2x - m1x) * d2y - (m2y - m1y) * d2x) / det;
|
||||
|
||||
var cx = m1x + t * d1x;
|
||||
var cy = m1y + t * d1y;
|
||||
var radius = System.Math.Sqrt((cx - a.X) * (cx - a.X) + (cy - a.Y) * (cy - a.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
return (new Vector(cx, cy), radius);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var bx = -dy / chordLen;
|
||||
var by = dx / chordLen;
|
||||
|
||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
if (tLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var nx = -tangent.Y / tLen;
|
||||
var ny = tangent.X / tLen;
|
||||
|
||||
var det = nx * by - ny * bx;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||
|
||||
var cx = p1.X + s * nx;
|
||||
var cy = p1.Y + s * ny;
|
||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var px = points[i].X - cx;
|
||||
var py = points[i].Y - cy;
|
||||
var dist = System.Math.Sqrt(px * px + py * py);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
return totalAngle >= 0
|
||||
? new Vector(-ry, rx)
|
||||
: new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private sealed class ArcFitResult
|
||||
{
|
||||
public Arc Arc { get; }
|
||||
public Vector EndTangent { get; }
|
||||
public int EndIndex { get; }
|
||||
|
||||
public ArcFitResult(Arc arc, Vector endTangent, int endIndex)
|
||||
{
|
||||
Arc = arc;
|
||||
EndTangent = endTangent;
|
||||
EndIndex = endIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
<RootNamespace>OpenNest</RootNamespace>
|
||||
<AssemblyName>OpenNest.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
|
||||
+48
-1
@@ -1,6 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -173,7 +174,53 @@ namespace OpenNest
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
if (!perimeter1.Intersects(perimeter2, out var rawPts))
|
||||
return false;
|
||||
|
||||
// Exclude intersection points that coincide with vertices of BOTH
|
||||
// perimeters — these are touch points (shared corners/endpoints),
|
||||
// not actual crossings where one shape enters the other's interior.
|
||||
var verts1 = CollectVertices(perimeter1);
|
||||
var verts2 = CollectVertices(perimeter2);
|
||||
|
||||
foreach (var pt in rawPts)
|
||||
{
|
||||
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
|
||||
continue;
|
||||
pts.Add(pt);
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectVertices(Geometry.Shape shape)
|
||||
{
|
||||
var verts = new List<Vector>();
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Geometry.Line line:
|
||||
verts.Add(line.StartPoint);
|
||||
verts.Add(line.EndPoint);
|
||||
break;
|
||||
case Geometry.Arc arc:
|
||||
verts.Add(arc.StartPoint());
|
||||
verts.Add(arc.EndPoint());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return verts;
|
||||
}
|
||||
|
||||
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
|
||||
{
|
||||
foreach (var v in vertices)
|
||||
{
|
||||
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public double Left
|
||||
|
||||
@@ -601,10 +601,24 @@ namespace OpenNest
|
||||
for (var i = 0; i < realParts.Count; i++)
|
||||
{
|
||||
var part1 = realParts[i];
|
||||
var b1 = part1.BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < realParts.Count; j++)
|
||||
{
|
||||
var part2 = realParts[j];
|
||||
var b2 = part2.BoundingBox;
|
||||
|
||||
// Skip pairs whose bounding boxes don't meaningfully overlap.
|
||||
// Floating-point rounding can produce sub-epsilon overlaps for
|
||||
// parts that are merely edge-touching, so require the overlap
|
||||
// region to exceed Epsilon in both dimensions.
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
|
||||
@@ -47,13 +47,21 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
// The iterative pair adjustment can shift parts enough to cause
|
||||
// genuine overlap. Fall back to the unadjusted column when this happens.
|
||||
if (HasOverlappingParts(adjusted))
|
||||
{
|
||||
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
||||
Description = $"Extents: column {adjusted.Count} parts",
|
||||
});
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
@@ -386,5 +394,31 @@ namespace OpenNest.Engine.Fill
|
||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
||||
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,15 @@ public class StripeFiller
|
||||
if (gridParts.Count == 0)
|
||||
return null;
|
||||
|
||||
// Reject results where bounding boxes overlap — the angle convergence
|
||||
// can produce slightly off-axis rotations where FillLinear's copy
|
||||
// distance calculation doesn't fully account for the rotated geometry.
|
||||
if (HasOverlappingParts(gridParts))
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
|
||||
return null;
|
||||
}
|
||||
|
||||
var allParts = new List<Part>(gridParts);
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||
@@ -470,4 +479,34 @@ public class StripeFiller
|
||||
{
|
||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any pair of parts geometrically overlap. Uses bounding box
|
||||
/// pre-filtering for performance, then falls back to shape intersection.
|
||||
/// </summary>
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ namespace OpenNest.Engine.Strategies
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= FilterStrategies();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all registered strategies regardless of enabled/disabled state.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<IFillStrategy> AllStrategies =>
|
||||
strategies.OrderBy(s => s.Order).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all permanently disabled strategies.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DisabledNames => disabled;
|
||||
|
||||
private static List<IFillStrategy> FilterStrategies()
|
||||
{
|
||||
var source = enabledFilter != null
|
||||
|
||||
@@ -63,9 +63,72 @@ namespace OpenNest.IO.Bending
|
||||
bends.Add(bend);
|
||||
}
|
||||
|
||||
PropagateCollinearBendNotes(bends);
|
||||
|
||||
return bends;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For bends without a note (e.g. split by a cutout), copy angle/radius/direction
|
||||
/// from a collinear bend that does have a note.
|
||||
/// </summary>
|
||||
private static void PropagateCollinearBendNotes(List<Bend> bends)
|
||||
{
|
||||
const double angleTolerance = 0.01; // radians
|
||||
const double distanceTolerance = 0.01;
|
||||
|
||||
foreach (var bend in bends)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
continue;
|
||||
|
||||
foreach (var other in bends)
|
||||
{
|
||||
if (string.IsNullOrEmpty(other.NoteText))
|
||||
continue;
|
||||
|
||||
if (!AreCollinear(bend, other, angleTolerance, distanceTolerance))
|
||||
continue;
|
||||
|
||||
bend.Direction = other.Direction;
|
||||
bend.Angle = other.Angle;
|
||||
bend.Radius = other.Radius;
|
||||
bend.NoteText = other.NoteText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreCollinear(Bend a, Bend b, double angleTolerance, double distanceTolerance)
|
||||
{
|
||||
var angleA = a.StartPoint.AngleTo(a.EndPoint);
|
||||
var angleB = b.StartPoint.AngleTo(b.EndPoint);
|
||||
|
||||
// Normalize angle difference to [0, PI) since opposite directions are still collinear
|
||||
var diff = System.Math.Abs(angleA - angleB) % System.Math.PI;
|
||||
if (diff > angleTolerance && System.Math.PI - diff > angleTolerance)
|
||||
return false;
|
||||
|
||||
// Perpendicular distance from midpoint of A to the infinite line through B
|
||||
var midA = new Vector(
|
||||
(a.StartPoint.X + a.EndPoint.X) / 2.0,
|
||||
(a.StartPoint.Y + a.EndPoint.Y) / 2.0);
|
||||
|
||||
var dx = b.EndPoint.X - b.StartPoint.X;
|
||||
var dy = b.EndPoint.Y - b.StartPoint.Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (len < 1e-9)
|
||||
return false;
|
||||
|
||||
// 2D cross product gives signed perpendicular distance * length
|
||||
var vx = midA.X - b.StartPoint.X;
|
||||
var vy = midA.Y - b.StartPoint.Y;
|
||||
var perp = System.Math.Abs(vx * dy - vy * dx) / len;
|
||||
|
||||
return perp <= distanceTolerance;
|
||||
}
|
||||
|
||||
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
|
||||
{
|
||||
return document.Entities
|
||||
|
||||
@@ -24,9 +24,10 @@ namespace OpenNest.IO
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
// Skip bend line entities — they are converted to Bend objects
|
||||
// separately via bend detection, not cut geometry.
|
||||
if (IsBendLayer(entity.Layer?.Name))
|
||||
// Skip bend/etch entities — bends are converted to Bend objects
|
||||
// separately via bend detection, and etch marks are generated from
|
||||
// bends during DXF export. Neither should be treated as cut geometry.
|
||||
if (IsNonCutLayer(entity.Layer?.Name))
|
||||
continue;
|
||||
|
||||
switch (entity)
|
||||
@@ -44,7 +45,11 @@ namespace OpenNest.IO
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Spline spline:
|
||||
lines.AddRange(spline.ToOpenNest());
|
||||
foreach (var e in spline.ToOpenNest(SplinePrecision))
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.LwPolyline lwPolyline:
|
||||
@@ -56,7 +61,11 @@ namespace OpenNest.IO
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Ellipse ellipse:
|
||||
lines.AddRange(ellipse.ToOpenNest(SplinePrecision));
|
||||
foreach (var e in ellipse.ToOpenNest())
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -137,9 +146,10 @@ namespace OpenNest.IO
|
||||
return success;
|
||||
}
|
||||
|
||||
private static bool IsBendLayer(string layerName)
|
||||
private static bool IsNonCutLayer(string layerName)
|
||||
{
|
||||
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-75
@@ -1,6 +1,7 @@
|
||||
using ACadSharp.Entities;
|
||||
using CSMath;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -56,42 +57,46 @@ namespace OpenNest.IO
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this Spline spline)
|
||||
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
|
||||
{
|
||||
var lines = new List<Geometry.Line>();
|
||||
var pts = spline.ControlPoints;
|
||||
|
||||
if (pts.Count == 0)
|
||||
return lines;
|
||||
|
||||
var layer = spline.Layer.ToOpenNest();
|
||||
var color = spline.ResolveColor();
|
||||
var lineTypeName = spline.ResolveLineTypeName();
|
||||
var lastPoint = pts[0].ToOpenNest();
|
||||
|
||||
for (var i = 1; i < pts.Count; i++)
|
||||
// Evaluate actual points on the spline curve (not control points)
|
||||
List<XYZ> curvePoints;
|
||||
try
|
||||
{
|
||||
var nextPoint = pts[i].ToOpenNest();
|
||||
|
||||
lines.Add(new Geometry.Line(lastPoint, nextPoint)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
|
||||
lastPoint = nextPoint;
|
||||
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Spline curve evaluation failed: {ex.Message}");
|
||||
curvePoints = null;
|
||||
}
|
||||
|
||||
if (spline.IsClosed)
|
||||
lines.Add(new Geometry.Line(lastPoint, pts[0].ToOpenNest())
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
if (curvePoints == null || curvePoints.Count < 2)
|
||||
{
|
||||
// Fallback: use control points if evaluation fails
|
||||
curvePoints = new List<XYZ>(spline.ControlPoints);
|
||||
if (curvePoints.Count < 2)
|
||||
return new List<Geometry.Entity>();
|
||||
}
|
||||
|
||||
return lines;
|
||||
var points = new List<Vector>(curvePoints.Count);
|
||||
foreach (var pt in curvePoints)
|
||||
points.Add(pt.ToOpenNest());
|
||||
|
||||
var entities = SplineConverter.Convert(points, spline.IsClosed, tolerance: 0.001);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
entity.Layer = layer;
|
||||
entity.Color = color;
|
||||
entity.LineTypeName = lineTypeName;
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this Polyline polyline)
|
||||
@@ -172,69 +177,32 @@ namespace OpenNest.IO
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, int precision = 200)
|
||||
public static List<Geometry.Entity> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, double tolerance = 0.001)
|
||||
{
|
||||
var lines = new List<Geometry.Line>();
|
||||
|
||||
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
||||
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
||||
var majorLength = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||
var minorLength = majorLength * ellipse.RadiusRatio;
|
||||
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
||||
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
||||
|
||||
var startParam = ellipse.StartParameter;
|
||||
var endParam = ellipse.EndParameter;
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += System.Math.PI * 2.0;
|
||||
|
||||
var step = (endParam - startParam) / precision;
|
||||
|
||||
var points = new List<Vector>();
|
||||
|
||||
for (var i = 0; i <= precision; i++)
|
||||
{
|
||||
var t = startParam + step * i;
|
||||
var x = majorLength * System.Math.Cos(t);
|
||||
var y = minorLength * System.Math.Sin(t);
|
||||
|
||||
// Rotate by the major axis angle and translate to center
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
var px = center.X + x * cos - y * sin;
|
||||
var py = center.Y + x * sin + y * cos;
|
||||
|
||||
points.Add(new Vector(px, py));
|
||||
}
|
||||
|
||||
var layer = ellipse.Layer.ToOpenNest();
|
||||
var color = ellipse.ResolveColor();
|
||||
var lineTypeName = ellipse.ResolveLineTypeName();
|
||||
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
var entities = EllipseConverter.Convert(center, semiMajor, semiMinor, rotation,
|
||||
startParam, endParam, tolerance);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
lines.Add(new Geometry.Line(points[i], points[i + 1])
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
entity.Layer = layer;
|
||||
entity.Color = color;
|
||||
entity.LineTypeName = lineTypeName;
|
||||
}
|
||||
|
||||
// Close the ellipse if it's a full ellipse
|
||||
if (lines.Count >= 2)
|
||||
{
|
||||
var first = lines.First();
|
||||
var last = lines.Last();
|
||||
lines.Add(new Geometry.Line(last.EndPoint, first.StartPoint)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static Geometry.Layer ToOpenNest(this ACadSharp.Tables.Layer layer)
|
||||
|
||||
@@ -29,12 +29,12 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
public void Write(TextWriter w, Program normalizedProgram, string drawingName,
|
||||
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
|
||||
{
|
||||
var allFeatures = SplitFeatures(normalizedProgram.Codes);
|
||||
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
|
||||
if (allFeatures.Count == 0)
|
||||
return;
|
||||
|
||||
// Classify and order: etch features first, then cut features
|
||||
var ordered = OrderFeatures(allFeatures);
|
||||
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
|
||||
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($":{subNumber}");
|
||||
@@ -46,7 +46,7 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
var featureNumber = i == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
@@ -70,81 +70,43 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
w.WriteLine($"M99 (END OF {drawingName})");
|
||||
}
|
||||
|
||||
internal static List<(List<ICode> codes, bool isEtch)> OrderFeatures(List<List<ICode>> features)
|
||||
{
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
var etch = new List<List<ICode>>();
|
||||
var cut = new List<List<ICode>>();
|
||||
|
||||
foreach (var f in features)
|
||||
{
|
||||
if (CincinnatiSheetWriter.IsFeatureEtch(f))
|
||||
etch.Add(f);
|
||||
else
|
||||
cut.Add(f);
|
||||
}
|
||||
|
||||
foreach (var f in etch)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cut)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-program key for matching parts to their sub-programs.
|
||||
/// </summary>
|
||||
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
|
||||
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
|
||||
|
||||
internal static List<List<ICode>> SplitFeatures(List<ICode> codes)
|
||||
/// <summary>
|
||||
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
|
||||
/// along with their normalized programs for writing.
|
||||
/// </summary>
|
||||
internal static (Dictionary<(int, long), int> mapping, List<(int subNum, string name, Program program)> entries)
|
||||
BuildRegistry(IEnumerable<Plate> plates, int startNumber)
|
||||
{
|
||||
var features = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
var mapping = new Dictionary<(int, long), int>();
|
||||
var entries = new List<(int, string, Program)>();
|
||||
var nextSubNum = startNumber;
|
||||
|
||||
foreach (var code in codes)
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (current != null)
|
||||
features.Add(current);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
if (part.BaseDrawing.IsCutOff) continue;
|
||||
var key = SubprogramKey(part);
|
||||
if (!mapping.ContainsKey(key))
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
mapping[key] = subNum;
|
||||
|
||||
var pgm = part.Program.Clone() as Program;
|
||||
var bbox = pgm.BoundingBox();
|
||||
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
|
||||
|
||||
entries.Add((subNum, part.BaseDrawing.Name, pgm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
features.Add(current);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
internal static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
currentPos = rapid.EndPoint;
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += currentPos.DistanceTo(arc.EndPoint);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
return (mapping, entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,32 +84,7 @@ namespace OpenNest.Posts.Cincinnati
|
||||
List<(int subNum, string name, Program program)> subprogramEntries = null;
|
||||
|
||||
if (Config.UsePartSubprograms)
|
||||
{
|
||||
partSubprograms = new Dictionary<(int, long), int>();
|
||||
subprogramEntries = new List<(int, string, Program)>();
|
||||
var nextSubNum = Config.PartSubprogramStart;
|
||||
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff) continue;
|
||||
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
|
||||
if (!partSubprograms.ContainsKey(key))
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
partSubprograms[key] = subNum;
|
||||
|
||||
// Create normalized program at origin
|
||||
var pgm = part.Program.Clone() as Program;
|
||||
var bbox = pgm.BoundingBox();
|
||||
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
|
||||
|
||||
subprogramEntries.Add((subNum, part.BaseDrawing.Name, pgm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
|
||||
|
||||
// 5. Create writers
|
||||
var preamble = new CincinnatiPreambleWriter(Config);
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
@@ -128,7 +127,7 @@ public sealed class CincinnatiSheetWriter
|
||||
else
|
||||
{
|
||||
// Inline features for cutoffs or parts without sub-programs
|
||||
var features = SplitAndOrderFeatures(part);
|
||||
var features = FeatureUtils.SplitAndClassify(part);
|
||||
for (var f = 0; f < features.Count; f++)
|
||||
{
|
||||
var (codes, isEtch) = features[f];
|
||||
@@ -137,7 +136,7 @@ public sealed class CincinnatiSheetWriter
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
@@ -205,7 +204,7 @@ public sealed class CincinnatiSheetWriter
|
||||
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
|
||||
foreach (var part in allParts)
|
||||
{
|
||||
var partFeatures = SplitAndOrderFeatures(part);
|
||||
var partFeatures = FeatureUtils.SplitAndClassify(part);
|
||||
foreach (var (codes, isEtch) in partFeatures)
|
||||
features.Add((part, codes, isEtch));
|
||||
}
|
||||
@@ -224,7 +223,7 @@ public sealed class CincinnatiSheetWriter
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
@@ -246,91 +245,4 @@ public sealed class CincinnatiSheetWriter
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a part's program into features (by rapids), classifies each as etch or cut,
|
||||
/// and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> SplitAndOrderFeatures(Part part)
|
||||
{
|
||||
var etchFeatures = new List<List<ICode>>();
|
||||
var cutFeatures = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
{
|
||||
if (current != null)
|
||||
ClassifyAndAdd(current, etchFeatures, cutFeatures);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
ClassifyAndAdd(current, etchFeatures, cutFeatures);
|
||||
|
||||
// Etch features first, then cut features
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
foreach (var f in etchFeatures)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cutFeatures)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ClassifyAndAdd(List<ICode> codes,
|
||||
List<List<ICode>> etchFeatures, List<List<ICode>> cutFeatures)
|
||||
{
|
||||
if (IsFeatureEtch(codes))
|
||||
etchFeatures.Add(codes);
|
||||
else
|
||||
cutFeatures.Add(codes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A feature is etch if any non-rapid move has LayerType.Scribe.
|
||||
/// </summary>
|
||||
public static bool IsFeatureEtch(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
{
|
||||
currentPos = rapid.EndPoint;
|
||||
}
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += currentPos.DistanceTo(arc.EndPoint);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Shared utilities for splitting CNC programs into features and classifying them.
|
||||
/// </summary>
|
||||
public static class FeatureUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits a flat list of codes into feature groups, breaking on rapid moves.
|
||||
/// Each feature starts with a rapid move followed by cutting/etching moves.
|
||||
/// </summary>
|
||||
public static List<List<ICode>> SplitByRapids(List<ICode> codes)
|
||||
{
|
||||
var features = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
{
|
||||
if (current != null)
|
||||
features.Add(current);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
features.Add(current);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies features as etch or cut and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> ClassifyAndOrder(List<List<ICode>> features)
|
||||
{
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
var etch = new List<List<ICode>>();
|
||||
var cut = new List<List<ICode>>();
|
||||
|
||||
foreach (var f in features)
|
||||
{
|
||||
if (IsEtch(f))
|
||||
etch.Add(f);
|
||||
else
|
||||
cut.Add(f);
|
||||
}
|
||||
|
||||
foreach (var f in etch)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cut)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a part's program into features by rapids, classifies each as etch or cut,
|
||||
/// and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part) =>
|
||||
ClassifyAndOrder(SplitByRapids(part.Program.Codes));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any non-rapid move in the feature has LayerType.Scribe.
|
||||
/// </summary>
|
||||
public static bool IsEtch(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the total cut distance of a feature by summing segment lengths.
|
||||
/// </summary>
|
||||
public static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
currentPos = rapid.EndPoint;
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += currentPos.DistanceTo(arc.EndPoint);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,76 @@ public class SolidWorksBendDetectorTests
|
||||
Assert.Empty(bends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EllipseConverter_ProducesArcsDirectly()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11 Test.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
|
||||
var result = importer.Import(path);
|
||||
|
||||
// EllipseConverter now produces arcs directly during import,
|
||||
// so the imported entities should contain Arc instances from the ellipses
|
||||
var arcCount = result.Entities.Count(e => e is OpenNest.Geometry.Arc);
|
||||
Assert.True(arcCount > 0, "Expected arcs from ellipse conversion");
|
||||
|
||||
// The GeometrySimplifier should find few or no line runs to simplify,
|
||||
// because ellipses are already converted to arcs at import time
|
||||
var shape = new OpenNest.Geometry.Shape();
|
||||
shape.Entities.AddRange(result.Entities);
|
||||
|
||||
var simplifier = new OpenNest.Geometry.GeometrySimplifier();
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.True(candidates.Count <= 10,
|
||||
$"Expected <=10 simplifier candidates but got {candidates.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_TrimmedEllipse_NoClosingChord()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter();
|
||||
var result = importer.Import(path);
|
||||
|
||||
// The DXF has 2 trimmed ellipses forming an oblong slot.
|
||||
// Trimmed ellipses must not generate a closing chord line.
|
||||
// EllipseConverter now produces arcs instead of line segments,
|
||||
// changing the entity count. Verify arcs are present and no
|
||||
// spurious closing chord exists.
|
||||
var arcCount = result.Entities.Count(e => e is OpenNest.Geometry.Arc);
|
||||
var circleCount = result.Entities.Count(e => e is OpenNest.Geometry.Circle);
|
||||
|
||||
Assert.True(arcCount > 0, "Expected arcs from ellipse conversion");
|
||||
Assert.True(circleCount >= 7, $"Expected at least 7 circles, got {circleCount}");
|
||||
Assert.Equal(115, result.Entities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectBends_SplitBendLine_PropagatesNote()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT23.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
|
||||
var detector = new SolidWorksBendDetector();
|
||||
var bends = detector.DetectBends(doc);
|
||||
|
||||
Assert.Equal(5, bends.Count);
|
||||
Assert.All(bends, b =>
|
||||
{
|
||||
Assert.NotNull(b.NoteText);
|
||||
Assert.Equal(BendDirection.Up, b.Direction);
|
||||
Assert.Equal(90.0, b.Angle);
|
||||
Assert.Equal(0.125, b.Radius);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectBends_RealDxf_ParsesNotesCorrectly()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -152,7 +152,7 @@ public class CincinnatiSheetWriterTests
|
||||
new LinearMove(1, 1) { Layer = LayerType.Scribe }
|
||||
};
|
||||
|
||||
Assert.True(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
Assert.True(FeatureUtils.IsEtch(codes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,7 +165,7 @@ public class CincinnatiSheetWriterTests
|
||||
new LinearMove(1, 1) { Layer = LayerType.Cut }
|
||||
};
|
||||
|
||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
Assert.False(FeatureUtils.IsEtch(codes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -176,7 +176,7 @@ public class CincinnatiSheetWriterTests
|
||||
new RapidMove(0, 0)
|
||||
};
|
||||
|
||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
Assert.False(FeatureUtils.IsEtch(codes));
|
||||
}
|
||||
|
||||
private static Program CreateSimpleProgram()
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class EllipseConverterTests
|
||||
{
|
||||
private const double Tol = 1e-10;
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
|
||||
{
|
||||
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), 0);
|
||||
Assert.InRange(p.X, 10 - Tol, 10 + Tol);
|
||||
Assert.InRange(p.Y, -Tol, Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePoint_AtHalfPi_ReturnsMinorAxisEnd()
|
||||
{
|
||||
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), System.Math.PI / 2);
|
||||
Assert.InRange(p.X, -Tol, Tol);
|
||||
Assert.InRange(p.Y, 5 - Tol, 5 + Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePoint_WithRotation_RotatesCorrectly()
|
||||
{
|
||||
var p = EllipseConverter.EvaluatePoint(10, 5, System.Math.PI / 2, new Vector(0, 0), 0);
|
||||
Assert.InRange(p.X, -Tol, Tol);
|
||||
Assert.InRange(p.Y, 10 - Tol, 10 + Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatePoint_WithCenter_TranslatesCorrectly()
|
||||
{
|
||||
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(100, 200), 0);
|
||||
Assert.InRange(p.X, 110 - Tol, 110 + Tol);
|
||||
Assert.InRange(p.Y, 200 - Tol, 200 + Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateTangent_AtZero_PointsUp()
|
||||
{
|
||||
var t = EllipseConverter.EvaluateTangent(10, 5, 0, 0);
|
||||
Assert.InRange(t.X, -Tol, Tol);
|
||||
Assert.True(t.Y > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateNormal_AtZero_PointsInward()
|
||||
{
|
||||
var n = EllipseConverter.EvaluateNormal(10, 5, 0, 0);
|
||||
Assert.True(n.X < 0);
|
||||
Assert.InRange(n.Y, -Tol, Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntersectNormals_PerpendicularNormals_FindsCenter()
|
||||
{
|
||||
var p1 = new Vector(5, 0);
|
||||
var n1 = new Vector(-1, 0);
|
||||
var p2 = new Vector(0, 5);
|
||||
var n2 = new Vector(0, -1);
|
||||
var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2);
|
||||
Assert.InRange(center.X, -Tol, Tol);
|
||||
Assert.InRange(center.Y, -Tol, Tol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntersectNormals_ParallelNormals_ReturnsInvalid()
|
||||
{
|
||||
var p1 = new Vector(0, 0);
|
||||
var n1 = new Vector(1, 0);
|
||||
var p2 = new Vector(0, 5);
|
||||
var n2 = new Vector(1, 0);
|
||||
var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2);
|
||||
Assert.False(center.IsValid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_Circle_ProducesOneOrTwoArcs()
|
||||
{
|
||||
var result = EllipseConverter.Convert(
|
||||
new Vector(0, 0), semiMajor: 10, semiMinor: 10, rotation: 0,
|
||||
startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001);
|
||||
|
||||
Assert.All(result, e => Assert.IsType<Arc>(e));
|
||||
Assert.InRange(result.Count, 1, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_ModerateEllipse_AllArcsWithinTolerance()
|
||||
{
|
||||
var a = 10.0;
|
||||
var b = 7.0;
|
||||
var tolerance = 0.001;
|
||||
var result = EllipseConverter.Convert(
|
||||
new Vector(0, 0), a, b, rotation: 0,
|
||||
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
|
||||
|
||||
Assert.True(result.Count >= 4, $"Expected at least 4 arcs, got {result.Count}");
|
||||
Assert.All(result, e => Assert.IsType<Arc>(e));
|
||||
|
||||
foreach (var entity in result)
|
||||
{
|
||||
var arc = (Arc)entity;
|
||||
var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50);
|
||||
Assert.True(maxDev <= tolerance,
|
||||
$"Arc at center ({arc.Center.X:F4},{arc.Center.Y:F4}) r={arc.Radius:F4} " +
|
||||
$"deviates {maxDev:F6} from ellipse (tolerance={tolerance})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_HighlyEccentricEllipse_ProducesMoreArcs()
|
||||
{
|
||||
var a = 20.0;
|
||||
var b = 3.0;
|
||||
var tolerance = 0.001;
|
||||
var result = EllipseConverter.Convert(
|
||||
new Vector(0, 0), a, b, rotation: 0,
|
||||
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
|
||||
|
||||
Assert.True(result.Count >= 8, $"Expected at least 8 arcs for eccentric ellipse, got {result.Count}");
|
||||
Assert.All(result, e => Assert.IsType<Arc>(e));
|
||||
|
||||
foreach (var entity in result)
|
||||
{
|
||||
var arc = (Arc)entity;
|
||||
var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50);
|
||||
Assert.True(maxDev <= tolerance,
|
||||
$"Deviation {maxDev:F6} exceeds tolerance {tolerance}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_PartialEllipse_CoversArcOnly()
|
||||
{
|
||||
var a = 10.0;
|
||||
var b = 5.0;
|
||||
var tolerance = 0.001;
|
||||
var result = EllipseConverter.Convert(
|
||||
new Vector(0, 0), a, b, rotation: 0,
|
||||
startParam: 0, endParam: System.Math.PI / 2, tolerance: tolerance);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.All(result, e => Assert.IsType<Arc>(e));
|
||||
|
||||
var firstArc = (Arc)result[0];
|
||||
var sp = firstArc.StartPoint();
|
||||
Assert.InRange(sp.X, a - 0.01, a + 0.01);
|
||||
Assert.InRange(sp.Y, -0.01, 0.01);
|
||||
|
||||
var lastArc = (Arc)result[^1];
|
||||
var ep = lastArc.EndPoint();
|
||||
Assert.InRange(ep.X, -0.01, 0.01);
|
||||
Assert.InRange(ep.Y, b - 0.01, b + 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_EndpointContinuity_ArcsConnect()
|
||||
{
|
||||
var result = EllipseConverter.Convert(
|
||||
new Vector(5, 10), semiMajor: 15, semiMinor: 8, rotation: 0.5,
|
||||
startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001);
|
||||
|
||||
for (var i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
var current = (Arc)result[i];
|
||||
var next = (Arc)result[i + 1];
|
||||
var gap = current.EndPoint().DistanceTo(next.StartPoint());
|
||||
Assert.True(gap < 0.001,
|
||||
$"Gap of {gap:F6} between arc {i} and arc {i + 1}");
|
||||
}
|
||||
|
||||
var lastArc = (Arc)result[^1];
|
||||
var firstArc = (Arc)result[0];
|
||||
var closingGap = lastArc.EndPoint().DistanceTo(firstArc.StartPoint());
|
||||
Assert.True(closingGap < 0.001,
|
||||
$"Closing gap of {closingGap:F6}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_WithRotationAndOffset_ProducesValidArcs()
|
||||
{
|
||||
var center = new Vector(50, -30);
|
||||
var rotation = System.Math.PI / 3;
|
||||
var a = 12.0;
|
||||
var b = 6.0;
|
||||
var tolerance = 0.001;
|
||||
|
||||
var result = EllipseConverter.Convert(center, a, b, rotation,
|
||||
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
foreach (var entity in result)
|
||||
{
|
||||
var arc = (Arc)entity;
|
||||
var maxDev = MaxDeviationFromEllipse(arc, center, a, b, rotation, 50);
|
||||
Assert.True(maxDev <= tolerance,
|
||||
$"Deviation {maxDev:F6} exceeds tolerance {tolerance}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DxfImporter_EllipseInDxf_ProducesArcsNotLines()
|
||||
{
|
||||
// Create a DXF in memory with an ellipse and verify import produces arcs
|
||||
var doc = new ACadSharp.CadDocument();
|
||||
var ellipse = new ACadSharp.Entities.Ellipse
|
||||
{
|
||||
Center = new CSMath.XYZ(0, 0, 0),
|
||||
MajorAxisEndPoint = new CSMath.XYZ(10, 0, 0),
|
||||
RadiusRatio = 0.6,
|
||||
StartParameter = 0,
|
||||
EndParameter = System.Math.PI * 2
|
||||
};
|
||||
doc.Entities.Add(ellipse);
|
||||
|
||||
// Write to temp file and re-import
|
||||
var tempPath = System.IO.Path.GetTempFileName() + ".dxf";
|
||||
try
|
||||
{
|
||||
using (var stream = System.IO.File.Create(tempPath))
|
||||
using (var writer = new ACadSharp.IO.DxfWriter(stream, doc, false))
|
||||
writer.Write();
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
|
||||
var result = importer.Import(tempPath);
|
||||
|
||||
var arcCount = result.Entities.Count(e => e is Arc);
|
||||
var lineCount = result.Entities.Count(e => e is Line);
|
||||
|
||||
// Should have arcs, not hundreds of lines
|
||||
Assert.True(arcCount >= 4, $"Expected at least 4 arcs, got {arcCount}");
|
||||
Assert.Equal(0, lineCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
|
||||
double semiMajor, double semiMinor, double rotation, int samples)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
var sweep = arc.SweepAngle();
|
||||
var startAngle = arc.StartAngle;
|
||||
if (arc.IsReversed)
|
||||
startAngle = arc.EndAngle;
|
||||
|
||||
for (var i = 0; i <= samples; i++)
|
||||
{
|
||||
var frac = (double)i / samples;
|
||||
var angle = startAngle + frac * sweep;
|
||||
var px = arc.Center.X + arc.Radius * System.Math.Cos(angle);
|
||||
var py = arc.Center.Y + arc.Radius * System.Math.Sin(angle);
|
||||
var arcPoint = new Vector(px, py);
|
||||
|
||||
// Coarse search over 1000 samples
|
||||
var bestT = 0.0;
|
||||
var minDist = double.MaxValue;
|
||||
for (var j = 0; j <= 1000; j++)
|
||||
{
|
||||
var t = (double)j / 1000 * Angle.TwoPI;
|
||||
var ep2 = EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t);
|
||||
var dist = arcPoint.DistanceTo(ep2);
|
||||
if (dist < minDist) { minDist = dist; bestT = t; }
|
||||
}
|
||||
|
||||
// Refine with local bisection around bestT
|
||||
var lo = bestT - Angle.TwoPI / 1000;
|
||||
var hi = bestT + Angle.TwoPI / 1000;
|
||||
for (var r = 0; r < 20; r++)
|
||||
{
|
||||
var t1 = lo + (hi - lo) / 3;
|
||||
var t2 = lo + 2 * (hi - lo) / 3;
|
||||
var d1 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1));
|
||||
var d2 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t2));
|
||||
if (d1 < d2) hi = t2; else lo = t1;
|
||||
}
|
||||
var bestDist = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (lo + hi) / 2));
|
||||
if (bestDist > maxDev) maxDev = bestDist;
|
||||
}
|
||||
|
||||
return maxDev;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class EngineOverlapTests
|
||||
{
|
||||
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public EngineOverlapTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static Drawing ImportDxf()
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
importer.GetGeometry(DxfPath, out var geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT15", pgm);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Default")]
|
||||
[InlineData("Strip")]
|
||||
[InlineData("Vertical Remnant")]
|
||||
[InlineData("Horizontal Remnant")]
|
||||
public void FillPlate_NoOverlaps(string engineName)
|
||||
{
|
||||
var drawing = ImportDxf();
|
||||
var plate = new Plate(60, 120);
|
||||
|
||||
NestEngineRegistry.ActiveEngineName = engineName;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var success = engine.Fill(item);
|
||||
|
||||
_output.WriteLine($"Engine: {engine.Name}, Parts: {plate.Parts.Count}, Utilization: {plate.Utilization():P1}");
|
||||
|
||||
if (engine is DefaultNestEngine defaultEngine)
|
||||
{
|
||||
_output.WriteLine($"Winner phase: {defaultEngine.WinnerPhase}");
|
||||
foreach (var pr in defaultEngine.PhaseResults)
|
||||
_output.WriteLine($" Phase {pr.Phase}: {pr.PartCount} parts in {pr.TimeMs}ms");
|
||||
}
|
||||
|
||||
// Show rotation distribution
|
||||
var rotGroups = plate.Parts
|
||||
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
|
||||
.OrderBy(g => g.Key);
|
||||
foreach (var g in rotGroups)
|
||||
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var collisionPoints);
|
||||
_output.WriteLine($"Overlaps: {hasOverlaps} ({collisionPoints.Count} collision pts)");
|
||||
|
||||
if (hasOverlaps)
|
||||
{
|
||||
for (var i = 0; i < System.Math.Min(collisionPoints.Count, 10); i++)
|
||||
_output.WriteLine($" ({collisionPoints[i].X:F2}, {collisionPoints[i].Y:F2})");
|
||||
}
|
||||
|
||||
Assert.False(hasOverlaps,
|
||||
$"Engine '{engineName}' produced {collisionPoints.Count} collision point(s) with {plate.Parts.Count} parts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentParts_ShouldNotOverlap()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(10, 0, 10));
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var pts);
|
||||
_output.WriteLine($"Adjacent squares: overlaps={hasOverlaps}, collision count={pts.Count}");
|
||||
|
||||
Assert.False(hasOverlaps, "Adjacent edge-touching parts should not be reported as overlapping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class GeometrySimplifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_LinesFromSemicircle_FindsOneCandidate()
|
||||
{
|
||||
// Create 20 lines approximating a semicircle of radius 10
|
||||
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
|
||||
var points = arc.ToPoints(20);
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(points[i], points[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Single(candidates);
|
||||
Assert.Equal(0, candidates[0].StartIndex);
|
||||
Assert.Equal(19, candidates[0].EndIndex);
|
||||
Assert.Equal(20, candidates[0].LineCount);
|
||||
Assert.InRange(candidates[0].FittedArc.Radius, 9.5, 10.5);
|
||||
Assert.True(candidates[0].MaxDeviation <= 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_TooFewLines_ReturnsNoCandidates()
|
||||
{
|
||||
// Only 2 consecutive lines — below MinLines threshold
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1)));
|
||||
shape.Entities.Add(new Line(new Vector(1, 1), new Vector(2, 0)));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_MixedEntitiesWithArc_FindsSeparateCandidates()
|
||||
{
|
||||
// Lines on one curve, then an arc at a different center, then lines on another curve
|
||||
// The arc is included in the run but can't merge with lines on different curves
|
||||
var shape = new Shape();
|
||||
// First run: 5 lines on a curve
|
||||
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
|
||||
var pts1 = arc1.ToPoints(5);
|
||||
for (var i = 0; i < pts1.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
|
||||
|
||||
// An existing arc entity (breaks the run)
|
||||
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
|
||||
|
||||
// Second run: 4 lines on a different curve
|
||||
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
|
||||
var pts2 = arc2.ToPoints(4);
|
||||
for (var i = 0; i < pts2.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
// First candidate covers indices 0-4 (5 lines)
|
||||
Assert.Equal(0, candidates[0].StartIndex);
|
||||
Assert.Equal(4, candidates[0].EndIndex);
|
||||
// Second candidate covers indices 6-9 (4 lines, after the arc at index 5)
|
||||
Assert.Equal(6, candidates[1].StartIndex);
|
||||
Assert.Equal(9, candidates[1].EndIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_SingleCandidate_ReplacesLinesWithArc()
|
||||
{
|
||||
// 20 lines approximating a semicircle
|
||||
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
|
||||
var points = arc.ToPoints(20);
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(points[i], points[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
var result = simplifier.Apply(shape, candidates);
|
||||
|
||||
Assert.Single(result.Entities);
|
||||
Assert.IsType<Arc>(result.Entities[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_OnlySelectedCandidates_LeavesUnselectedAsLines()
|
||||
{
|
||||
// Two runs of lines with an arc between them
|
||||
var shape = new Shape();
|
||||
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
|
||||
var pts1 = arc1.ToPoints(5);
|
||||
for (var i = 0; i < pts1.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
|
||||
|
||||
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
|
||||
|
||||
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
|
||||
var pts2 = arc2.ToPoints(4);
|
||||
for (var i = 0; i < pts2.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
// Deselect the first candidate
|
||||
candidates[0].IsSelected = false;
|
||||
|
||||
var result = simplifier.Apply(shape, candidates);
|
||||
|
||||
// First run (5 lines) stays as lines + middle arc + second run replaced by arc
|
||||
// 5 original lines + 1 original arc + 1 fitted arc = 7 entities
|
||||
Assert.Equal(7, result.Entities.Count);
|
||||
// First 5 should be lines
|
||||
for (var i = 0; i < 5; i++)
|
||||
Assert.IsType<Line>(result.Entities[i]);
|
||||
// Index 5 is the original arc
|
||||
Assert.IsType<Arc>(result.Entities[5]);
|
||||
// Index 6 is the fitted arc replacing the second run
|
||||
Assert.IsType<Arc>(result.Entities[6]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class SplineConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_SemicirclePoints_ProducesSingleArc()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
for (var i = 0; i <= 50; i++)
|
||||
{
|
||||
var t = System.Math.PI * i / 50;
|
||||
points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
|
||||
}
|
||||
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.IsType<Arc>(result[0]);
|
||||
var arc = (Arc)result[0];
|
||||
Assert.InRange(arc.Radius, 9.99, 10.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_StraightLinePoints_ProducesSingleLine()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
for (var i = 0; i <= 10; i++)
|
||||
points.Add(new Vector(i, 2 * i + 1));
|
||||
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
|
||||
Assert.All(result, e => Assert.IsType<Line>(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_SCurve_ProducesMultipleArcs()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
for (var i = 0; i <= 30; i++)
|
||||
{
|
||||
var t = System.Math.PI * i / 30;
|
||||
points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
|
||||
}
|
||||
for (var i = 1; i <= 30; i++)
|
||||
{
|
||||
var t = -System.Math.PI * i / 30;
|
||||
points.Add(new Vector(-20 + 10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
|
||||
}
|
||||
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
|
||||
var arcCount = result.Count(e => e is Arc);
|
||||
Assert.True(arcCount >= 2, $"Expected at least 2 arcs, got {arcCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_TwoPoints_ProducesSingleLine()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>
|
||||
{
|
||||
new Vector(0, 0),
|
||||
new Vector(10, 5)
|
||||
};
|
||||
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.IsType<Line>(result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_EndpointContinuity_EntitiesConnect()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector>();
|
||||
for (var i = 0; i <= 80; i++)
|
||||
{
|
||||
var t = Angle.TwoPI * i / 80;
|
||||
points.Add(new Vector(15 * System.Math.Cos(t), 8 * System.Math.Sin(t)));
|
||||
}
|
||||
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
|
||||
for (var i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
var endPt = GetEndPoint(result[i]);
|
||||
var startPt = GetStartPoint(result[i + 1]);
|
||||
var gap = endPt.DistanceTo(startPt);
|
||||
Assert.True(gap < 0.001,
|
||||
$"Gap of {gap:F6} between entity {i} and {i + 1}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_EmptyPoints_ReturnsEmpty()
|
||||
{
|
||||
var result = SplineConverter.Convert(new System.Collections.Generic.List<Vector>(),
|
||||
isClosed: false, tolerance: 0.001);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_SinglePoint_ReturnsEmpty()
|
||||
{
|
||||
var points = new System.Collections.Generic.List<Vector> { new Vector(5, 5) };
|
||||
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
private static Vector GetStartPoint(Entity e)
|
||||
{
|
||||
return e switch
|
||||
{
|
||||
Arc a => a.StartPoint(),
|
||||
Line l => l.StartPoint,
|
||||
_ => throw new System.Exception("Unexpected entity type")
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector GetEndPoint(Entity e)
|
||||
{
|
||||
return e switch
|
||||
{
|
||||
Arc a => a.EndPoint(),
|
||||
Line l => l.EndPoint,
|
||||
_ => throw new System.Exception("Unexpected entity type")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using ACadSharp.IO;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Splitting;
|
||||
|
||||
public class SplitDxfWriterEtchLayerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Write_DrawingWithUpBend_EtchLinesHaveEtchLayer()
|
||||
{
|
||||
// Create a simple rectangular drawing with an up bend
|
||||
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
|
||||
drawing.Bends = new List<Bend>
|
||||
{
|
||||
new Bend
|
||||
{
|
||||
StartPoint = new Vector(0, 25),
|
||||
EndPoint = new Vector(100, 25),
|
||||
Direction = BendDirection.Up,
|
||||
Angle = 90,
|
||||
Radius = 0.06,
|
||||
NoteText = "UP 90° R0.06"
|
||||
}
|
||||
};
|
||||
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"etch_layer_test_{Guid.NewGuid()}.dxf");
|
||||
try
|
||||
{
|
||||
var writer = new SplitDxfWriter();
|
||||
writer.Write(tempPath, drawing);
|
||||
|
||||
// Re-read the DXF and check entity layers
|
||||
using var reader = new DxfReader(tempPath);
|
||||
var doc = reader.Read();
|
||||
|
||||
var etchEntities = new List<ACadSharp.Entities.Entity>();
|
||||
var allEntities = new List<(string LayerName, string Type)>();
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
var layerName = entity.Layer?.Name ?? "(null)";
|
||||
allEntities.Add((layerName, entity.GetType().Name));
|
||||
|
||||
// Etch lines are short lines along the bend direction at the ends
|
||||
if (entity is ACadSharp.Entities.Line line)
|
||||
{
|
||||
// Check if this line is an etch mark (short, near the bend Y=25)
|
||||
var midY = (line.StartPoint.Y + line.EndPoint.Y) / 2;
|
||||
var length = System.Math.Sqrt(
|
||||
System.Math.Pow(line.EndPoint.X - line.StartPoint.X, 2) +
|
||||
System.Math.Pow(line.EndPoint.Y - line.StartPoint.Y, 2));
|
||||
|
||||
if (System.Math.Abs(midY - 25) < 0.1 && length <= 1.5 && layerName != "BEND")
|
||||
{
|
||||
etchEntities.Add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have etch lines (up bend with length 100 > 3*EtchLength, so 2 etch dashes)
|
||||
Assert.True(etchEntities.Count >= 2,
|
||||
$"Expected at least 2 etch lines, found {etchEntities.Count}. " +
|
||||
$"All entities: {string.Join(", ", allEntities.Select(e => $"{e.Type}@{e.LayerName}"))}");
|
||||
|
||||
// ALL etch lines should be on the ETCH layer, not layer 0
|
||||
foreach (var etch in etchEntities)
|
||||
{
|
||||
var layerName = etch.Layer?.Name ?? "(null)";
|
||||
Assert.Equal("ETCH", layerName);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_SplitDrawingWithUpBend_EtchLinesHaveEtchLayer()
|
||||
{
|
||||
// Create a drawing, split it, then verify etch layers in the split DXFs
|
||||
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
|
||||
drawing.Bends = new List<Bend>
|
||||
{
|
||||
new Bend
|
||||
{
|
||||
StartPoint = new Vector(0, 25),
|
||||
EndPoint = new Vector(100, 25),
|
||||
Direction = BendDirection.Up,
|
||||
Angle = 90,
|
||||
Radius = 0.06,
|
||||
NoteText = "UP 90° R0.06"
|
||||
}
|
||||
};
|
||||
|
||||
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
|
||||
var parameters = new SplitParameters { Type = SplitType.Straight };
|
||||
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
|
||||
foreach (var splitDrawing in results)
|
||||
{
|
||||
// Each split piece should have the bend (clipped to region)
|
||||
Assert.NotNull(splitDrawing.Bends);
|
||||
Assert.True(splitDrawing.Bends.Count > 0, $"{splitDrawing.Name} should have bends");
|
||||
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"split_etch_test_{splitDrawing.Name}_{Guid.NewGuid()}.dxf");
|
||||
try
|
||||
{
|
||||
var writer = new SplitDxfWriter();
|
||||
writer.Write(tempPath, splitDrawing);
|
||||
|
||||
// Re-read and verify
|
||||
using var reader = new DxfReader(tempPath);
|
||||
var doc = reader.Read();
|
||||
|
||||
var entitySummary = new List<string>();
|
||||
var etchLayerEntities = new List<ACadSharp.Entities.Entity>();
|
||||
var layer0Entities = new List<ACadSharp.Entities.Entity>();
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
var layerName = entity.Layer?.Name ?? "(null)";
|
||||
entitySummary.Add($"{entity.GetType().Name}@{layerName}");
|
||||
|
||||
if (string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase))
|
||||
etchLayerEntities.Add(entity);
|
||||
else if (string.Equals(layerName, "0", StringComparison.OrdinalIgnoreCase))
|
||||
layer0Entities.Add(entity);
|
||||
}
|
||||
|
||||
// Should have etch entities
|
||||
Assert.True(etchLayerEntities.Count > 0,
|
||||
$"{splitDrawing.Name}: No entities on ETCH layer. " +
|
||||
$"All: {string.Join(", ", entitySummary)}");
|
||||
|
||||
// No entities should be on layer 0
|
||||
Assert.True(layer0Entities.Count == 0,
|
||||
$"{splitDrawing.Name}: {layer0Entities.Count} entities on layer 0 " +
|
||||
$"(expected all on CUT/BEND/ETCH). " +
|
||||
$"All: {string.Join(", ", entitySummary)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_ReImport_EtchEntitiesFilteredFromCutGeometry()
|
||||
{
|
||||
// After re-import, ETCH entities should be filtered (like BEND) since
|
||||
// etch marks are generated from bends, not treated as cut geometry.
|
||||
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
|
||||
drawing.Bends = new List<Bend>
|
||||
{
|
||||
new Bend
|
||||
{
|
||||
StartPoint = new Vector(0, 25),
|
||||
EndPoint = new Vector(100, 25),
|
||||
Direction = BendDirection.Up,
|
||||
Angle = 90,
|
||||
Radius = 0.06,
|
||||
NoteText = "UP 90° R0.06"
|
||||
}
|
||||
};
|
||||
|
||||
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
|
||||
var parameters = new SplitParameters { Type = SplitType.Straight };
|
||||
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
|
||||
|
||||
foreach (var splitDrawing in results)
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"reimport_etch_test_{splitDrawing.Name}_{Guid.NewGuid()}.dxf");
|
||||
try
|
||||
{
|
||||
var writer = new SplitDxfWriter();
|
||||
writer.Write(tempPath, splitDrawing);
|
||||
|
||||
// Re-import via DxfImporter (same path as CadConverterForm)
|
||||
var importer = new DxfImporter();
|
||||
var result = importer.Import(tempPath);
|
||||
|
||||
// ETCH entities should be filtered during import (like BEND)
|
||||
var etchEntities = result.Entities
|
||||
.Where(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var layer0Entities = result.Entities
|
||||
.Where(e => string.Equals(e.Layer?.Name, "0", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
Assert.True(etchEntities.Count == 0,
|
||||
$"{splitDrawing.Name}: ETCH entities should be filtered during import, found {etchEntities.Count}");
|
||||
|
||||
Assert.True(layer0Entities.Count == 0,
|
||||
$"{splitDrawing.Name}: {layer0Entities.Count} entities on layer 0 after re-import");
|
||||
|
||||
// All imported entities should be on CUT layer (cut geometry only)
|
||||
Assert.True(result.Entities.Count > 0, $"{splitDrawing.Name}: Should have cut geometry");
|
||||
Assert.True(result.Entities.All(e => string.Equals(e.Layer?.Name, "CUT", StringComparison.OrdinalIgnoreCase)),
|
||||
$"{splitDrawing.Name}: All imported entities should be on CUT layer. " +
|
||||
$"Found: {string.Join(", ", result.Entities.Select(e => e.Layer?.Name ?? "(null)").Distinct())}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ public class StripeFillerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ProducesPartsForSimpleDrawing()
|
||||
public void Fill_ProducesNonOverlappingPartsForSimpleDrawing()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
@@ -158,11 +158,19 @@ public class StripeFillerTests
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
|
||||
// StripeFiller may return empty if the converged angle produces
|
||||
// overlapping parts that fail the overlap validation check.
|
||||
// The important thing is that any returned parts are overlap-free.
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(parts);
|
||||
var hasOverlaps = plate.HasOverlappingParts(out _);
|
||||
Assert.False(hasOverlaps, "Stripe fill should not produce overlapping parts");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_VerticalProducesParts()
|
||||
public void Fill_VerticalProducesNonOverlappingParts()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
@@ -186,7 +194,12 @@ public class StripeFillerTests
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from column fill");
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(parts);
|
||||
var hasOverlaps = plate.HasOverlappingParts(out _);
|
||||
Assert.False(hasOverlaps, "Column fill should not produce overlapping parts");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class StrategyOverlapTests
|
||||
{
|
||||
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public StrategyOverlapTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static Drawing ImportDxf()
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
importer.GetGeometry(DxfPath, out var geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT15", pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachStrategy_CheckOverlaps()
|
||||
{
|
||||
var drawing = ImportDxf();
|
||||
_output.WriteLine($"Drawing bbox: {drawing.Program.BoundingBox().Width:F2} x {drawing.Program.BoundingBox().Length:F2}");
|
||||
|
||||
var strategies = FillStrategyRegistry.Strategies.ToList();
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var comparer = new DefaultFillComparer();
|
||||
var policy = new FillPolicy(comparer);
|
||||
var context = new FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = plate.WorkArea(),
|
||||
Plate = plate,
|
||||
PlateNumber = 0,
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Policy = policy,
|
||||
};
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
||||
item, bestRotation, context.WorkArea);
|
||||
|
||||
var parts = strategy.Fill(context);
|
||||
var count = parts?.Count ?? 0;
|
||||
|
||||
_output.WriteLine($"\n{strategy.GetType().Name} (Phase: {strategy.Phase}, Order: {strategy.Order}): {count} parts");
|
||||
|
||||
if (count == 0)
|
||||
continue;
|
||||
|
||||
plate.Parts.AddRange(parts);
|
||||
_output.WriteLine($" Utilization: {plate.Utilization():P1}");
|
||||
|
||||
var rotGroups = parts
|
||||
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
|
||||
.OrderBy(g => g.Key);
|
||||
foreach (var g in rotGroups)
|
||||
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var pts);
|
||||
_output.WriteLine($" Overlaps: {hasOverlaps} ({pts.Count} collision pts)");
|
||||
|
||||
if (hasOverlaps)
|
||||
{
|
||||
failures.Add($"{strategy.GetType().Name} ({strategy.Phase}): {pts.Count} collision pts, {count} parts");
|
||||
|
||||
// Show overlapping pair details
|
||||
for (var a = 0; a < parts.Count; a++)
|
||||
{
|
||||
for (var b = a + 1; b < parts.Count; b++)
|
||||
{
|
||||
var ba = parts[a].BoundingBox;
|
||||
var bb = parts[b].BoundingBox;
|
||||
var oX = System.Math.Min(ba.Right, bb.Right) - System.Math.Max(ba.Left, bb.Left);
|
||||
var oY = System.Math.Min(ba.Top, bb.Top) - System.Math.Max(ba.Bottom, bb.Bottom);
|
||||
if (oX <= OpenNest.Math.Tolerance.Epsilon || oY <= OpenNest.Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[a].Intersects(parts[b], out var pairPts) && pairPts.Count > 0)
|
||||
{
|
||||
_output.WriteLine($" [{a}] vs [{b}]: {pairPts.Count} pts, bbox overlap: {oX:F4} x {oY:F4}");
|
||||
_output.WriteLine($" [{a}]: loc=({parts[a].Location.X:F4},{parts[a].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[a].Rotation):F2}°");
|
||||
_output.WriteLine($" [{b}]: loc=({parts[b].Location.X:F4},{parts[b].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[b].Rotation):F2}°");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"\n=== SUMMARY ===");
|
||||
foreach (var f in failures)
|
||||
_output.WriteLine($" OVERLAP: {f}");
|
||||
|
||||
Assert.Empty(failures);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
@@ -15,6 +16,19 @@ namespace OpenNest.Controls
|
||||
public List<Bend> Bends = new List<Bend>();
|
||||
public int SelectedBendIndex = -1;
|
||||
|
||||
private HashSet<Entity> simplifierHighlightSet;
|
||||
|
||||
public List<Entity> SimplifierHighlight
|
||||
{
|
||||
get => simplifierHighlightSet?.ToList();
|
||||
set => simplifierHighlightSet = value != null ? new HashSet<Entity>(value) : null;
|
||||
}
|
||||
|
||||
public Arc SimplifierPreview { get; set; }
|
||||
public List<Entity> SimplifierToleranceLeft { get; set; }
|
||||
public List<Entity> SimplifierToleranceRight { get; set; }
|
||||
public List<Entity> OriginalEntities { get; set; }
|
||||
|
||||
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||
|
||||
@@ -68,10 +82,24 @@ namespace OpenNest.Controls
|
||||
|
||||
e.Graphics.TranslateTransform(origin.X, origin.Y);
|
||||
|
||||
// Draw original geometry overlay (faded, behind current)
|
||||
if (OriginalEntities != null)
|
||||
{
|
||||
using var origPen = new Pen(Color.FromArgb(50, 255, 140, 40));
|
||||
foreach (var entity in OriginalEntities)
|
||||
{
|
||||
if (!IsEtchLayer(entity.Layer))
|
||||
DrawEntity(e.Graphics, entity, origPen);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in Entities)
|
||||
{
|
||||
if (IsEtchLayer(entity.Layer)) continue;
|
||||
var pen = GetEntityPen(entity.Color);
|
||||
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
|
||||
var pen = isHighlighted
|
||||
? GetEntityPen(Color.FromArgb(60, entity.Color))
|
||||
: GetEntityPen(entity.Color);
|
||||
DrawEntity(e.Graphics, entity, pen);
|
||||
}
|
||||
|
||||
@@ -84,6 +112,33 @@ namespace OpenNest.Controls
|
||||
DrawEntity(e.Graphics, entity, pen);
|
||||
}
|
||||
|
||||
DrawEtchMarks(e.Graphics);
|
||||
|
||||
if (SimplifierPreview != null)
|
||||
{
|
||||
// Draw tolerance zone (offset lines each side of original geometry)
|
||||
if (SimplifierToleranceLeft != null)
|
||||
{
|
||||
using var zonePen = new Pen(Color.FromArgb(40, 100, 200, 100));
|
||||
foreach (var entity in SimplifierToleranceLeft)
|
||||
DrawEntity(e.Graphics, entity, zonePen);
|
||||
foreach (var entity in SimplifierToleranceRight)
|
||||
DrawEntity(e.Graphics, entity, zonePen);
|
||||
}
|
||||
|
||||
// Draw old geometry (highlighted lines) in orange dashed
|
||||
if (simplifierHighlightSet != null)
|
||||
{
|
||||
using var oldPen = new Pen(Color.FromArgb(180, 255, 160, 50), 1f / ViewScale) { DashPattern = new float[] { 6, 3 } };
|
||||
foreach (var entity in simplifierHighlightSet)
|
||||
DrawEntity(e.Graphics, entity, oldPen);
|
||||
}
|
||||
|
||||
// Draw the new arc in bright green
|
||||
using var previewPen = new Pen(Color.FromArgb(0, 200, 80), 2f / ViewScale);
|
||||
DrawArc(e.Graphics, SimplifierPreview, previewPen);
|
||||
}
|
||||
|
||||
#if DRAW_OFFSET
|
||||
|
||||
var offsetShape = new Shape();
|
||||
@@ -185,6 +240,46 @@ namespace OpenNest.Controls
|
||||
private static bool IsEtchLayer(Layer layer) =>
|
||||
string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private void DrawEtchMarks(Graphics g)
|
||||
{
|
||||
if (Bends == null || Bends.Count == 0)
|
||||
return;
|
||||
|
||||
using var etchPen = new Pen(Color.Green, 1.5f);
|
||||
var etchLength = 1.0;
|
||||
|
||||
foreach (var bend in Bends)
|
||||
{
|
||||
if (bend.Direction != BendDirection.Up)
|
||||
continue;
|
||||
|
||||
var start = bend.StartPoint;
|
||||
var end = bend.EndPoint;
|
||||
var length = bend.Length;
|
||||
|
||||
if (length < etchLength * 3.0)
|
||||
{
|
||||
var pt1 = PointWorldToGraph(start);
|
||||
var pt2 = PointWorldToGraph(end);
|
||||
g.DrawLine(etchPen, pt1, pt2);
|
||||
}
|
||||
else
|
||||
{
|
||||
var angle = start.AngleTo(end);
|
||||
var dx = System.Math.Cos(angle) * etchLength;
|
||||
var dy = System.Math.Sin(angle) * etchLength;
|
||||
|
||||
var s1 = PointWorldToGraph(start);
|
||||
var e1 = PointWorldToGraph(new Vector(start.X + dx, start.Y + dy));
|
||||
g.DrawLine(etchPen, s1, e1);
|
||||
|
||||
var s2 = PointWorldToGraph(end);
|
||||
var e2 = PointWorldToGraph(new Vector(end.X - dx, end.Y - dy));
|
||||
g.DrawLine(etchPen, s2, e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBendLines(Graphics g)
|
||||
{
|
||||
if (Bends == null || Bends.Count == 0)
|
||||
@@ -198,20 +293,26 @@ namespace OpenNest.Controls
|
||||
{
|
||||
DashPattern = new float[] { 6, 4 }
|
||||
};
|
||||
using var noteFont = new Font("Segoe UI", 9f);
|
||||
using var noteBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
||||
using var selectedNoteBrush = new SolidBrush(Color.FromArgb(220, 255, 180, 100));
|
||||
|
||||
for (var i = 0; i < Bends.Count; i++)
|
||||
{
|
||||
var bend = Bends[i];
|
||||
var pt1 = PointWorldToGraph(bend.StartPoint);
|
||||
var pt2 = PointWorldToGraph(bend.EndPoint);
|
||||
var isSelected = i == SelectedBendIndex;
|
||||
|
||||
if (i == SelectedBendIndex)
|
||||
{
|
||||
if (isSelected)
|
||||
g.DrawLine(glowPen, pt1, pt2);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.DrawLine(bendPen, pt1, pt2);
|
||||
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
{
|
||||
var mid = new PointF((pt1.X + pt2.X) / 2f, (pt1.Y + pt2.Y) / 2f);
|
||||
g.DrawString(bend.NoteText, noteFont, isSelected ? selectedNoteBrush : noteBrush, mid.X + 4, mid.Y + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +459,15 @@ namespace OpenNest.Controls
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearSimplifierPreview()
|
||||
{
|
||||
SimplifierHighlight = null;
|
||||
SimplifierPreview = null;
|
||||
SimplifierToleranceLeft = null;
|
||||
SimplifierToleranceRight = null;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void ZoomToFit(bool redraw = true)
|
||||
{
|
||||
ZoomToArea(Entities.GetBoundingBox(), redraw);
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace OpenNest.Controls
|
||||
public int Quantity { get; set; } = 1;
|
||||
public string Path { get; set; }
|
||||
public List<Entity> Entities { get; set; } = new();
|
||||
public List<Entity> OriginalEntities { get; set; }
|
||||
public List<Bend> Bends { get; set; } = new();
|
||||
public Box Bounds { get; set; }
|
||||
public int EntityCount { get; set; }
|
||||
|
||||
+81
-17
@@ -15,6 +15,7 @@ namespace OpenNest.Forms
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
mainSplit = new System.Windows.Forms.SplitContainer();
|
||||
sidebarSplit = new System.Windows.Forms.SplitContainer();
|
||||
fileList = new OpenNest.Controls.FileListControl();
|
||||
filterPanel = new OpenNest.Controls.FilterPanel();
|
||||
@@ -27,11 +28,18 @@ namespace OpenNest.Forms
|
||||
lblDimensions = new System.Windows.Forms.Label();
|
||||
lblEntityCount = new System.Windows.Forms.Label();
|
||||
btnSplit = new System.Windows.Forms.Button();
|
||||
btnSimplify = new System.Windows.Forms.Button();
|
||||
btnExportDxf = new System.Windows.Forms.Button();
|
||||
chkShowOriginal = new System.Windows.Forms.CheckBox();
|
||||
lblDetect = new System.Windows.Forms.Label();
|
||||
cboBendDetector = new System.Windows.Forms.ComboBox();
|
||||
bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
cancelButton = new System.Windows.Forms.Button();
|
||||
acceptButton = new System.Windows.Forms.Button();
|
||||
((System.ComponentModel.ISupportInitialize)mainSplit).BeginInit();
|
||||
mainSplit.Panel1.SuspendLayout();
|
||||
mainSplit.Panel2.SuspendLayout();
|
||||
mainSplit.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)sidebarSplit).BeginInit();
|
||||
sidebarSplit.Panel1.SuspendLayout();
|
||||
sidebarSplit.Panel2.SuspendLayout();
|
||||
@@ -40,25 +48,46 @@ namespace OpenNest.Forms
|
||||
((System.ComponentModel.ISupportInitialize)numQuantity).BeginInit();
|
||||
bottomPanel1.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
//
|
||||
// mainSplit
|
||||
//
|
||||
mainSplit.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
mainSplit.FixedPanel = System.Windows.Forms.FixedPanel.Panel1;
|
||||
mainSplit.Location = new System.Drawing.Point(0, 0);
|
||||
mainSplit.Name = "mainSplit";
|
||||
//
|
||||
// mainSplit.Panel1
|
||||
//
|
||||
mainSplit.Panel1.Controls.Add(sidebarSplit);
|
||||
mainSplit.Panel1MinSize = 200;
|
||||
//
|
||||
// mainSplit.Panel2
|
||||
//
|
||||
mainSplit.Panel2.Controls.Add(entityView1);
|
||||
mainSplit.Panel2.Controls.Add(detailBar);
|
||||
mainSplit.Size = new System.Drawing.Size(1024, 670);
|
||||
mainSplit.SplitterDistance = 260;
|
||||
mainSplit.SplitterWidth = 5;
|
||||
mainSplit.TabIndex = 2;
|
||||
//
|
||||
// sidebarSplit
|
||||
//
|
||||
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Left;
|
||||
//
|
||||
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
sidebarSplit.Location = new System.Drawing.Point(0, 0);
|
||||
sidebarSplit.Name = "sidebarSplit";
|
||||
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
|
||||
//
|
||||
//
|
||||
// sidebarSplit.Panel1
|
||||
//
|
||||
//
|
||||
sidebarSplit.Panel1.Controls.Add(fileList);
|
||||
//
|
||||
//
|
||||
// sidebarSplit.Panel2
|
||||
//
|
||||
//
|
||||
sidebarSplit.Panel2.Controls.Add(filterPanel);
|
||||
sidebarSplit.Size = new System.Drawing.Size(260, 670);
|
||||
sidebarSplit.SplitterDistance = 300;
|
||||
sidebarSplit.SplitterWidth = 5;
|
||||
sidebarSplit.TabIndex = 2;
|
||||
sidebarSplit.TabIndex = 0;
|
||||
//
|
||||
// fileList
|
||||
//
|
||||
@@ -86,9 +115,9 @@ namespace OpenNest.Forms
|
||||
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
||||
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
||||
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
entityView1.Location = new System.Drawing.Point(260, 0);
|
||||
entityView1.Location = new System.Drawing.Point(0, 0);
|
||||
entityView1.Name = "entityView1";
|
||||
entityView1.Size = new System.Drawing.Size(764, 634);
|
||||
entityView1.Size = new System.Drawing.Size(759, 634);
|
||||
entityView1.TabIndex = 0;
|
||||
//
|
||||
// detailBar
|
||||
@@ -101,13 +130,16 @@ namespace OpenNest.Forms
|
||||
detailBar.Controls.Add(lblDimensions);
|
||||
detailBar.Controls.Add(lblEntityCount);
|
||||
detailBar.Controls.Add(btnSplit);
|
||||
detailBar.Controls.Add(btnSimplify);
|
||||
detailBar.Controls.Add(btnExportDxf);
|
||||
detailBar.Controls.Add(chkShowOriginal);
|
||||
detailBar.Controls.Add(lblDetect);
|
||||
detailBar.Controls.Add(cboBendDetector);
|
||||
detailBar.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
detailBar.Location = new System.Drawing.Point(260, 634);
|
||||
detailBar.Location = new System.Drawing.Point(0, 634);
|
||||
detailBar.Name = "detailBar";
|
||||
detailBar.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
|
||||
detailBar.Size = new System.Drawing.Size(764, 36);
|
||||
detailBar.Size = new System.Drawing.Size(759, 36);
|
||||
detailBar.TabIndex = 1;
|
||||
detailBar.WrapContents = false;
|
||||
//
|
||||
@@ -187,9 +219,35 @@ namespace OpenNest.Forms
|
||||
btnSplit.Size = new System.Drawing.Size(60, 24);
|
||||
btnSplit.TabIndex = 6;
|
||||
btnSplit.Text = "Split...";
|
||||
//
|
||||
//
|
||||
// btnSimplify
|
||||
//
|
||||
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
btnSimplify.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
btnSimplify.Text = "Simplify...";
|
||||
btnSimplify.AutoSize = true;
|
||||
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
|
||||
//
|
||||
// btnExportDxf
|
||||
//
|
||||
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
btnExportDxf.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
btnExportDxf.Text = "Export DXF";
|
||||
btnExportDxf.AutoSize = true;
|
||||
btnExportDxf.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
btnExportDxf.Click += new System.EventHandler(this.OnExportDxfClick);
|
||||
//
|
||||
// chkShowOriginal
|
||||
//
|
||||
chkShowOriginal.AutoSize = true;
|
||||
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
chkShowOriginal.Text = "Original";
|
||||
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
||||
chkShowOriginal.CheckedChanged += new System.EventHandler(this.OnShowOriginalChanged);
|
||||
//
|
||||
// lblDetect
|
||||
//
|
||||
//
|
||||
lblDetect.AutoSize = true;
|
||||
lblDetect.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
lblDetect.Location = new System.Drawing.Point(361, 9);
|
||||
@@ -248,9 +306,7 @@ namespace OpenNest.Forms
|
||||
AllowDrop = true;
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
ClientSize = new System.Drawing.Size(1024, 720);
|
||||
Controls.Add(entityView1);
|
||||
Controls.Add(detailBar);
|
||||
Controls.Add(sidebarSplit);
|
||||
Controls.Add(mainSplit);
|
||||
Controls.Add(bottomPanel1);
|
||||
Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
MinimizeBox = false;
|
||||
@@ -260,6 +316,10 @@ namespace OpenNest.Forms
|
||||
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
Text = "CAD Converter";
|
||||
WindowState = System.Windows.Forms.FormWindowState.Maximized;
|
||||
mainSplit.Panel1.ResumeLayout(false);
|
||||
mainSplit.Panel2.ResumeLayout(false);
|
||||
((System.ComponentModel.ISupportInitialize)mainSplit).EndInit();
|
||||
mainSplit.ResumeLayout(false);
|
||||
sidebarSplit.Panel1.ResumeLayout(false);
|
||||
sidebarSplit.Panel2.ResumeLayout(false);
|
||||
((System.ComponentModel.ISupportInitialize)sidebarSplit).EndInit();
|
||||
@@ -273,6 +333,7 @@ namespace OpenNest.Forms
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.SplitContainer mainSplit;
|
||||
private System.Windows.Forms.SplitContainer sidebarSplit;
|
||||
private Controls.FileListControl fileList;
|
||||
private Controls.FilterPanel filterPanel;
|
||||
@@ -283,6 +344,9 @@ namespace OpenNest.Forms
|
||||
private System.Windows.Forms.NumericUpDown numQuantity;
|
||||
private System.Windows.Forms.TextBox txtCustomer;
|
||||
private System.Windows.Forms.Button btnSplit;
|
||||
private System.Windows.Forms.Button btnSimplify;
|
||||
private System.Windows.Forms.Button btnExportDxf;
|
||||
private System.Windows.Forms.CheckBox chkShowOriginal;
|
||||
private System.Windows.Forms.ComboBox cboBendDetector;
|
||||
private System.Windows.Forms.Label lblQty;
|
||||
private System.Windows.Forms.Label lblCust;
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace OpenNest.Forms
|
||||
public partial class CadConverterForm : Form
|
||||
{
|
||||
private static int colorIndex;
|
||||
private SimplifierViewerForm simplifierViewer;
|
||||
|
||||
public CadConverterForm()
|
||||
{
|
||||
@@ -140,6 +141,7 @@ namespace OpenNest.Forms
|
||||
entityView1.IsPickingBendLine = false;
|
||||
filterPanel.SetPickMode(false);
|
||||
}
|
||||
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
|
||||
entityView1.Entities.Clear();
|
||||
entityView1.Entities.AddRange(item.Entities);
|
||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||
@@ -161,6 +163,50 @@ namespace OpenNest.Forms
|
||||
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||
|
||||
entityView1.ZoomToFit();
|
||||
CheckSimplifiable(item);
|
||||
}
|
||||
|
||||
private void CheckSimplifiable(FileListItem item)
|
||||
{
|
||||
ResetSimplifyButton();
|
||||
|
||||
// Only check original (unsimplified) entities
|
||||
var entities = item.OriginalEntities ?? item.Entities;
|
||||
if (entities == null || entities.Count < 10) return;
|
||||
|
||||
// Quick line count check — need at least MinLines consecutive lines
|
||||
var lineCount = entities.Count(e => e is Geometry.Line);
|
||||
if (lineCount < 3) return;
|
||||
|
||||
// Run a quick analysis on a background thread
|
||||
var capturedEntities = new List<Entity>(entities);
|
||||
Task.Run(() =>
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(capturedEntities);
|
||||
var simplifier = new GeometrySimplifier();
|
||||
var count = 0;
|
||||
foreach (var shape in shapes)
|
||||
count += simplifier.Analyze(shape).Count;
|
||||
return count;
|
||||
}).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully && t.Result > 0)
|
||||
HighlightSimplifyButton(t.Result);
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
private void HighlightSimplifyButton(int candidateCount)
|
||||
{
|
||||
btnSimplify.Text = $"Simplify ({candidateCount})";
|
||||
btnSimplify.BackColor = Color.FromArgb(60, 120, 60);
|
||||
btnSimplify.ForeColor = Color.White;
|
||||
}
|
||||
|
||||
private void ResetSimplifyButton()
|
||||
{
|
||||
btnSimplify.Text = "Simplify...";
|
||||
btnSimplify.BackColor = SystemColors.Control;
|
||||
btnSimplify.ForeColor = SystemColors.ControlText;
|
||||
}
|
||||
|
||||
private void ClearDetailBar()
|
||||
@@ -378,6 +424,117 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSimplifyClick(object sender, EventArgs e)
|
||||
{
|
||||
if (entityView1.Entities == null || entityView1.Entities.Count == 0)
|
||||
return;
|
||||
|
||||
// Always simplify from original geometry to prevent tolerance creep
|
||||
var item = CurrentItem;
|
||||
if (item != null && item.OriginalEntities == null)
|
||||
item.OriginalEntities = new List<Entity>(item.Entities);
|
||||
|
||||
var sourceEntities = item?.OriginalEntities ?? entityView1.Entities;
|
||||
var shapes = ShapeBuilder.GetShapes(sourceEntities);
|
||||
if (shapes.Count == 0)
|
||||
return;
|
||||
|
||||
if (simplifierViewer == null || simplifierViewer.IsDisposed)
|
||||
{
|
||||
simplifierViewer = new SimplifierViewerForm();
|
||||
simplifierViewer.Owner = this;
|
||||
simplifierViewer.Applied += OnSimplifierApplied;
|
||||
|
||||
// Position next to this form
|
||||
var screen = Screen.FromControl(this);
|
||||
simplifierViewer.Location = new Point(
|
||||
System.Math.Min(Right, screen.WorkingArea.Right - simplifierViewer.Width),
|
||||
Top);
|
||||
}
|
||||
|
||||
simplifierViewer.LoadShapes(shapes, entityView1);
|
||||
}
|
||||
|
||||
private void OnSimplifierApplied(List<Entity> entities)
|
||||
{
|
||||
entityView1.Entities.Clear();
|
||||
entityView1.Entities.AddRange(entities);
|
||||
entityView1.ZoomToFit();
|
||||
entityView1.Invalidate();
|
||||
|
||||
var item = CurrentItem;
|
||||
if (item != null)
|
||||
{
|
||||
item.Entities = entities;
|
||||
item.EntityCount = entities.Count;
|
||||
item.Bounds = entities.GetBoundingBox();
|
||||
}
|
||||
|
||||
lblEntityCount.Text = $"{entities.Count} entities";
|
||||
ResetSimplifyButton();
|
||||
}
|
||||
|
||||
private void OnShowOriginalChanged(object sender, EventArgs e)
|
||||
{
|
||||
var item = CurrentItem;
|
||||
entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OriginalEntities : null;
|
||||
entityView1.Invalidate();
|
||||
}
|
||||
|
||||
private void OnExportDxfClick(object sender, EventArgs e)
|
||||
{
|
||||
var item = CurrentItem;
|
||||
if (item == null) return;
|
||||
|
||||
using var dlg = new SaveFileDialog
|
||||
{
|
||||
Filter = "DXF Files|*.dxf",
|
||||
FileName = Path.ChangeExtension(item.Name, ".dxf"),
|
||||
};
|
||||
|
||||
if (dlg.ShowDialog() != DialogResult.OK) return;
|
||||
|
||||
var doc = new ACadSharp.CadDocument();
|
||||
foreach (var entity in item.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Geometry.Line line:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new CSMath.XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
|
||||
EndPoint = new CSMath.XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
|
||||
});
|
||||
break;
|
||||
|
||||
case Geometry.Arc arc:
|
||||
var startAngle = arc.StartAngle;
|
||||
var endAngle = arc.EndAngle;
|
||||
if (arc.IsReversed)
|
||||
OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
|
||||
doc.Entities.Add(new ACadSharp.Entities.Arc
|
||||
{
|
||||
Center = new CSMath.XYZ(arc.Center.X, arc.Center.Y, 0),
|
||||
Radius = arc.Radius,
|
||||
StartAngle = startAngle,
|
||||
EndAngle = endAngle,
|
||||
});
|
||||
break;
|
||||
|
||||
case Geometry.Circle circle:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Circle
|
||||
{
|
||||
Center = new CSMath.XYZ(circle.Center.X, circle.Center.Y, 0),
|
||||
Radius = circle.Radius,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
using var writer = new ACadSharp.IO.DxfWriter(dlg.FileName, doc, false);
|
||||
writer.Write();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Output
|
||||
|
||||
@@ -58,6 +58,8 @@ namespace OpenNest.Forms
|
||||
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
OptionsForm.ApplyDisabledStrategies();
|
||||
|
||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||
engineComboBox.Items.Add(engine.Name);
|
||||
|
||||
@@ -79,7 +81,7 @@ namespace OpenNest.Forms
|
||||
private string GetNestName(DateTime date, int id)
|
||||
{
|
||||
var year = (date.Year % 100).ToString("D2");
|
||||
var seq = ToBase36(id).PadLeft(3, '0');
|
||||
var seq = ToBase36(id).PadLeft(3, '2');
|
||||
|
||||
return $"N{year}-{seq}";
|
||||
}
|
||||
@@ -87,13 +89,13 @@ namespace OpenNest.Forms
|
||||
private static string ToBase36(int value)
|
||||
{
|
||||
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
|
||||
if (value == 0) return "0";
|
||||
if (value == 0) return chars[0].ToString();
|
||||
|
||||
var result = "";
|
||||
while (value > 0)
|
||||
{
|
||||
result = chars[value % 36] + result;
|
||||
value /= 36;
|
||||
result = chars[value % chars.Length] + result;
|
||||
value /= chars.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Generated
+14
-2
@@ -42,6 +42,7 @@
|
||||
this.saveButton = new System.Windows.Forms.Button();
|
||||
this.cancelButton = new System.Windows.Forms.Button();
|
||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||
this.tableLayoutPanel1.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown2)).BeginInit();
|
||||
@@ -210,13 +211,23 @@
|
||||
this.bottomPanel1.Name = "bottomPanel1";
|
||||
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
||||
this.bottomPanel1.TabIndex = 1;
|
||||
//
|
||||
//
|
||||
// strategyGroupBox
|
||||
//
|
||||
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
|
||||
this.strategyGroupBox.Name = "strategyGroupBox";
|
||||
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
||||
this.strategyGroupBox.TabIndex = 2;
|
||||
this.strategyGroupBox.TabStop = false;
|
||||
this.strategyGroupBox.Text = "Fill Strategies";
|
||||
//
|
||||
// OptionsForm
|
||||
//
|
||||
//
|
||||
this.AcceptButton = this.saveButton;
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
this.CancelButton = this.cancelButton;
|
||||
this.ClientSize = new System.Drawing.Size(708, 418);
|
||||
this.Controls.Add(this.strategyGroupBox);
|
||||
this.Controls.Add(this.tableLayoutPanel1);
|
||||
this.Controls.Add(this.bottomPanel1);
|
||||
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
||||
@@ -252,5 +263,6 @@
|
||||
private System.Windows.Forms.TextBox textBox1;
|
||||
private System.Windows.Forms.Label label3;
|
||||
private System.Windows.Forms.Button button1;
|
||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,58 @@
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Properties;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
public partial class OptionsForm : Form
|
||||
{
|
||||
private readonly List<CheckBox> _strategyCheckBoxes = new();
|
||||
|
||||
public OptionsForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
BuildStrategyCheckBoxes();
|
||||
}
|
||||
|
||||
protected override void OnLoad(System.EventArgs e)
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void BuildStrategyCheckBoxes()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.AllStrategies;
|
||||
var y = 20;
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
{
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Text = strategy.Name,
|
||||
Tag = strategy.Name,
|
||||
AutoSize = true,
|
||||
Location = new System.Drawing.Point(10, y),
|
||||
};
|
||||
strategyGroupBox.Controls.Add(cb);
|
||||
_strategyCheckBoxes.Add(cb);
|
||||
y += 24;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
textBox1.Text = Settings.Default.NestTemplatePath;
|
||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||
numericUpDown2.Value = (decimal)Settings.Default.ImportSplinePrecision;
|
||||
|
||||
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
||||
foreach (var cb in _strategyCheckBoxes)
|
||||
cb.Checked = !disabledNames.Contains((string)cb.Tag);
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
@@ -30,15 +61,47 @@ namespace OpenNest.Forms
|
||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||
Settings.Default.ImportSplinePrecision = (int)numericUpDown2.Value;
|
||||
|
||||
var disabledNames = _strategyCheckBoxes
|
||||
.Where(cb => !cb.Checked)
|
||||
.Select(cb => (string)cb.Tag);
|
||||
Settings.Default.DisabledStrategies = string.Join(",", disabledNames);
|
||||
|
||||
Settings.Default.Save();
|
||||
ApplyDisabledStrategies();
|
||||
}
|
||||
|
||||
private void SaveSettings_Click(object sender, System.EventArgs e)
|
||||
/// <summary>
|
||||
/// Applies the DisabledStrategies setting to the FillStrategyRegistry.
|
||||
/// Called on save and at startup from MainForm.
|
||||
/// </summary>
|
||||
public static void ApplyDisabledStrategies()
|
||||
{
|
||||
// Re-enable all, then disable the persisted set.
|
||||
var all = FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray();
|
||||
FillStrategyRegistry.Enable(all);
|
||||
|
||||
var disabled = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
||||
if (disabled.Count > 0)
|
||||
FillStrategyRegistry.Disable(disabled.ToArray());
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseDisabledStrategies(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new HashSet<string>(
|
||||
value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SaveSettings_Click(object sender, EventArgs e)
|
||||
{
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void BrowseNestTemplatePath_Click(object sender, System.EventArgs e)
|
||||
private void BrowseNestTemplatePath_Click(object sender, EventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog();
|
||||
dlg.Filter = "Template File|*.nstdot";
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class SimplifierViewerForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.listView = new System.Windows.Forms.ListView();
|
||||
this.columnLines = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnRadius = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnDeviation = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnLocation = new System.Windows.Forms.ColumnHeader();
|
||||
this.bottomPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
this.lblTolerance = new System.Windows.Forms.Label();
|
||||
this.numTolerance = new System.Windows.Forms.NumericUpDown();
|
||||
this.lblCount = new System.Windows.Forms.Label();
|
||||
this.btnApply = new System.Windows.Forms.Button();
|
||||
this.bottomPanel.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).BeginInit();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// listView
|
||||
//
|
||||
this.listView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
|
||||
this.columnLines,
|
||||
this.columnRadius,
|
||||
this.columnDeviation,
|
||||
this.columnLocation});
|
||||
this.listView.CheckBoxes = true;
|
||||
this.listView.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.listView.FullRowSelect = true;
|
||||
this.listView.GridLines = true;
|
||||
this.listView.Location = new System.Drawing.Point(0, 0);
|
||||
this.listView.Name = "listView";
|
||||
this.listView.Size = new System.Drawing.Size(404, 378);
|
||||
this.listView.TabIndex = 0;
|
||||
this.listView.UseCompatibleStateImageBehavior = false;
|
||||
this.listView.View = System.Windows.Forms.View.Details;
|
||||
this.listView.ItemChecked += new System.Windows.Forms.ItemCheckedEventHandler(this.OnItemChecked);
|
||||
this.listView.ItemSelectionChanged += new System.Windows.Forms.ListViewItemSelectionChangedEventHandler(this.OnItemSelected);
|
||||
//
|
||||
// columnLines
|
||||
//
|
||||
this.columnLines.Text = "Lines";
|
||||
this.columnLines.Width = 50;
|
||||
//
|
||||
// columnRadius
|
||||
//
|
||||
this.columnRadius.Text = "Radius";
|
||||
this.columnRadius.Width = 70;
|
||||
//
|
||||
// columnDeviation
|
||||
//
|
||||
this.columnDeviation.Text = "Deviation";
|
||||
this.columnDeviation.Width = 75;
|
||||
//
|
||||
// columnLocation
|
||||
//
|
||||
this.columnLocation.Text = "Location";
|
||||
this.columnLocation.Width = 100;
|
||||
//
|
||||
// bottomPanel
|
||||
//
|
||||
this.bottomPanel.Controls.Add(this.lblTolerance);
|
||||
this.bottomPanel.Controls.Add(this.numTolerance);
|
||||
this.bottomPanel.Controls.Add(this.lblCount);
|
||||
this.bottomPanel.Controls.Add(this.btnApply);
|
||||
this.bottomPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
this.bottomPanel.Location = new System.Drawing.Point(0, 378);
|
||||
this.bottomPanel.Name = "bottomPanel";
|
||||
this.bottomPanel.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
|
||||
this.bottomPanel.Size = new System.Drawing.Size(404, 36);
|
||||
this.bottomPanel.TabIndex = 1;
|
||||
this.bottomPanel.WrapContents = false;
|
||||
//
|
||||
// lblTolerance
|
||||
//
|
||||
this.lblTolerance.AutoSize = true;
|
||||
this.lblTolerance.Location = new System.Drawing.Point(7, 9);
|
||||
this.lblTolerance.Margin = new System.Windows.Forms.Padding(0, 3, 2, 0);
|
||||
this.lblTolerance.Name = "lblTolerance";
|
||||
this.lblTolerance.Size = new System.Drawing.Size(61, 15);
|
||||
this.lblTolerance.TabIndex = 0;
|
||||
this.lblTolerance.Text = "Tolerance:";
|
||||
//
|
||||
// numTolerance
|
||||
//
|
||||
this.numTolerance.DecimalPlaces = 3;
|
||||
this.numTolerance.Increment = new decimal(new int[] {
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.Location = new System.Drawing.Point(73, 6);
|
||||
this.numTolerance.Maximum = new decimal(new int[] {
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0});
|
||||
this.numTolerance.Minimum = new decimal(new int[] {
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.Name = "numTolerance";
|
||||
this.numTolerance.Size = new System.Drawing.Size(70, 23);
|
||||
this.numTolerance.TabIndex = 1;
|
||||
this.numTolerance.Value = new decimal(new int[] {
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.ValueChanged += new System.EventHandler(this.OnToleranceChanged);
|
||||
//
|
||||
// lblCount
|
||||
//
|
||||
this.lblCount.AutoSize = true;
|
||||
this.lblCount.Location = new System.Drawing.Point(155, 9);
|
||||
this.lblCount.Margin = new System.Windows.Forms.Padding(8, 3, 4, 0);
|
||||
this.lblCount.Name = "lblCount";
|
||||
this.lblCount.Size = new System.Drawing.Size(84, 15);
|
||||
this.lblCount.TabIndex = 2;
|
||||
this.lblCount.Text = "0 of 0 selected";
|
||||
//
|
||||
// btnApply
|
||||
//
|
||||
this.btnApply.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnApply.Location = new System.Drawing.Point(247, 6);
|
||||
this.btnApply.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
this.btnApply.Name = "btnApply";
|
||||
this.btnApply.Size = new System.Drawing.Size(60, 25);
|
||||
this.btnApply.TabIndex = 3;
|
||||
this.btnApply.Text = "Apply";
|
||||
this.btnApply.UseVisualStyleBackColor = true;
|
||||
this.btnApply.Click += new System.EventHandler(this.OnApplyClick);
|
||||
//
|
||||
// SimplifierViewerForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(404, 414);
|
||||
this.Controls.Add(this.listView);
|
||||
this.Controls.Add(this.bottomPanel);
|
||||
this.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
|
||||
this.Name = "SimplifierViewerForm";
|
||||
this.ShowInTaskbar = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.Manual;
|
||||
this.Text = "Geometry Simplifier";
|
||||
this.TopMost = true;
|
||||
this.bottomPanel.ResumeLayout(false);
|
||||
this.bottomPanel.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).EndInit();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.ListView listView;
|
||||
private System.Windows.Forms.ColumnHeader columnLines;
|
||||
private System.Windows.Forms.ColumnHeader columnRadius;
|
||||
private System.Windows.Forms.ColumnHeader columnDeviation;
|
||||
private System.Windows.Forms.ColumnHeader columnLocation;
|
||||
private System.Windows.Forms.FlowLayoutPanel bottomPanel;
|
||||
private System.Windows.Forms.Label lblTolerance;
|
||||
private System.Windows.Forms.NumericUpDown numTolerance;
|
||||
private System.Windows.Forms.Label lblCount;
|
||||
private System.Windows.Forms.Button btnApply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
public partial class SimplifierViewerForm : Form
|
||||
{
|
||||
private EntityView entityView;
|
||||
private GeometrySimplifier simplifier;
|
||||
private List<Shape> shapes;
|
||||
private List<ArcCandidate> candidates;
|
||||
|
||||
public event System.Action<List<Entity>> Applied;
|
||||
|
||||
public SimplifierViewerForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public void LoadShapes(List<Shape> shapes, EntityView view, double tolerance = 0.004)
|
||||
{
|
||||
this.shapes = shapes;
|
||||
this.entityView = view;
|
||||
numTolerance.Value = (decimal)tolerance;
|
||||
simplifier = new GeometrySimplifier { Tolerance = tolerance };
|
||||
RunAnalysis();
|
||||
Show();
|
||||
BringToFront();
|
||||
}
|
||||
|
||||
private void RunAnalysis()
|
||||
{
|
||||
candidates = new List<ArcCandidate>();
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
var shapeCandidates = simplifier.Analyze(shapes[i]);
|
||||
foreach (var c in shapeCandidates)
|
||||
c.ShapeIndex = i;
|
||||
|
||||
var axis = GeometrySimplifier.DetectMirrorAxis(shapes[i]);
|
||||
if (axis.IsValid)
|
||||
simplifier.Symmetrize(shapeCandidates, axis);
|
||||
|
||||
candidates.AddRange(shapeCandidates);
|
||||
}
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
listView.BeginUpdate();
|
||||
listView.Items.Clear();
|
||||
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
var item = new ListViewItem(c.LineCount.ToString());
|
||||
item.Checked = c.IsSelected;
|
||||
item.SubItems.Add(c.FittedArc.Radius.ToString("F3"));
|
||||
item.SubItems.Add(c.MaxDeviation.ToString("F4"));
|
||||
item.SubItems.Add($"{c.BoundingBox.Center.X:F1}, {c.BoundingBox.Center.Y:F1}");
|
||||
item.Tag = c;
|
||||
listView.Items.Add(item);
|
||||
}
|
||||
|
||||
listView.EndUpdate();
|
||||
UpdateCountLabel();
|
||||
}
|
||||
|
||||
private void UpdateCountLabel()
|
||||
{
|
||||
var selected = candidates.Count(c => c.IsSelected);
|
||||
lblCount.Text = $"{selected} of {candidates.Count} selected";
|
||||
btnApply.Enabled = selected > 0;
|
||||
}
|
||||
|
||||
private void OnItemSelected(object sender, ListViewItemSelectionChangedEventArgs e)
|
||||
{
|
||||
if (!e.IsSelected || e.Item.Tag is not ArcCandidate candidate)
|
||||
{
|
||||
entityView?.ClearSimplifierPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight the candidate lines in the shape
|
||||
var shape = shapes[candidate.ShapeIndex];
|
||||
var highlightEntities = new List<Entity>();
|
||||
for (var i = candidate.StartIndex; i <= candidate.EndIndex; i++)
|
||||
highlightEntities.Add(shape.Entities[i]);
|
||||
|
||||
entityView.SimplifierHighlight = highlightEntities;
|
||||
entityView.SimplifierPreview = candidate.FittedArc;
|
||||
|
||||
// Build tolerance zone by offsetting each original line both directions
|
||||
var tol = simplifier.Tolerance;
|
||||
var leftEntities = new List<Entity>();
|
||||
var rightEntities = new List<Entity>();
|
||||
foreach (var entity in highlightEntities)
|
||||
{
|
||||
var left = entity.OffsetEntity(tol, OffsetSide.Left);
|
||||
var right = entity.OffsetEntity(tol, OffsetSide.Right);
|
||||
if (left != null) leftEntities.Add(left);
|
||||
if (right != null) rightEntities.Add(right);
|
||||
}
|
||||
entityView.SimplifierToleranceLeft = leftEntities;
|
||||
entityView.SimplifierToleranceRight = rightEntities;
|
||||
|
||||
// Zoom with padding for the tolerance zone
|
||||
var padded = new Box(
|
||||
candidate.BoundingBox.X - tol * 2,
|
||||
candidate.BoundingBox.Y - tol * 2,
|
||||
candidate.BoundingBox.Width + tol * 4,
|
||||
candidate.BoundingBox.Length + tol * 4);
|
||||
entityView.ZoomToArea(padded);
|
||||
}
|
||||
|
||||
private void OnItemChecked(object sender, ItemCheckedEventArgs e)
|
||||
{
|
||||
if (e.Item.Tag is ArcCandidate candidate)
|
||||
{
|
||||
candidate.IsSelected = e.Item.Checked;
|
||||
UpdateCountLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnToleranceChanged(object sender, System.EventArgs e)
|
||||
{
|
||||
if (simplifier == null) return;
|
||||
simplifier.Tolerance = (double)numTolerance.Value;
|
||||
entityView?.ClearSimplifierPreview();
|
||||
RunAnalysis();
|
||||
}
|
||||
|
||||
private void OnApplyClick(object sender, System.EventArgs e)
|
||||
{
|
||||
var byShape = candidates
|
||||
.Where(c => c.IsSelected)
|
||||
.GroupBy(c => c.ShapeIndex)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (byShape.TryGetValue(i, out var selected))
|
||||
shapes[i] = simplifier.Apply(shapes[i], selected);
|
||||
}
|
||||
|
||||
var entities = shapes.SelectMany(s => s.Entities).ToList();
|
||||
entityView?.ClearSimplifierPreview();
|
||||
Applied?.Invoke(entities);
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override bool ProcessDialogKey(Keys keyData)
|
||||
{
|
||||
if (keyData == Keys.Escape)
|
||||
{
|
||||
Close();
|
||||
return true;
|
||||
}
|
||||
return base.ProcessDialogKey(keyData);
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
entityView?.ClearSimplifierPreview();
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
Generated
+12
@@ -214,5 +214,17 @@ namespace OpenNest.Properties {
|
||||
this["LastPierceTime"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("")]
|
||||
public string DisabledStrategies {
|
||||
get {
|
||||
return ((string)(this["DisabledStrategies"]));
|
||||
}
|
||||
set {
|
||||
this["DisabledStrategies"] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
<Setting Name="LastPierceTime" Type="System.Decimal" Scope="User">
|
||||
<Value Profile="(Default)">0</Value>
|
||||
</Setting>
|
||||
<Setting Name="DisabledStrategies" Type="System.String" Scope="User">
|
||||
<Value Profile="(Default)" />
|
||||
</Setting>
|
||||
</Settings>
|
||||
</SettingsFile>
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenNest
|
||||
|
||||
A Windows desktop app for CNC nesting — imports DXF drawings, arranges parts on plates and exports layouts as DXF or G-code for cutting.
|
||||
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
||||
|
||||

|
||||
|
||||
@@ -8,15 +8,21 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and
|
||||
|
||||
## Features
|
||||
|
||||
- **DXF Import/Export** — Load part drawings from DXF files and export completed nest layouts
|
||||
- **Multiple Fill Strategies** — Grid-based linear fill and rectangle bin packing
|
||||
- **Part Rotation** — Automatically tries different rotation angles to find better fits
|
||||
- **Gravity Compaction** — After placing parts, pushes them together to close gaps
|
||||
- **DXF/DWG Import & Export** — Load part drawings from DXF or DWG files and export completed nest layouts as DXF
|
||||
- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system
|
||||
- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts
|
||||
- **GPU Acceleration** — Optional ILGPU-based bitmap overlap detection for faster best-fit evaluation
|
||||
- **Part Rotation** — Automatically tries different rotation angles to find better fits, with optional ML-based angle prediction (ONNX)
|
||||
- **Gravity Compaction** — After placing parts, pushes them together using polygon-based directional distance to close gaps between irregular shapes
|
||||
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
|
||||
- **G-code Output** — Post-process nested layouts to G-code for CNC cutting machines
|
||||
- **Built-in Shapes** — Create basic geometric parts (circles, rectangles, triangles, etc.) without needing a DXF file
|
||||
- **Interactive Editing** — Zoom, pan, select, clone, and manually arrange parts on the plate view
|
||||
- **Lead-in/Lead-out & Tabs** — Cutting parameters like approach paths and holding tabs (engine support, UI coming soon)
|
||||
- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts
|
||||
- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints
|
||||
- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in)
|
||||
- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting
|
||||
- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors
|
||||
- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts
|
||||
- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view
|
||||
- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs
|
||||
|
||||

|
||||
|
||||
@@ -46,12 +52,11 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
|
||||
### Quick Walkthrough
|
||||
|
||||
1. **Create a nest** — File > New Nest
|
||||
2. **Add drawings** — Import DXF files or create built-in shapes (rectangles, circles, etc.). DXF drawings should be 1:1 scale CAD files.
|
||||
3. **Set up a plate** — Define the plate size and material
|
||||
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
|
||||
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||
|
||||
<!-- TODO: Add screenshots for each step -->
|
||||
2. **Add drawings** — Import DXF files via the CAD Converter (handles bend detection, layer filtering, and color/linetype exclusion) or create built-in shapes
|
||||
3. **Set up a plate** — Define the plate size, material, quadrant, and spacing
|
||||
4. **Fill the plate** — The nesting engine arranges parts automatically using the active fill strategy
|
||||
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
||||
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||
|
||||
## Command-Line Interface
|
||||
|
||||
@@ -95,6 +100,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| `--keep-parts` | Keep existing parts instead of clearing before fill |
|
||||
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
|
||||
| `--engine <name>` | Select a registered nesting engine |
|
||||
| `--post <name>` | Post-process the result with the named post-processor plugin |
|
||||
| `--no-save` | Skip saving the output file |
|
||||
| `--no-log` | Skip writing the debug log |
|
||||
|
||||
@@ -102,26 +108,74 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
|
||||
```
|
||||
OpenNest.sln
|
||||
├── OpenNest/ # WinForms desktop application (UI)
|
||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
|
||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
|
||||
├── OpenNest.Training/ # ML training data collection
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
└── OpenNest.Tests/ # Unit tests
|
||||
├── OpenNest/ # WinForms desktop application (UI)
|
||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit)
|
||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||
├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)
|
||||
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
||||
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
||||
└── OpenNest.Tests/ # Unit tests (xUnit)
|
||||
```
|
||||
|
||||
For most users, only these matter:
|
||||
|
||||
| Project | What it does |
|
||||
|---------|-------------|
|
||||
| **OpenNest** | The app you run. WinForms UI with plate viewer, drawing list, and dialogs. |
|
||||
| **OpenNest** | The app you run. WinForms MDI interface with plate viewer, drawing list, CAD converter, and dialogs. |
|
||||
| **OpenNest.Console** | Command-line interface for batch nesting, scripting, and automation. |
|
||||
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
|
||||
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
|
||||
| **OpenNest.IO** | Reads and writes files — DXF (via ACadSharp), G-code, and the `.nest` ZIP format. |
|
||||
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. |
|
||||
| **OpenNest.Engine** | The brains — fill strategies (linear, pairs, rect best-fit, extents), NFP-based pair evaluation, gravity compaction, and a pluggable engine registry. |
|
||||
| **OpenNest.IO** | Reads and writes files — DXF/DWG (via ACadSharp), G-code, and the `.nest` ZIP format. |
|
||||
| **OpenNest.Api** | High-level API for running the full nesting pipeline programmatically (import, nest, export). |
|
||||
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
|
||||
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
|
||||
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
|
||||
| **OpenNest.Tests** | 75+ test files covering core geometry, fill strategies, splitting, bending, post-processing, and the API. |
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.
|
||||
|
||||
| Engine | Description |
|
||||
|--------|-------------|
|
||||
| **Default** | Multi-phase strategy: linear fill, pair fill, rect best-fit, then remainder. Balances density and speed. |
|
||||
| **Vertical Remnant** | Optimizes for a clean vertical drop on the right side of the plate. |
|
||||
| **Horizontal Remnant** | Optimizes for a clean horizontal drop on the top of the plate. |
|
||||
|
||||
Custom engines can be built by subclassing `NestEngineBase` and registering via `NestEngineRegistry` or dropping a plugin DLL in the `Engines/` directory.
|
||||
|
||||
### Fill Strategies
|
||||
|
||||
Each engine composes from a set of fill strategies:
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| **Linear** | Grid-based fill with geometry-aware copy distance and 4-config rotation/axis optimization |
|
||||
| **Pairs** | NFP-based interlocking pair evaluation — finds tight-fitting orientations between two parts |
|
||||
| **Rect Best-Fit** | Greedy rectangle bin-packing with horizontal and vertical orientation trials |
|
||||
| **Extents** | Extents-based pair tiling for simple rectangular arrangements |
|
||||
|
||||
## Drawing Splitting
|
||||
|
||||
Oversized parts that don't fit on a single plate can be split into smaller pieces:
|
||||
|
||||
- **Straight Split** — Clean cut with no joining features
|
||||
- **Weld-Gap Tabs** — Rectangular tab spacers on one side for weld alignment
|
||||
- **Spike-Groove** — Interlocking V-shaped spike and groove pairs for self-aligning joints
|
||||
|
||||
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
||||
|
||||
## Post-Processors
|
||||
|
||||
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
||||
|
||||
**Included:**
|
||||
|
||||
- **Cincinnati** — Full post-processor for Cincinnati CL-707/800/900/940/CLX laser cutting machines with variable declarations, material library resolution, speed classification, kerf compensation, and optional part sub-programs (M98).
|
||||
|
||||
Custom post-processors implement the `IPostProcessor` interface and are auto-discovered from DLLs in the `Posts/` directory.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
@@ -131,6 +185,7 @@ For most users, only these matter:
|
||||
| `F` | Zoom to fit the plate view |
|
||||
| `Shift` + Mouse Wheel | Rotate parts when a drawing is selected |
|
||||
| `Shift` + Left Click | Push the selected group of parts to the bottom-left most point |
|
||||
| Middle Mouse Click | Rotate selected parts 90 degrees |
|
||||
| `X` | Push selected parts left (negative X) |
|
||||
| `Shift+X` | Push selected parts right (positive X) |
|
||||
| `Y` | Push selected parts down (negative Y) |
|
||||
@@ -145,18 +200,26 @@ For most users, only these matter:
|
||||
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
|
||||
| DWG (AutoCAD Drawing) | Yes | No |
|
||||
| G-code | No | Yes (via post-processors) |
|
||||
| `.nest` (ZIP-based project format) | Yes | Yes |
|
||||
|
||||
## Nest File Format
|
||||
|
||||
Nest files (`.nest`) are ZIP archives containing:
|
||||
|
||||
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
|
||||
- `programs/program-N` — G-code text for each drawing's cut program
|
||||
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **NFP-based nesting** — No Fit Polygon algorithms and simulated annealing optimizer exist in the engine but aren't integrated into the UI or engine registry yet
|
||||
- **Lead-in/Lead-out UI** — Engine support for lead-ins, lead-outs, and tabs is implemented; needs a UI for configuration
|
||||
- **Sheet cut-offs** — Cut the sheet to size after nesting to reduce waste
|
||||
- **Post-processors** — Plugin interface (`IPostProcessor`) is in place; need to ship built-in post-processors for common CNC controllers
|
||||
- **Shape library UI** — Built-in shape generation code exists; needs a browsable library UI for quick access
|
||||
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
|
||||
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
|
||||
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
|
||||
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
|
||||
|
||||
## Status
|
||||
|
||||
OpenNest is under active development. The core nesting workflows function, but there's plenty of room for improvement in packing efficiency, UI polish, and format support. Contributions and feedback are welcome.
|
||||
OpenNest is under active development. The core nesting workflows function end-to-end — from DXF import through filling, splitting, cut-offs, and G-code post-processing. Contributions and feedback are welcome.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
# Direct Arc Conversion for Spline and Ellipse Import
|
||||
|
||||
## Problem
|
||||
|
||||
During DXF import, splines and ellipses are converted to many small line segments (200 for ellipses, control-point polygons for splines), then optionally reconstructed back to arcs via GeometrySimplifier in the CAD converter. This is wasteful and lossy:
|
||||
|
||||
- **Ellipses** are sampled into 200 line segments, discarding the known parametric form.
|
||||
- **Splines** connect control points with lines, which is geometrically incorrect for B-splines (control points don't lie on the curve).
|
||||
- Reconstructing arcs from approximate line segments is less accurate than fitting arcs to the exact curve.
|
||||
|
||||
## Solution
|
||||
|
||||
Convert splines and ellipses directly to circular arcs (and lines where necessary) during import, using the exact curve geometry. No user review step — the import produces the best representation automatically.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| When to convert | During import (automatic) | User didn't ask for 200 lines; produce best representation |
|
||||
| Tolerance | 0.001" default | Tighter than simplifier's 0.004" because we have exact curves |
|
||||
| Ellipse method | Analytical (curvature-based) | We have the exact parametric form |
|
||||
| Spline method | Sample-then-fit | ACadSharp provides `PolygonalVertexes()` for accurate curve points |
|
||||
| Fallback | Keep line segments where arcs can't fit | Handles rapid curvature changes in splines |
|
||||
| Junction continuity | G1 (tangent) continuity | Normal-constrained arc centers prevent serrated edges |
|
||||
|
||||
## Architecture
|
||||
|
||||
Two new classes in `OpenNest.Core/Geometry/`:
|
||||
|
||||
### EllipseConverter
|
||||
|
||||
**Input:** Ellipse parameters — center, semi-major axis length, semi-minor axis length, rotation angle, start parameter, end parameter, tolerance.
|
||||
|
||||
**Output:** `List<Entity>` containing arcs that approximate the ellipse within tolerance.
|
||||
|
||||
**Algorithm — normal-constrained arc fitting:**
|
||||
|
||||
1. Compute an initial set of split parameters along the ellipse. Start with quadrant boundaries (points of maximum/minimum curvature) as natural split candidates.
|
||||
2. For each pair of consecutive split points (t_start, t_end):
|
||||
a. Compute the ellipse normal at both endpoints analytically.
|
||||
b. Find the arc center at the intersection of the two normals. This guarantees the arc is tangent to the ellipse at both endpoints (G1 continuity).
|
||||
c. Compute the arc radius from the center to either endpoint.
|
||||
d. Sample several points on the ellipse between t_start and t_end, and measure the maximum radial deviation from the fitted arc.
|
||||
e. If deviation exceeds tolerance, subdivide: insert a split point at the midpoint and retry both halves.
|
||||
f. If deviation is within tolerance, emit the arc.
|
||||
3. Continue until all segments are within tolerance.
|
||||
|
||||
**Ellipse analytical formulas (in local coordinates before rotation):**
|
||||
|
||||
- Point: `P(t) = (a cos t, b sin t)`
|
||||
- Tangent: `T(t) = (-a sin t, b cos t)`
|
||||
- Normal (inward): perpendicular to tangent, pointing toward center of curvature
|
||||
- Curvature: `k(t) = ab / (a^2 sin^2 t + b^2 cos^2 t)^(3/2)`
|
||||
|
||||
After computing in local coordinates, rotate by the ellipse's major axis angle and translate to center.
|
||||
|
||||
**Arc count:** Depends on eccentricity and tolerance. A nearly-circular ellipse needs 1-2 arcs. A highly eccentric one (ratio < 0.3) may need 8-16. Tolerance drives this automatically via subdivision.
|
||||
|
||||
**Closed ellipse handling:** When the ellipse sweep is approximately 2pi, ensure the last arc's endpoint connects back to the first arc's start point. Tangent continuity wraps around.
|
||||
|
||||
### SplineConverter
|
||||
|
||||
**Input:** List of points evaluated on the spline curve (from ACadSharp's `PolygonalVertexes`), tolerance, and whether the spline is closed.
|
||||
|
||||
**Output:** `List<Entity>` containing arcs and lines that approximate the spline within tolerance.
|
||||
|
||||
**Algorithm — tangent-chained greedy arc fitting:**
|
||||
|
||||
1. Evaluate the spline at high density using `PolygonalVertexes(precision)` where precision comes from the existing `SplinePrecision` setting.
|
||||
2. Walk the evaluated points from the start:
|
||||
a. At the current segment start, compute the tangent direction from the first two points (or from the chained tangent of the previous arc).
|
||||
b. Fit an arc constrained to be tangent at the start point:
|
||||
- The arc center lies on the normal to the tangent at the start point.
|
||||
- Use the perpendicular bisector of the chord from start to candidate end point, intersected with the start normal, to find the center.
|
||||
c. Extend the arc forward point-by-point. At each extension, recompute the center (intersection of start normal and chord bisector to the new endpoint) and check that all intermediate points are within tolerance of the arc.
|
||||
d. When adding the next point would exceed tolerance, finalize the arc with the last good endpoint.
|
||||
e. Compute the tangent at the arc's end point (perpendicular to the radius at that point) and chain it to the next segment.
|
||||
3. If fewer than 3 points remain in a run where no arc fits (curvature changes too rapidly), emit line segments instead.
|
||||
4. For closed splines, chain the final arc's tangent back to constrain the first arc.
|
||||
|
||||
**This is essentially the same approach GeometrySimplifier uses** (tangent chaining via `chainedTangent`), but operating on densely-sampled curve points rather than pre-existing line segments.
|
||||
|
||||
### Changes to Existing Code
|
||||
|
||||
#### Extensions.cs
|
||||
|
||||
```
|
||||
// Before: returns List<Line>
|
||||
public static List<Geometry.Line> ToOpenNest(this Spline spline)
|
||||
|
||||
// After: returns List<Entity>
|
||||
public static List<Entity> ToOpenNest(this Spline spline, int precision)
|
||||
```
|
||||
|
||||
- Extracts ACadSharp spline data, calls `SplineConverter.Convert()`
|
||||
- Now accepts `precision` parameter (was ignored before)
|
||||
|
||||
```
|
||||
// Before: returns List<Line>
|
||||
public static List<Geometry.Line> ToOpenNest(this Ellipse ellipse, int precision = 200)
|
||||
|
||||
// After: returns List<Entity>
|
||||
public static List<Entity> ToOpenNest(this Ellipse ellipse, double tolerance = 0.001)
|
||||
```
|
||||
|
||||
- Extracts ACadSharp ellipse parameters, calls `EllipseConverter.Convert()`
|
||||
- Precision parameter replaced by tolerance (precision is no longer relevant)
|
||||
|
||||
Both methods preserve Layer, Color, and LineTypeName on the output entities.
|
||||
|
||||
#### DxfImporter.cs
|
||||
|
||||
Currently collects `List<Line>` and `List<Arc>` separately. The new converters return `List<Entity>` (mixed arcs and lines). Options:
|
||||
|
||||
- Sort returned entities into the existing `lines` and `arcs` lists by type, OR
|
||||
- Switch to a single `List<Entity>` collection
|
||||
|
||||
The simpler change is to sort into existing lists so downstream code (GeometryOptimizer, ShapeBuilder) is unaffected.
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- **GeometrySimplifier** — still exists for user-triggered simplification of genuinely line-based geometry (e.g., DXF files that actually contain line segments, or polylines)
|
||||
- **GeometryOptimizer** — still merges collinear lines and coradial arcs post-import. May merge adjacent arcs produced by the new converters if they happen to be coradial.
|
||||
- **ShapeBuilder, ConvertGeometry, CNC pipeline** — unchanged, they already handle mixed Line/Arc entities
|
||||
- **SplinePrecision setting** — still used for spline point evaluation density
|
||||
|
||||
## Testing
|
||||
|
||||
- **EllipseConverter unit tests:**
|
||||
- Circle (ratio = 1.0) produces 1-2 arcs
|
||||
- Moderate ellipse produces arcs within tolerance
|
||||
- Highly eccentric ellipse produces more arcs, all within tolerance
|
||||
- Partial ellipse (elliptical arc) works correctly
|
||||
- Endpoint continuity: each arc's end matches the next arc's start
|
||||
- Tangent continuity: no discontinuities at junctions
|
||||
- Closed ellipse: last arc connects back to first
|
||||
|
||||
- **SplineConverter unit tests:**
|
||||
- Circular arc spline produces a single arc
|
||||
- S-curve spline produces arcs + lines where needed
|
||||
- Straight-line spline produces a line (not degenerate arcs)
|
||||
- Closed spline: endpoints connect
|
||||
- Tangent chaining: smooth transitions between consecutive arcs
|
||||
|
||||
- **Integration test:**
|
||||
- Import a DXF with splines and ellipses, verify the result contains arcs (not 200 lines)
|
||||
- Compare bounding boxes to ensure geometry is preserved
|
||||
Reference in New Issue
Block a user