diff --git a/OpenNest.Core/Geometry/GeometrySimplifier.cs b/OpenNest.Core/Geometry/GeometrySimplifier.cs
index 4828972..33078af 100644
--- a/OpenNest.Core/Geometry/GeometrySimplifier.cs
+++ b/OpenNest.Core/Geometry/GeometrySimplifier.cs
@@ -15,11 +15,46 @@ public class ArcCandidate
public double MaxDeviation { get; set; }
public Box BoundingBox { get; set; }
public bool IsSelected { get; set; } = true;
+ /// First point of the original line segments this candidate covers.
+ public Vector FirstPoint { get; set; }
+ /// Last point of the original line segments this candidate covers.
+ public Vector LastPoint { get; set; }
+}
+
+///
+/// A mirror axis defined by a point on the axis and a unit direction vector.
+///
+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;
+ }
+
+ /// Reflects a point across this axis.
+ 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.5;
+ public double Tolerance { get; set; } = 0.004;
public int MinLines { get; set; } = 3;
public List Analyze(Shape shape)
@@ -36,18 +71,16 @@ public class GeometrySimplifier
continue;
}
- // Collect consecutive lines and arcs on the same layer
var runStart = i;
- var layer = entities[i].Layer;
+ var layerName = entities[i].Layer?.Name;
var lineCount = 0;
- while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer == layer)
+ 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;
- // Only analyze runs that have enough line entities to simplify
if (lineCount >= MinLines)
FindCandidatesInRun(entities, runStart, runEnd, candidates);
}
@@ -67,21 +100,16 @@ public class GeometrySimplifier
foreach (var candidate in selected)
{
- // Copy entities before this candidate
while (i < candidate.StartIndex)
{
newEntities.Add(shape.Entities[i]);
i++;
}
- // Insert the fitted arc
newEntities.Add(candidate.FittedArc);
-
- // Skip past the replaced lines
i = candidate.EndIndex + 1;
}
- // Copy remaining entities
while (i < shape.Entities.Count)
{
newEntities.Add(shape.Entities[i]);
@@ -93,81 +121,386 @@ public class GeometrySimplifier
return result;
}
- private void FindCandidatesInRun(List entities, int runStart, int runEnd, List candidates)
+ ///
+ /// 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.
+ ///
+ public static MirrorAxisResult DetectMirrorAxis(Shape shape)
{
- var j = runStart;
+ var midpoints = new List();
+ foreach (var e in shape.Entities)
+ midpoints.Add(e.BoundingBox.Center);
- while (j <= runEnd - MinLines + 1)
+ 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)
{
- // Need at least MinLines entities ahead
- var k = j + MinLines - 1;
- if (k > runEnd) break;
-
- var points = CollectPoints(entities, j, k);
- if (points.Count < 3)
- {
- j++;
- continue;
- }
-
- var (center, radius, dev) = FitMirrorAxis(points);
-
- if (!center.IsValid() || dev > Tolerance)
- {
- j++;
- continue;
- }
-
- // Extend as far as possible
- var prevCenter = center;
- var prevRadius = radius;
- var prevDev = dev;
-
- while (k + 1 <= runEnd)
- {
- k++;
- points = CollectPoints(entities, j, k);
- if (points.Count < 3)
- {
- k--;
- break;
- }
-
- var (nc, nr, nd) = FitMirrorAxis(points);
-
- if (!nc.IsValid() || nd > Tolerance)
- {
- k--;
- break;
- }
-
- prevCenter = nc;
- prevRadius = nr;
- prevDev = nd;
- }
-
- var finalPoints = CollectPoints(entities, j, k);
- var arc = CreateArc(prevCenter, prevRadius, finalPoints, entities[j]);
- var bbox = ComputeBoundingBox(finalPoints);
-
- candidates.Add(new ArcCandidate
- {
- StartIndex = j,
- EndIndex = k,
- FittedArc = arc,
- MaxDeviation = prevDev,
- BoundingBox = bbox,
- });
-
- j = k + 1;
+ 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();
+
+ // 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 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;
}
///
- /// Fits a circular arc through a set of points using the mirror axis approach.
- /// The center is constrained to lie on the perpendicular bisector of the chord
- /// (P1→Pn), guaranteeing the arc passes exactly through both endpoints.
- /// Golden section search finds the optimal position along this axis.
+ /// 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.
+ ///
+ public void Symmetrize(List candidates, MirrorAxisResult axis)
+ {
+ if (!axis.IsValid || candidates.Count < 2) return;
+
+ var paired = new HashSet();
+
+ 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 entities, int runStart, int runEnd, List 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 Points, int EndIndex);
+
+ private ArcFitResult TryFitArcAt(List 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 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ private static (Vector center, double radius, double deviation) FitWithStartTangent(
+ List 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));
+ }
+
+ ///
+ /// Computes the tangent direction at the last point of a fitted arc,
+ /// used to chain tangent continuity to the next arc.
+ ///
+ private static Vector ComputeEndTangent(Vector center, List 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);
+ }
+
+ ///
+ /// 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.
///
private (Vector center, double radius, double deviation) FitMirrorAxis(List points)
{
@@ -176,24 +509,18 @@ public class GeometrySimplifier
var p1 = points[0];
var pn = points[^1];
-
- // Chord midpoint and length
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;
-
- // Unit normal (mirror axis direction, perpendicular to chord)
var nx = -dy / chordLen;
var ny = dx / chordLen;
- // Find max signed projection onto the normal (sagitta with sign)
var maxSagitta = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
@@ -201,70 +528,36 @@ public class GeometrySimplifier
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
maxSagitta = proj;
}
-
if (System.Math.Abs(maxSagitta) < 1e-10)
- return (Vector.Invalid, 0, double.MaxValue); // collinear
+ return (Vector.Invalid, 0, double.MaxValue);
- // Initial d estimate from sagitta geometry:
- // Center at M + d*N, radius R = sqrt(halfChord² + d²)
- // For a point on the arc at perpendicular distance s from chord:
- // (d - s)² = halfChord² + d² → d = (s² - halfChord²) / (2s)
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
-
- // Golden section search around initial estimate
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
- var dLow = dInit - range;
- var dHigh = dInit + range;
- var phi = (System.Math.Sqrt(5) - 1) / 2;
- for (var iter = 0; iter < 50; iter++)
- {
- var d1 = dHigh - phi * (dHigh - dLow);
- var d2 = dLow + phi * (dHigh - dLow);
+ var dOpt = GoldenSectionMin(dInit - range, dInit + range,
+ d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
+ System.Math.Sqrt(halfChord * halfChord + d * d)));
- var dev1 = EvalDeviation(points, mx, my, nx, ny, halfChord, d1);
- var dev2 = EvalDeviation(points, mx, my, nx, ny, halfChord, d2);
-
- if (dev1 < dev2)
- dHigh = d2;
- else
- dLow = d1;
-
- if (dHigh - dLow < 1e-12)
- break;
- }
-
- var dOpt = (dLow + dHigh) / 2;
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
- var deviation = EvalDeviation(points, mx, my, nx, ny, halfChord, dOpt);
-
- return (center, radius, deviation);
+ return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
}
- ///
- /// Evaluates the max deviation of intermediate points from the circle
- /// defined by center = M + d*N, radius = sqrt(halfChord² + d²).
- /// Endpoints are excluded since they're on the circle by construction.
- ///
- private static double EvalDeviation(List points,
- double mx, double my, double nx, double ny, double halfChord, double d)
+ private static double GoldenSectionMin(double low, double high, Func eval)
{
- var cx = mx + d * nx;
- var cy = my + d * ny;
- var r = System.Math.Sqrt(halfChord * halfChord + d * d);
-
- var maxDev = 0.0;
- for (var i = 1; i < points.Count - 1; i++)
+ var phi = (System.Math.Sqrt(5) - 1) / 2;
+ for (var iter = 0; iter < 30; iter++)
{
- 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 - r);
- if (dev > maxDev)
- maxDev = dev;
+ 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 maxDev;
+ return (low + high) / 2;
}
private static List CollectPoints(List entities, int start, int end)
@@ -284,11 +577,8 @@ public class GeometrySimplifier
case Arc arc:
if (i == start)
points.Add(arc.StartPoint());
- // Sample intermediate points so deviation is measured
- // accurately across the full arc span
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
var arcPoints = arc.ToPoints(segments);
- // Skip first (already added or connects to previous) and add the rest
for (var j = 1; j < arcPoints.Count; j++)
points.Add(arcPoints[j]);
break;
@@ -305,22 +595,9 @@ public class GeometrySimplifier
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;
- // Determine direction by summing signed angular changes
- var totalAngle = 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;
- totalAngle += da;
- }
-
- var isReversed = totalAngle < 0;
-
- // Normalize angles to [0, 2pi)
+ // Normalize to [0, 2pi)
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
@@ -330,78 +607,97 @@ public class GeometrySimplifier
return arc;
}
- private static Box ComputeBoundingBox(List points)
+ ///
+ /// Sums signed angular change traversing consecutive points around a center.
+ /// Positive = CCW, negative = CW.
+ ///
+ private static double SumSignedAngles(Vector center, List points)
{
- var minX = double.MaxValue;
- var minY = double.MaxValue;
- var maxX = double.MinValue;
- var maxY = double.MinValue;
-
- for (var i = 0; i < points.Count; i++)
+ var total = 0.0;
+ for (var i = 0; i < points.Count - 1; i++)
{
- if (points[i].X < minX) minX = points[i].X;
- if (points[i].Y < minY) minY = points[i].Y;
- if (points[i].X > maxX) maxX = points[i].X;
- if (points[i].Y > maxY) maxY = points[i].Y;
+ 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 new Box(minX, minY, maxX - minX, maxY - minY);
+ return total;
}
- internal static (Vector center, double radius) FitCircle(List points)
+ ///
+ /// Max deviation of intermediate points (excluding endpoints) from a circle.
+ ///
+ private static double MaxRadialDeviation(List points, double cx, double cy, double radius)
{
- var n = points.Count;
- if (n < 3)
- return (Vector.Invalid, 0);
-
- double sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0, sumXY = 0;
- double sumXZ = 0, sumYZ = 0, sumZ = 0;
-
- for (var i = 0; i < n; i++)
+ var maxDev = 0.0;
+ for (var i = 1; i < points.Count - 1; i++)
{
- var x = points[i].X;
- var y = points[i].Y;
- var z = x * x + y * y;
- sumX += x;
- sumY += y;
- sumX2 += x * x;
- sumY2 += y * y;
- sumXY += x * y;
- sumXZ += x * z;
- sumYZ += y * z;
- sumZ += z;
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static double MaxArcToSegmentDeviation(List 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 det = sumX2 * (sumY2 * n - sumY * sumY)
- - sumXY * (sumXY * n - sumY * sumX)
- + sumX * (sumXY * sumY - sumY2 * sumX);
+ var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
+ sampleCount = System.Math.Min(sampleCount, 100);
- if (System.Math.Abs(det) < 1e-10)
- return (Vector.Invalid, 0);
+ 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 detA = sumXZ * (sumY2 * n - sumY * sumY)
- - sumXY * (sumYZ * n - sumY * sumZ)
- + sumX * (sumYZ * sumY - sumY2 * sumZ);
+ 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;
+ }
- var detB = sumX2 * (sumYZ * n - sumY * sumZ)
- - sumXZ * (sumXY * n - sumY * sumX)
- + sumX * (sumXY * sumZ - sumYZ * sumX);
+ 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 detC = sumX2 * (sumY2 * sumZ - sumYZ * sumY)
- - sumXY * (sumXY * sumZ - sumYZ * sumX)
- + sumXZ * (sumXY * sumY - sumY2 * sumX);
-
- var a = detA / det;
- var b = detB / det;
- var c = detC / det;
-
- var cx = a / 2.0;
- var cy = b / 2.0;
- var rSquared = cx * cx + cy * cy + c;
-
- if (rSquared <= 0)
- return (Vector.Invalid, 0);
-
- return (new Vector(cx, cy), System.Math.Sqrt(rSquared));
+ 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));
}
}
diff --git a/OpenNest/Forms/CadConverterForm.Designer.cs b/OpenNest/Forms/CadConverterForm.Designer.cs
index f801128..293c442 100644
--- a/OpenNest/Forms/CadConverterForm.Designer.cs
+++ b/OpenNest/Forms/CadConverterForm.Designer.cs
@@ -29,6 +29,7 @@ namespace OpenNest.Forms
lblEntityCount = new System.Windows.Forms.Label();
btnSplit = new System.Windows.Forms.Button();
btnSimplify = new System.Windows.Forms.Button();
+ btnExportDxf = new System.Windows.Forms.Button();
chkShowOriginal = new System.Windows.Forms.CheckBox();
lblDetect = new System.Windows.Forms.Label();
cboBendDetector = new System.Windows.Forms.ComboBox();
@@ -130,6 +131,7 @@ namespace OpenNest.Forms
detailBar.Controls.Add(lblEntityCount);
detailBar.Controls.Add(btnSplit);
detailBar.Controls.Add(btnSimplify);
+ detailBar.Controls.Add(btnExportDxf);
detailBar.Controls.Add(chkShowOriginal);
detailBar.Controls.Add(lblDetect);
detailBar.Controls.Add(cboBendDetector);
@@ -227,6 +229,15 @@ namespace OpenNest.Forms
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;
@@ -334,6 +345,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.TextBox txtCustomer;
private System.Windows.Forms.Button btnSplit;
private System.Windows.Forms.Button btnSimplify;
+ private System.Windows.Forms.Button btnExportDxf;
private System.Windows.Forms.CheckBox chkShowOriginal;
private System.Windows.Forms.ComboBox cboBendDetector;
private System.Windows.Forms.Label lblQty;
diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs
index 3d3a7c8..20c0656 100644
--- a/OpenNest/Forms/CadConverterForm.cs
+++ b/OpenNest/Forms/CadConverterForm.cs
@@ -436,6 +436,60 @@ namespace OpenNest.Forms
entityView1.Invalidate();
}
+ private void OnExportDxfClick(object sender, EventArgs e)
+ {
+ var item = CurrentItem;
+ if (item == null) return;
+
+ using var dlg = new SaveFileDialog
+ {
+ Filter = "DXF Files|*.dxf",
+ FileName = Path.ChangeExtension(item.Name, ".dxf"),
+ };
+
+ if (dlg.ShowDialog() != DialogResult.OK) return;
+
+ var doc = new ACadSharp.CadDocument();
+ foreach (var entity in item.Entities)
+ {
+ switch (entity)
+ {
+ case Geometry.Line line:
+ doc.Entities.Add(new ACadSharp.Entities.Line
+ {
+ StartPoint = new CSMath.XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
+ EndPoint = new CSMath.XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
+ });
+ break;
+
+ case Geometry.Arc arc:
+ var startAngle = arc.StartAngle;
+ var endAngle = arc.EndAngle;
+ if (arc.IsReversed)
+ OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
+ doc.Entities.Add(new ACadSharp.Entities.Arc
+ {
+ Center = new CSMath.XYZ(arc.Center.X, arc.Center.Y, 0),
+ Radius = arc.Radius,
+ StartAngle = startAngle,
+ EndAngle = endAngle,
+ });
+ break;
+
+ case Geometry.Circle circle:
+ doc.Entities.Add(new ACadSharp.Entities.Circle
+ {
+ Center = new CSMath.XYZ(circle.Center.X, circle.Center.Y, 0),
+ Radius = circle.Radius,
+ });
+ break;
+ }
+ }
+
+ using var writer = new ACadSharp.IO.DxfWriter(dlg.FileName, doc, false);
+ writer.Write();
+ }
+
#endregion
#region Output
diff --git a/OpenNest/Forms/SimplifierViewerForm.cs b/OpenNest/Forms/SimplifierViewerForm.cs
index 1853ec0..03e0ade 100644
--- a/OpenNest/Forms/SimplifierViewerForm.cs
+++ b/OpenNest/Forms/SimplifierViewerForm.cs
@@ -7,12 +7,8 @@ using OpenNest.Geometry;
namespace OpenNest.Forms;
-public class SimplifierViewerForm : Form
+public partial class SimplifierViewerForm : Form
{
- private ListView listView;
- private System.Windows.Forms.NumericUpDown numTolerance;
- private Label lblCount;
- private Button btnApply;
private EntityView entityView;
private GeometrySimplifier simplifier;
private List shapes;
@@ -22,85 +18,10 @@ public class SimplifierViewerForm : Form
public SimplifierViewerForm()
{
- Text = "Geometry Simplifier";
- FormBorderStyle = FormBorderStyle.SizableToolWindow;
- ShowInTaskbar = false;
- TopMost = true;
- StartPosition = FormStartPosition.Manual;
- Size = new System.Drawing.Size(420, 450);
- Font = new Font("Segoe UI", 9f);
-
- InitializeControls();
+ InitializeComponent();
}
- private void InitializeControls()
- {
- // Bottom panel
- var bottomPanel = new FlowLayoutPanel
- {
- Dock = DockStyle.Bottom,
- Height = 36,
- Padding = new Padding(4, 6, 4, 4),
- WrapContents = false,
- };
-
- var lblTolerance = new Label
- {
- Text = "Tolerance:",
- AutoSize = true,
- Margin = new Padding(0, 3, 2, 0),
- };
-
- numTolerance = new System.Windows.Forms.NumericUpDown
- {
- Minimum = 0.001m,
- Maximum = 5.000m,
- DecimalPlaces = 3,
- Increment = 0.05m,
- Value = 0.500m,
- Width = 70,
- };
- numTolerance.ValueChanged += OnToleranceChanged;
-
- lblCount = new Label
- {
- Text = "0 of 0 selected",
- AutoSize = true,
- Margin = new Padding(8, 3, 4, 0),
- };
-
- btnApply = new Button
- {
- Text = "Apply",
- FlatStyle = FlatStyle.Flat,
- Width = 60,
- Margin = new Padding(4, 0, 0, 0),
- };
- btnApply.Click += OnApplyClick;
-
- bottomPanel.Controls.AddRange(new Control[] { lblTolerance, numTolerance, lblCount, btnApply });
-
- // ListView
- listView = new ListView
- {
- Dock = DockStyle.Fill,
- View = View.Details,
- FullRowSelect = true,
- CheckBoxes = true,
- GridLines = true,
- };
- listView.Columns.Add("Lines", 50);
- listView.Columns.Add("Radius", 70);
- listView.Columns.Add("Deviation", 75);
- listView.Columns.Add("Location", 100);
- listView.ItemSelectionChanged += OnItemSelected;
- listView.ItemChecked += OnItemChecked;
-
- Controls.Add(listView);
- Controls.Add(bottomPanel);
- }
-
- public void LoadShapes(List shapes, EntityView view, double tolerance = 0.5)
+ public void LoadShapes(List shapes, EntityView view, double tolerance = 0.004)
{
this.shapes = shapes;
this.entityView = view;
@@ -119,6 +40,11 @@ public class SimplifierViewerForm : Form
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();