fix: geometry simplifier arc connectivity and ellipse support

Three bugs prevented the simplifier from working on ellipse geometry:

1. Sweep angle check blocked initial fit — the 5-degree minimum sweep
   was inside TryFit(), killing candidates before the extension loop
   could accumulate enough segments. Moved to TryFitArcAt() after
   extension.

2. Layer reference equality split runs — entities from separate DXF
   ellipses had different Layer object instances for the same layer "0",
   splitting them into independent runs. Changed to compare Layer.Name.

3. Symmetrize replaced arcs with mirrored copies whose endpoints didn't
   match the target's original geometry, creating ~0.014 gaps. Now only
   applies mirrored arcs when endpoints are within tolerance of the
   target's boundary points.

Also: default tolerance 0.02 -> 0.004, Export DXF button in
CadConverterForm for debugging simplified geometry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 13:49:27 -04:00
parent 356b989424
commit e27def388f
4 changed files with 583 additions and 295 deletions

View File

@@ -15,11 +15,46 @@ public class ArcCandidate
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.5;
public double Tolerance { get; set; } = 0.004;
public int MinLines { get; set; } = 3;
public List<ArcCandidate> 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<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
/// <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 j = runStart;
var midpoints = new List<Vector>();
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<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>
/// 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.
/// </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)
{
@@ -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));
}
/// <summary>
/// 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.
/// </summary>
private static double EvalDeviation(List<Vector> points,
double mx, double my, double nx, double ny, double halfChord, double d)
private static double GoldenSectionMin(double low, double high, Func<double, double> 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<Vector> CollectPoints(List<Entity> 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<Vector> points)
/// <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 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<Vector> points)
/// <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 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;
}
/// <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 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));
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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<Shape> 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<Shape> shapes, EntityView view, double tolerance = 0.5)
public void LoadShapes(List<Shape> 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();