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();