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)
|
if (line1 == line2)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (line1.Layer?.Name != line2.Layer?.Name)
|
||||||
|
return false;
|
||||||
|
|
||||||
if (!line1.IsCollinearTo(line2))
|
if (!line1.IsCollinearTo(line2))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -113,9 +116,9 @@ namespace OpenNest.Geometry
|
|||||||
var b = b1 < b2 ? b1 : b2;
|
var b = b1 < b2 ? b1 : b2;
|
||||||
|
|
||||||
if (!line1.IsVertical() && line1.Slope() < 0)
|
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
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -127,6 +130,9 @@ namespace OpenNest.Geometry
|
|||||||
if (arc1 == arc2)
|
if (arc1 == arc2)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (arc1.Layer?.Name != arc2.Layer?.Name)
|
||||||
|
return false;
|
||||||
|
|
||||||
if (arc1.Center != arc2.Center)
|
if (arc1.Center != arc2.Center)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -161,7 +167,7 @@ namespace OpenNest.Geometry
|
|||||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||||
if (endAngle < 0) endAngle += 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;
|
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>
|
<RootNamespace>OpenNest</RootNamespace>
|
||||||
<AssemblyName>OpenNest.Core</AssemblyName>
|
<AssemblyName>OpenNest.Core</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||||
|
|||||||
+48
-1
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
@@ -173,7 +174,53 @@ namespace OpenNest
|
|||||||
perimeter1.Offset(Location);
|
perimeter1.Offset(Location);
|
||||||
perimeter2.Offset(part.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
|
public double Left
|
||||||
|
|||||||
@@ -601,10 +601,24 @@ namespace OpenNest
|
|||||||
for (var i = 0; i < realParts.Count; i++)
|
for (var i = 0; i < realParts.Count; i++)
|
||||||
{
|
{
|
||||||
var part1 = realParts[i];
|
var part1 = realParts[i];
|
||||||
|
var b1 = part1.BoundingBox;
|
||||||
|
|
||||||
for (var j = i + 1; j < realParts.Count; j++)
|
for (var j = i + 1; j < realParts.Count; j++)
|
||||||
{
|
{
|
||||||
var part2 = realParts[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))
|
if (part1.Intersects(part2, out var pts2))
|
||||||
pts.AddRange(pts2);
|
pts.AddRange(pts2);
|
||||||
|
|||||||
@@ -47,13 +47,21 @@ namespace OpenNest.Engine.Fill
|
|||||||
|
|
||||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
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
|
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
Phase = NestPhase.Extents,
|
Phase = NestPhase.Extents,
|
||||||
PlateNumber = plateNumber,
|
PlateNumber = plateNumber,
|
||||||
Parts = adjusted,
|
Parts = adjusted,
|
||||||
WorkArea = workArea,
|
WorkArea = workArea,
|
||||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
Description = $"Extents: column {adjusted.Count} parts",
|
||||||
});
|
});
|
||||||
|
|
||||||
var result = RepeatColumns(adjusted, token);
|
var result = RepeatColumns(adjusted, token);
|
||||||
@@ -386,5 +394,31 @@ namespace OpenNest.Engine.Fill
|
|||||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
||||||
part.BoundingBox.Bottom >= workArea.Bottom - 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)
|
if (gridParts.Count == 0)
|
||||||
return null;
|
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 allParts = new List<Part>(gridParts);
|
||||||
|
|
||||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||||
@@ -470,4 +479,34 @@ public class StripeFiller
|
|||||||
{
|
{
|
||||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
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 =>
|
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||||
sorted ??= FilterStrategies();
|
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()
|
private static List<IFillStrategy> FilterStrategies()
|
||||||
{
|
{
|
||||||
var source = enabledFilter != null
|
var source = enabledFilter != null
|
||||||
|
|||||||
@@ -63,9 +63,72 @@ namespace OpenNest.IO.Bending
|
|||||||
bends.Add(bend);
|
bends.Add(bend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PropagateCollinearBendNotes(bends);
|
||||||
|
|
||||||
return 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)
|
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
|
||||||
{
|
{
|
||||||
return document.Entities
|
return document.Entities
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
foreach (var entity in doc.Entities)
|
foreach (var entity in doc.Entities)
|
||||||
{
|
{
|
||||||
// Skip bend line entities — they are converted to Bend objects
|
// Skip bend/etch entities — bends are converted to Bend objects
|
||||||
// separately via bend detection, not cut geometry.
|
// separately via bend detection, and etch marks are generated from
|
||||||
if (IsBendLayer(entity.Layer?.Name))
|
// bends during DXF export. Neither should be treated as cut geometry.
|
||||||
|
if (IsNonCutLayer(entity.Layer?.Name))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
switch (entity)
|
switch (entity)
|
||||||
@@ -44,7 +45,11 @@ namespace OpenNest.IO
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ACadSharp.Entities.Spline spline:
|
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;
|
break;
|
||||||
|
|
||||||
case ACadSharp.Entities.LwPolyline lwPolyline:
|
case ACadSharp.Entities.LwPolyline lwPolyline:
|
||||||
@@ -56,7 +61,11 @@ namespace OpenNest.IO
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ACadSharp.Entities.Ellipse ellipse:
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,9 +146,10 @@ namespace OpenNest.IO
|
|||||||
return success;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-73
@@ -1,6 +1,7 @@
|
|||||||
using ACadSharp.Entities;
|
using ACadSharp.Entities;
|
||||||
using CSMath;
|
using CSMath;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -56,42 +57,46 @@ namespace OpenNest.IO
|
|||||||
return result;
|
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 layer = spline.Layer.ToOpenNest();
|
||||||
var color = spline.ResolveColor();
|
var color = spline.ResolveColor();
|
||||||
var lineTypeName = spline.ResolveLineTypeName();
|
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();
|
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
|
||||||
|
}
|
||||||
lines.Add(new Geometry.Line(lastPoint, nextPoint)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Layer = layer,
|
System.Diagnostics.Debug.WriteLine($"Spline curve evaluation failed: {ex.Message}");
|
||||||
Color = color,
|
curvePoints = null;
|
||||||
LineTypeName = lineTypeName
|
|
||||||
});
|
|
||||||
|
|
||||||
lastPoint = nextPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spline.IsClosed)
|
if (curvePoints == null || curvePoints.Count < 2)
|
||||||
lines.Add(new Geometry.Line(lastPoint, pts[0].ToOpenNest())
|
|
||||||
{
|
{
|
||||||
Layer = layer,
|
// Fallback: use control points if evaluation fails
|
||||||
Color = color,
|
curvePoints = new List<XYZ>(spline.ControlPoints);
|
||||||
LineTypeName = lineTypeName
|
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)
|
public static List<Geometry.Line> ToOpenNest(this Polyline polyline)
|
||||||
@@ -172,69 +177,32 @@ namespace OpenNest.IO
|
|||||||
return lines;
|
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 center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
||||||
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.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 semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||||
var minorLength = majorLength * ellipse.RadiusRatio;
|
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
||||||
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
||||||
|
|
||||||
var startParam = ellipse.StartParameter;
|
var startParam = ellipse.StartParameter;
|
||||||
var endParam = ellipse.EndParameter;
|
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 layer = ellipse.Layer.ToOpenNest();
|
||||||
var color = ellipse.ResolveColor();
|
var color = ellipse.ResolveColor();
|
||||||
var lineTypeName = ellipse.ResolveLineTypeName();
|
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])
|
entity.Layer = layer;
|
||||||
{
|
entity.Color = color;
|
||||||
Layer = layer,
|
entity.LineTypeName = lineTypeName;
|
||||||
Color = color,
|
|
||||||
LineTypeName = lineTypeName
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the ellipse if it's a full ellipse
|
return entities;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Geometry.Layer ToOpenNest(this ACadSharp.Tables.Layer layer)
|
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,
|
public void Write(TextWriter w, Program normalizedProgram, string drawingName,
|
||||||
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
|
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
|
||||||
{
|
{
|
||||||
var allFeatures = SplitFeatures(normalizedProgram.Codes);
|
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
|
||||||
if (allFeatures.Count == 0)
|
if (allFeatures.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Classify and order: etch features first, then cut features
|
// Classify and order: etch features first, then cut features
|
||||||
var ordered = OrderFeatures(allFeatures);
|
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
|
||||||
|
|
||||||
w.WriteLine("(*****************************************************)");
|
w.WriteLine("(*****************************************************)");
|
||||||
w.WriteLine($":{subNumber}");
|
w.WriteLine($":{subNumber}");
|
||||||
@@ -46,7 +46,7 @@ public sealed class CincinnatiPartSubprogramWriter
|
|||||||
var featureNumber = i == 0
|
var featureNumber = i == 0
|
||||||
? _config.FeatureLineNumberStart
|
? _config.FeatureLineNumberStart
|
||||||
: 1000 + i + 1;
|
: 1000 + i + 1;
|
||||||
var cutDistance = ComputeCutDistance(codes);
|
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||||
|
|
||||||
var ctx = new FeatureContext
|
var ctx = new FeatureContext
|
||||||
{
|
{
|
||||||
@@ -70,81 +70,43 @@ public sealed class CincinnatiPartSubprogramWriter
|
|||||||
w.WriteLine($"M99 (END OF {drawingName})");
|
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>
|
/// <summary>
|
||||||
/// Creates a sub-program key for matching parts to their sub-programs.
|
/// Creates a sub-program key for matching parts to their sub-programs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
|
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
|
||||||
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
|
(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>>();
|
var mapping = new Dictionary<(int, long), int>();
|
||||||
List<ICode> current = null;
|
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)
|
if (part.BaseDrawing.IsCutOff) continue;
|
||||||
features.Add(current);
|
var key = SubprogramKey(part);
|
||||||
current = new List<ICode> { code };
|
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));
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
current ??= new List<ICode>();
|
|
||||||
current.Add(code);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current != null && current.Count > 0)
|
return (mapping, entries);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,32 +84,7 @@ namespace OpenNest.Posts.Cincinnati
|
|||||||
List<(int subNum, string name, Program program)> subprogramEntries = null;
|
List<(int subNum, string name, Program program)> subprogramEntries = null;
|
||||||
|
|
||||||
if (Config.UsePartSubprograms)
|
if (Config.UsePartSubprograms)
|
||||||
{
|
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Create writers
|
// 5. Create writers
|
||||||
var preamble = new CincinnatiPreambleWriter(Config);
|
var preamble = new CincinnatiPreambleWriter(Config);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.Posts.Cincinnati;
|
namespace OpenNest.Posts.Cincinnati;
|
||||||
|
|
||||||
@@ -128,7 +127,7 @@ public sealed class CincinnatiSheetWriter
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Inline features for cutoffs or parts without sub-programs
|
// 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++)
|
for (var f = 0; f < features.Count; f++)
|
||||||
{
|
{
|
||||||
var (codes, isEtch) = features[f];
|
var (codes, isEtch) = features[f];
|
||||||
@@ -137,7 +136,7 @@ public sealed class CincinnatiSheetWriter
|
|||||||
: 1000 + featureIndex + 1;
|
: 1000 + featureIndex + 1;
|
||||||
|
|
||||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||||
var cutDistance = ComputeCutDistance(codes);
|
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||||
|
|
||||||
var ctx = new FeatureContext
|
var ctx = new FeatureContext
|
||||||
{
|
{
|
||||||
@@ -205,7 +204,7 @@ public sealed class CincinnatiSheetWriter
|
|||||||
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
|
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
|
||||||
foreach (var part in allParts)
|
foreach (var part in allParts)
|
||||||
{
|
{
|
||||||
var partFeatures = SplitAndOrderFeatures(part);
|
var partFeatures = FeatureUtils.SplitAndClassify(part);
|
||||||
foreach (var (codes, isEtch) in partFeatures)
|
foreach (var (codes, isEtch) in partFeatures)
|
||||||
features.Add((part, codes, isEtch));
|
features.Add((part, codes, isEtch));
|
||||||
}
|
}
|
||||||
@@ -224,7 +223,7 @@ public sealed class CincinnatiSheetWriter
|
|||||||
? _config.FeatureLineNumberStart
|
? _config.FeatureLineNumberStart
|
||||||
: 1000 + i + 1;
|
: 1000 + i + 1;
|
||||||
|
|
||||||
var cutDistance = ComputeCutDistance(codes);
|
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||||
|
|
||||||
var ctx = new FeatureContext
|
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);
|
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]
|
[Fact]
|
||||||
public void DetectBends_RealDxf_ParsesNotesCorrectly()
|
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 }
|
new LinearMove(1, 1) { Layer = LayerType.Scribe }
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.True(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
Assert.True(FeatureUtils.IsEtch(codes));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -165,7 +165,7 @@ public class CincinnatiSheetWriterTests
|
|||||||
new LinearMove(1, 1) { Layer = LayerType.Cut }
|
new LinearMove(1, 1) { Layer = LayerType.Cut }
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
Assert.False(FeatureUtils.IsEtch(codes));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -176,7 +176,7 @@ public class CincinnatiSheetWriterTests
|
|||||||
new RapidMove(0, 0)
|
new RapidMove(0, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
Assert.False(FeatureUtils.IsEtch(codes));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Program CreateSimpleProgram()
|
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]
|
[Fact]
|
||||||
public void Fill_ProducesPartsForSimpleDrawing()
|
public void Fill_ProducesNonOverlappingPartsForSimpleDrawing()
|
||||||
{
|
{
|
||||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||||
var drawing = MakeRectDrawing(20, 10);
|
var drawing = MakeRectDrawing(20, 10);
|
||||||
@@ -158,11 +158,19 @@ public class StripeFillerTests
|
|||||||
var parts = filler.Fill();
|
var parts = filler.Fill();
|
||||||
|
|
||||||
Assert.NotNull(parts);
|
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]
|
[Fact]
|
||||||
public void Fill_VerticalProducesParts()
|
public void Fill_VerticalProducesNonOverlappingParts()
|
||||||
{
|
{
|
||||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||||
var drawing = MakeRectDrawing(20, 10);
|
var drawing = MakeRectDrawing(20, 10);
|
||||||
@@ -186,7 +194,12 @@ public class StripeFillerTests
|
|||||||
var parts = filler.Fill();
|
var parts = filler.Fill();
|
||||||
|
|
||||||
Assert.NotNull(parts);
|
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]
|
[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.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace OpenNest.Controls
|
namespace OpenNest.Controls
|
||||||
@@ -15,6 +16,19 @@ namespace OpenNest.Controls
|
|||||||
public List<Bend> Bends = new List<Bend>();
|
public List<Bend> Bends = new List<Bend>();
|
||||||
public int SelectedBendIndex = -1;
|
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 Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||||
|
|
||||||
@@ -68,10 +82,24 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
e.Graphics.TranslateTransform(origin.X, origin.Y);
|
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)
|
foreach (var entity in Entities)
|
||||||
{
|
{
|
||||||
if (IsEtchLayer(entity.Layer)) continue;
|
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);
|
DrawEntity(e.Graphics, entity, pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +112,33 @@ namespace OpenNest.Controls
|
|||||||
DrawEntity(e.Graphics, entity, pen);
|
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
|
#if DRAW_OFFSET
|
||||||
|
|
||||||
var offsetShape = new Shape();
|
var offsetShape = new Shape();
|
||||||
@@ -185,6 +240,46 @@ namespace OpenNest.Controls
|
|||||||
private static bool IsEtchLayer(Layer layer) =>
|
private static bool IsEtchLayer(Layer layer) =>
|
||||||
string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase);
|
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)
|
private void DrawBendLines(Graphics g)
|
||||||
{
|
{
|
||||||
if (Bends == null || Bends.Count == 0)
|
if (Bends == null || Bends.Count == 0)
|
||||||
@@ -198,20 +293,26 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
DashPattern = new float[] { 6, 4 }
|
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++)
|
for (var i = 0; i < Bends.Count; i++)
|
||||||
{
|
{
|
||||||
var bend = Bends[i];
|
var bend = Bends[i];
|
||||||
var pt1 = PointWorldToGraph(bend.StartPoint);
|
var pt1 = PointWorldToGraph(bend.StartPoint);
|
||||||
var pt2 = PointWorldToGraph(bend.EndPoint);
|
var pt2 = PointWorldToGraph(bend.EndPoint);
|
||||||
|
var isSelected = i == SelectedBendIndex;
|
||||||
|
|
||||||
if (i == SelectedBendIndex)
|
if (isSelected)
|
||||||
{
|
|
||||||
g.DrawLine(glowPen, pt1, pt2);
|
g.DrawLine(glowPen, pt1, pt2);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
g.DrawLine(bendPen, pt1, pt2);
|
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)
|
public void ZoomToFit(bool redraw = true)
|
||||||
{
|
{
|
||||||
ZoomToArea(Entities.GetBoundingBox(), redraw);
|
ZoomToArea(Entities.GetBoundingBox(), redraw);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ namespace OpenNest.Controls
|
|||||||
public int Quantity { get; set; } = 1;
|
public int Quantity { get; set; } = 1;
|
||||||
public string Path { get; set; }
|
public string Path { get; set; }
|
||||||
public List<Entity> Entities { get; set; } = new();
|
public List<Entity> Entities { get; set; } = new();
|
||||||
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public List<Bend> Bends { get; set; } = new();
|
public List<Bend> Bends { get; set; } = new();
|
||||||
public Box Bounds { get; set; }
|
public Box Bounds { get; set; }
|
||||||
public int EntityCount { get; set; }
|
public int EntityCount { get; set; }
|
||||||
|
|||||||
+73
-9
@@ -15,6 +15,7 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
private void InitializeComponent()
|
private void InitializeComponent()
|
||||||
{
|
{
|
||||||
|
mainSplit = new System.Windows.Forms.SplitContainer();
|
||||||
sidebarSplit = new System.Windows.Forms.SplitContainer();
|
sidebarSplit = new System.Windows.Forms.SplitContainer();
|
||||||
fileList = new OpenNest.Controls.FileListControl();
|
fileList = new OpenNest.Controls.FileListControl();
|
||||||
filterPanel = new OpenNest.Controls.FilterPanel();
|
filterPanel = new OpenNest.Controls.FilterPanel();
|
||||||
@@ -27,11 +28,18 @@ namespace OpenNest.Forms
|
|||||||
lblDimensions = new System.Windows.Forms.Label();
|
lblDimensions = new System.Windows.Forms.Label();
|
||||||
lblEntityCount = new System.Windows.Forms.Label();
|
lblEntityCount = new System.Windows.Forms.Label();
|
||||||
btnSplit = new System.Windows.Forms.Button();
|
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();
|
lblDetect = new System.Windows.Forms.Label();
|
||||||
cboBendDetector = new System.Windows.Forms.ComboBox();
|
cboBendDetector = new System.Windows.Forms.ComboBox();
|
||||||
bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||||
cancelButton = new System.Windows.Forms.Button();
|
cancelButton = new System.Windows.Forms.Button();
|
||||||
acceptButton = 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();
|
((System.ComponentModel.ISupportInitialize)sidebarSplit).BeginInit();
|
||||||
sidebarSplit.Panel1.SuspendLayout();
|
sidebarSplit.Panel1.SuspendLayout();
|
||||||
sidebarSplit.Panel2.SuspendLayout();
|
sidebarSplit.Panel2.SuspendLayout();
|
||||||
@@ -41,9 +49,30 @@ namespace OpenNest.Forms
|
|||||||
bottomPanel1.SuspendLayout();
|
bottomPanel1.SuspendLayout();
|
||||||
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
|
||||||
//
|
//
|
||||||
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Left;
|
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
sidebarSplit.Location = new System.Drawing.Point(0, 0);
|
sidebarSplit.Location = new System.Drawing.Point(0, 0);
|
||||||
sidebarSplit.Name = "sidebarSplit";
|
sidebarSplit.Name = "sidebarSplit";
|
||||||
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
|
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
|
||||||
@@ -58,7 +87,7 @@ namespace OpenNest.Forms
|
|||||||
sidebarSplit.Size = new System.Drawing.Size(260, 670);
|
sidebarSplit.Size = new System.Drawing.Size(260, 670);
|
||||||
sidebarSplit.SplitterDistance = 300;
|
sidebarSplit.SplitterDistance = 300;
|
||||||
sidebarSplit.SplitterWidth = 5;
|
sidebarSplit.SplitterWidth = 5;
|
||||||
sidebarSplit.TabIndex = 2;
|
sidebarSplit.TabIndex = 0;
|
||||||
//
|
//
|
||||||
// fileList
|
// fileList
|
||||||
//
|
//
|
||||||
@@ -86,9 +115,9 @@ namespace OpenNest.Forms
|
|||||||
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
||||||
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
||||||
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
|
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.Name = "entityView1";
|
||||||
entityView1.Size = new System.Drawing.Size(764, 634);
|
entityView1.Size = new System.Drawing.Size(759, 634);
|
||||||
entityView1.TabIndex = 0;
|
entityView1.TabIndex = 0;
|
||||||
//
|
//
|
||||||
// detailBar
|
// detailBar
|
||||||
@@ -101,13 +130,16 @@ namespace OpenNest.Forms
|
|||||||
detailBar.Controls.Add(lblDimensions);
|
detailBar.Controls.Add(lblDimensions);
|
||||||
detailBar.Controls.Add(lblEntityCount);
|
detailBar.Controls.Add(lblEntityCount);
|
||||||
detailBar.Controls.Add(btnSplit);
|
detailBar.Controls.Add(btnSplit);
|
||||||
|
detailBar.Controls.Add(btnSimplify);
|
||||||
|
detailBar.Controls.Add(btnExportDxf);
|
||||||
|
detailBar.Controls.Add(chkShowOriginal);
|
||||||
detailBar.Controls.Add(lblDetect);
|
detailBar.Controls.Add(lblDetect);
|
||||||
detailBar.Controls.Add(cboBendDetector);
|
detailBar.Controls.Add(cboBendDetector);
|
||||||
detailBar.Dock = System.Windows.Forms.DockStyle.Bottom;
|
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.Name = "detailBar";
|
||||||
detailBar.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
|
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.TabIndex = 1;
|
||||||
detailBar.WrapContents = false;
|
detailBar.WrapContents = false;
|
||||||
//
|
//
|
||||||
@@ -188,6 +220,32 @@ namespace OpenNest.Forms
|
|||||||
btnSplit.TabIndex = 6;
|
btnSplit.TabIndex = 6;
|
||||||
btnSplit.Text = "Split...";
|
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
|
||||||
//
|
//
|
||||||
lblDetect.AutoSize = true;
|
lblDetect.AutoSize = true;
|
||||||
@@ -248,9 +306,7 @@ namespace OpenNest.Forms
|
|||||||
AllowDrop = true;
|
AllowDrop = true;
|
||||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||||
ClientSize = new System.Drawing.Size(1024, 720);
|
ClientSize = new System.Drawing.Size(1024, 720);
|
||||||
Controls.Add(entityView1);
|
Controls.Add(mainSplit);
|
||||||
Controls.Add(detailBar);
|
|
||||||
Controls.Add(sidebarSplit);
|
|
||||||
Controls.Add(bottomPanel1);
|
Controls.Add(bottomPanel1);
|
||||||
Font = new System.Drawing.Font("Segoe UI", 9F);
|
Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
MinimizeBox = false;
|
MinimizeBox = false;
|
||||||
@@ -260,6 +316,10 @@ namespace OpenNest.Forms
|
|||||||
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||||
Text = "CAD Converter";
|
Text = "CAD Converter";
|
||||||
WindowState = System.Windows.Forms.FormWindowState.Maximized;
|
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.Panel1.ResumeLayout(false);
|
||||||
sidebarSplit.Panel2.ResumeLayout(false);
|
sidebarSplit.Panel2.ResumeLayout(false);
|
||||||
((System.ComponentModel.ISupportInitialize)sidebarSplit).EndInit();
|
((System.ComponentModel.ISupportInitialize)sidebarSplit).EndInit();
|
||||||
@@ -273,6 +333,7 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
private System.Windows.Forms.SplitContainer mainSplit;
|
||||||
private System.Windows.Forms.SplitContainer sidebarSplit;
|
private System.Windows.Forms.SplitContainer sidebarSplit;
|
||||||
private Controls.FileListControl fileList;
|
private Controls.FileListControl fileList;
|
||||||
private Controls.FilterPanel filterPanel;
|
private Controls.FilterPanel filterPanel;
|
||||||
@@ -283,6 +344,9 @@ namespace OpenNest.Forms
|
|||||||
private System.Windows.Forms.NumericUpDown numQuantity;
|
private System.Windows.Forms.NumericUpDown numQuantity;
|
||||||
private System.Windows.Forms.TextBox txtCustomer;
|
private System.Windows.Forms.TextBox txtCustomer;
|
||||||
private System.Windows.Forms.Button btnSplit;
|
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.ComboBox cboBendDetector;
|
||||||
private System.Windows.Forms.Label lblQty;
|
private System.Windows.Forms.Label lblQty;
|
||||||
private System.Windows.Forms.Label lblCust;
|
private System.Windows.Forms.Label lblCust;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace OpenNest.Forms
|
|||||||
public partial class CadConverterForm : Form
|
public partial class CadConverterForm : Form
|
||||||
{
|
{
|
||||||
private static int colorIndex;
|
private static int colorIndex;
|
||||||
|
private SimplifierViewerForm simplifierViewer;
|
||||||
|
|
||||||
public CadConverterForm()
|
public CadConverterForm()
|
||||||
{
|
{
|
||||||
@@ -140,6 +141,7 @@ namespace OpenNest.Forms
|
|||||||
entityView1.IsPickingBendLine = false;
|
entityView1.IsPickingBendLine = false;
|
||||||
filterPanel.SetPickMode(false);
|
filterPanel.SetPickMode(false);
|
||||||
}
|
}
|
||||||
|
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
|
||||||
entityView1.Entities.Clear();
|
entityView1.Entities.Clear();
|
||||||
entityView1.Entities.AddRange(item.Entities);
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||||
@@ -161,6 +163,50 @@ namespace OpenNest.Forms
|
|||||||
lblEntityCount.Text = $"{item.EntityCount} entities";
|
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||||
|
|
||||||
entityView1.ZoomToFit();
|
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()
|
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
|
#endregion
|
||||||
|
|
||||||
#region Output
|
#region Output
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ namespace OpenNest.Forms
|
|||||||
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
||||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||||
|
|
||||||
|
OptionsForm.ApplyDisabledStrategies();
|
||||||
|
|
||||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||||
engineComboBox.Items.Add(engine.Name);
|
engineComboBox.Items.Add(engine.Name);
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ namespace OpenNest.Forms
|
|||||||
private string GetNestName(DateTime date, int id)
|
private string GetNestName(DateTime date, int id)
|
||||||
{
|
{
|
||||||
var year = (date.Year % 100).ToString("D2");
|
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}";
|
return $"N{year}-{seq}";
|
||||||
}
|
}
|
||||||
@@ -87,13 +89,13 @@ namespace OpenNest.Forms
|
|||||||
private static string ToBase36(int value)
|
private static string ToBase36(int value)
|
||||||
{
|
{
|
||||||
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
|
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
|
||||||
if (value == 0) return "0";
|
if (value == 0) return chars[0].ToString();
|
||||||
|
|
||||||
var result = "";
|
var result = "";
|
||||||
while (value > 0)
|
while (value > 0)
|
||||||
{
|
{
|
||||||
result = chars[value % 36] + result;
|
result = chars[value % chars.Length] + result;
|
||||||
value /= 36;
|
value /= chars.Length;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+12
@@ -42,6 +42,7 @@
|
|||||||
this.saveButton = new System.Windows.Forms.Button();
|
this.saveButton = new System.Windows.Forms.Button();
|
||||||
this.cancelButton = new System.Windows.Forms.Button();
|
this.cancelButton = new System.Windows.Forms.Button();
|
||||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||||
|
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||||
this.tableLayoutPanel1.SuspendLayout();
|
this.tableLayoutPanel1.SuspendLayout();
|
||||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown2)).BeginInit();
|
((System.ComponentModel.ISupportInitialize)(this.numericUpDown2)).BeginInit();
|
||||||
@@ -211,12 +212,22 @@
|
|||||||
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
||||||
this.bottomPanel1.TabIndex = 1;
|
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
|
// OptionsForm
|
||||||
//
|
//
|
||||||
this.AcceptButton = this.saveButton;
|
this.AcceptButton = this.saveButton;
|
||||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||||
this.CancelButton = this.cancelButton;
|
this.CancelButton = this.cancelButton;
|
||||||
this.ClientSize = new System.Drawing.Size(708, 418);
|
this.ClientSize = new System.Drawing.Size(708, 418);
|
||||||
|
this.Controls.Add(this.strategyGroupBox);
|
||||||
this.Controls.Add(this.tableLayoutPanel1);
|
this.Controls.Add(this.tableLayoutPanel1);
|
||||||
this.Controls.Add(this.bottomPanel1);
|
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)));
|
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.TextBox textBox1;
|
||||||
private System.Windows.Forms.Label label3;
|
private System.Windows.Forms.Label label3;
|
||||||
private System.Windows.Forms.Button button1;
|
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;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
{
|
{
|
||||||
public partial class OptionsForm : Form
|
public partial class OptionsForm : Form
|
||||||
{
|
{
|
||||||
|
private readonly List<CheckBox> _strategyCheckBoxes = new();
|
||||||
|
|
||||||
public OptionsForm()
|
public OptionsForm()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
BuildStrategyCheckBoxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnLoad(System.EventArgs e)
|
protected override void OnLoad(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnLoad(e);
|
base.OnLoad(e);
|
||||||
LoadSettings();
|
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()
|
private void LoadSettings()
|
||||||
{
|
{
|
||||||
textBox1.Text = Settings.Default.NestTemplatePath;
|
textBox1.Text = Settings.Default.NestTemplatePath;
|
||||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||||
numericUpDown2.Value = (decimal)Settings.Default.ImportSplinePrecision;
|
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()
|
private void SaveSettings()
|
||||||
@@ -30,15 +61,47 @@ namespace OpenNest.Forms
|
|||||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||||
Settings.Default.ImportSplinePrecision = (int)numericUpDown2.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();
|
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();
|
SaveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BrowseNestTemplatePath_Click(object sender, System.EventArgs e)
|
private void BrowseNestTemplatePath_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var dlg = new OpenFileDialog();
|
var dlg = new OpenFileDialog();
|
||||||
dlg.Filter = "Template File|*.nstdot";
|
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;
|
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">
|
<Setting Name="LastPierceTime" Type="System.Decimal" Scope="User">
|
||||||
<Value Profile="(Default)">0</Value>
|
<Value Profile="(Default)">0</Value>
|
||||||
</Setting>
|
</Setting>
|
||||||
|
<Setting Name="DisabledStrategies" Type="System.String" Scope="User">
|
||||||
|
<Value Profile="(Default)" />
|
||||||
|
</Setting>
|
||||||
</Settings>
|
</Settings>
|
||||||
</SettingsFile>
|
</SettingsFile>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# OpenNest
|
# 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
|
## Features
|
||||||
|
|
||||||
- **DXF Import/Export** — Load part drawings from DXF files and export completed nest layouts
|
- **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 and rectangle bin packing
|
- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system
|
||||||
- **Part Rotation** — Automatically tries different rotation angles to find better fits
|
- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts
|
||||||
- **Gravity Compaction** — After placing parts, pushes them together to close gaps
|
- **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
|
- **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
|
- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts
|
||||||
- **Built-in Shapes** — Create basic geometric parts (circles, rectangles, triangles, etc.) without needing a DXF file
|
- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints
|
||||||
- **Interactive Editing** — Zoom, pan, select, clone, and manually arrange parts on the plate view
|
- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in)
|
||||||
- **Lead-in/Lead-out & Tabs** — Cutting parameters like approach paths and holding tabs (engine support, UI coming soon)
|
- **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
|
### Quick Walkthrough
|
||||||
|
|
||||||
1. **Create a nest** — File > New Nest
|
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.
|
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 and material
|
3. **Set up a plate** — Define the plate size, material, quadrant, and spacing
|
||||||
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
|
4. **Fill the plate** — The nesting engine arranges parts automatically using the active fill strategy
|
||||||
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
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
|
||||||
<!-- TODO: Add screenshots for each step -->
|
|
||||||
|
|
||||||
## Command-Line Interface
|
## 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 |
|
| `--keep-parts` | Keep existing parts instead of clearing before fill |
|
||||||
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
|
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
|
||||||
| `--engine <name>` | Select a registered nesting engine |
|
| `--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-save` | Skip saving the output file |
|
||||||
| `--no-log` | Skip writing the debug log |
|
| `--no-log` | Skip writing the debug log |
|
||||||
|
|
||||||
@@ -104,24 +110,72 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
|||||||
OpenNest.sln
|
OpenNest.sln
|
||||||
├── OpenNest/ # WinForms desktop application (UI)
|
├── OpenNest/ # WinForms desktop application (UI)
|
||||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
|
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit)
|
||||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||||
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
|
├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)
|
||||||
├── OpenNest.Training/ # ML training data collection
|
├── 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.Mcp/ # MCP server for AI tool integration
|
||||||
└── OpenNest.Tests/ # Unit tests
|
├── 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 |
|
| 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.Console** | Command-line interface for batch nesting, scripting, and automation. |
|
||||||
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
|
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. |
|
||||||
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
|
| **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 (via ACadSharp), G-code, and the `.nest` ZIP format. |
|
| **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
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
@@ -131,6 +185,7 @@ For most users, only these matter:
|
|||||||
| `F` | Zoom to fit the plate view |
|
| `F` | Zoom to fit the plate view |
|
||||||
| `Shift` + Mouse Wheel | Rotate parts when a drawing is selected |
|
| `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 |
|
| `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) |
|
| `X` | Push selected parts left (negative X) |
|
||||||
| `Shift+X` | Push selected parts right (positive X) |
|
| `Shift+X` | Push selected parts right (positive X) |
|
||||||
| `Y` | Push selected parts down (negative Y) |
|
| `Y` | Push selected parts down (negative Y) |
|
||||||
@@ -145,18 +200,26 @@ For most users, only these matter:
|
|||||||
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
|
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
|
||||||
| DWG (AutoCAD Drawing) | Yes | No |
|
| DWG (AutoCAD Drawing) | Yes | No |
|
||||||
| G-code | No | Yes (via post-processors) |
|
| 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
|
## 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
|
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
|
||||||
- **Lead-in/Lead-out UI** — Engine support for lead-ins, lead-outs, and tabs is implemented; needs a UI for configuration
|
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
|
||||||
- **Sheet cut-offs** — Cut the sheet to size after nesting to reduce waste
|
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
|
||||||
- **Post-processors** — Plugin interface (`IPostProcessor`) is in place; need to ship built-in post-processors for common CNC controllers
|
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
|
||||||
- **Shape library UI** — Built-in shape generation code exists; needs a browsable library UI for quick access
|
|
||||||
|
|
||||||
## Status
|
## 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
|
## 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