Compare commits
10 Commits
0a294934ae
...
7b815c9579
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b815c9579 | |||
| 5568789902 | |||
| fd93cc9db2 | |||
| 740fd79adc | |||
| e1b6752ede | |||
| 18d9bbadfa | |||
| e27def388f | |||
| 356b989424 | |||
| c6652f7707 | |||
| df008081d1 |
@@ -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.005;
|
||||
public double Tolerance { get; set; } = 0.004;
|
||||
public int MinLines { get; set; } = 3;
|
||||
|
||||
public List<ArcCandidate> Analyze(Shape shape)
|
||||
@@ -30,21 +65,24 @@ public class GeometrySimplifier
|
||||
|
||||
while (i < entities.Count)
|
||||
{
|
||||
if (entities[i] is not Line firstLine)
|
||||
if (entities[i] is not Line and not Arc)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect consecutive lines on the same layer
|
||||
var runStart = i;
|
||||
var layer = firstLine.Layer;
|
||||
while (i < entities.Count && entities[i] is Line line && line.Layer == 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?.Name == layerName)
|
||||
{
|
||||
if (entities[i] is Line) lineCount++;
|
||||
i++;
|
||||
}
|
||||
var runEnd = i - 1;
|
||||
|
||||
// Try to find arc candidates within this run
|
||||
FindCandidatesInRun(entities, runStart, runEnd, candidates);
|
||||
if (lineCount >= MinLines)
|
||||
FindCandidatesInRun(entities, runStart, runEnd, candidates);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
@@ -62,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]);
|
||||
@@ -88,113 +121,483 @@ 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)
|
||||
{
|
||||
// Start with MinLines lines
|
||||
var k = j + MinLines - 1;
|
||||
var points = CollectPoints(entities, j, k);
|
||||
var (center, radius) = FitCircle(points);
|
||||
var dx = p.X - cx;
|
||||
var dy = p.Y - cy;
|
||||
cxx += dx * dx;
|
||||
cxy += dx * dy;
|
||||
cyy += dy * dy;
|
||||
}
|
||||
|
||||
if (!center.IsValid() || MaxDeviation(points, center, radius) > Tolerance)
|
||||
// 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)
|
||||
{
|
||||
j++;
|
||||
matched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extend as far as possible
|
||||
var prevCenter = center;
|
||||
var prevRadius = radius;
|
||||
var prevMaxDev = MaxDeviation(points, center, radius);
|
||||
// Reflect across axis and look for partner
|
||||
var mx = p.X - 2 * perpX;
|
||||
var my = p.Y - 2 * perpY;
|
||||
|
||||
while (k + 1 <= runEnd)
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
k++;
|
||||
points = CollectPoints(entities, j, k);
|
||||
var (newCenter, newRadius) = FitCircle(points);
|
||||
if (!newCenter.IsValid())
|
||||
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)
|
||||
{
|
||||
k--;
|
||||
matched++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var newMaxDev = MaxDeviation(points, newCenter, newRadius);
|
||||
if (newMaxDev > Tolerance)
|
||||
return (double)matched / points.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pairs candidates across a mirror axis and forces each pair to use
|
||||
/// the same arc (mirrored). The candidate with more lines or lower
|
||||
/// deviation is kept as the source.
|
||||
/// </summary>
|
||||
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
|
||||
{
|
||||
if (!axis.IsValid || candidates.Count < 2) return;
|
||||
|
||||
var paired = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (paired.Contains(i)) continue;
|
||||
|
||||
var ci = candidates[i];
|
||||
var ciCenter = ci.BoundingBox.Center;
|
||||
|
||||
// Distance from candidate center to axis
|
||||
var dx = ciCenter.X - axis.Point.X;
|
||||
var dy = ciCenter.Y - axis.Point.Y;
|
||||
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
|
||||
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
|
||||
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
|
||||
if (perpDist < 0.1) continue; // on the axis
|
||||
|
||||
var mirrorCenter = axis.Reflect(ciCenter);
|
||||
|
||||
var bestJ = -1;
|
||||
var bestDist = double.MaxValue;
|
||||
for (var j = i + 1; j < candidates.Count; j++)
|
||||
{
|
||||
if (paired.Contains(j)) continue;
|
||||
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
|
||||
if (d < bestDist)
|
||||
{
|
||||
k--;
|
||||
break;
|
||||
bestDist = d;
|
||||
bestJ = j;
|
||||
}
|
||||
|
||||
prevCenter = newCenter;
|
||||
prevRadius = newRadius;
|
||||
prevMaxDev = newMaxDev;
|
||||
}
|
||||
|
||||
// Build the candidate
|
||||
var finalPoints = CollectPoints(entities, j, k);
|
||||
var arc = BuildArc(prevCenter, prevRadius, finalPoints, entities[j]);
|
||||
var bbox = ComputeBoundingBox(finalPoints);
|
||||
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 = k,
|
||||
FittedArc = arc,
|
||||
MaxDeviation = prevMaxDev,
|
||||
BoundingBox = bbox,
|
||||
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 = k + 1;
|
||||
j = result.EndIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
|
||||
|
||||
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
|
||||
{
|
||||
var k = start + MinLines - 1;
|
||||
if (k > runEnd) return null;
|
||||
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var (center, radius, dev) = TryFit(points, startTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
points = extPoints;
|
||||
}
|
||||
|
||||
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
|
||||
// that happen to fit a huge circle. Applied after extension so that many small
|
||||
// segments can accumulate enough sweep to qualify.
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, points));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
|
||||
{
|
||||
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given direction at the
|
||||
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
|
||||
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
|
||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var bx = -dy / chordLen;
|
||||
var by = dx / chordLen;
|
||||
|
||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
if (tLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var nx = -tangent.Y / tLen;
|
||||
var ny = tangent.X / tLen;
|
||||
|
||||
var det = nx * by - ny * bx;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||
|
||||
var cx = p1.X + t * nx;
|
||||
var cy = p1.Y + t * ny;
|
||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
/// </summary>
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
if (totalAngle >= 0)
|
||||
return new Vector(-ry, rx);
|
||||
else
|
||||
return new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc using the mirror axis approach. The center is constrained
|
||||
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
|
||||
/// passes exactly through both endpoints. Golden section search optimizes position.
|
||||
/// </summary>
|
||||
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var nx = -dy / chordLen;
|
||||
var ny = dx / chordLen;
|
||||
|
||||
var maxSagitta = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
|
||||
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
|
||||
maxSagitta = proj;
|
||||
}
|
||||
if (System.Math.Abs(maxSagitta) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
|
||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||
|
||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||
|
||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
||||
{
|
||||
var phi = (System.Math.Sqrt(5) - 1) / 2;
|
||||
for (var iter = 0; iter < 30; iter++)
|
||||
{
|
||||
var d1 = high - phi * (high - low);
|
||||
var d2 = low + phi * (high - low);
|
||||
if (eval(d1) < eval(d2))
|
||||
high = d2;
|
||||
else
|
||||
low = d1;
|
||||
if (high - low < 1e-6)
|
||||
break;
|
||||
}
|
||||
return (low + high) / 2;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
points.Add(((Line)entities[start]).StartPoint);
|
||||
|
||||
for (var i = start; i <= end; i++)
|
||||
points.Add(((Line)entities[i]).EndPoint);
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Line line:
|
||||
if (i == start)
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
if (i == start)
|
||||
points.Add(arc.StartPoint());
|
||||
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
|
||||
var arcPoints = arc.ToPoints(segments);
|
||||
for (var j = 1; j < arcPoints.Count; j++)
|
||||
points.Add(arcPoints[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static double MaxDeviation(List<Vector> points, Vector center, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
var dev = System.Math.Abs(points[i].DistanceTo(center) - radius);
|
||||
if (dev > maxDev)
|
||||
maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static Arc BuildArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -204,81 +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;
|
||||
}
|
||||
|
||||
// Solve: [sumX2 sumXY sumX] [A] [sumXZ]
|
||||
// [sumXY sumY2 sumY] [B] = [sumYZ]
|
||||
// [sumX sumY n ] [C] [sumZ ]
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -1,6 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -173,7 +174,53 @@ namespace OpenNest
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
if (!perimeter1.Intersects(perimeter2, out var rawPts))
|
||||
return false;
|
||||
|
||||
// Exclude intersection points that coincide with vertices of BOTH
|
||||
// perimeters — these are touch points (shared corners/endpoints),
|
||||
// not actual crossings where one shape enters the other's interior.
|
||||
var verts1 = CollectVertices(perimeter1);
|
||||
var verts2 = CollectVertices(perimeter2);
|
||||
|
||||
foreach (var pt in rawPts)
|
||||
{
|
||||
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
|
||||
continue;
|
||||
pts.Add(pt);
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectVertices(Geometry.Shape shape)
|
||||
{
|
||||
var verts = new List<Vector>();
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Geometry.Line line:
|
||||
verts.Add(line.StartPoint);
|
||||
verts.Add(line.EndPoint);
|
||||
break;
|
||||
case Geometry.Arc arc:
|
||||
verts.Add(arc.StartPoint());
|
||||
verts.Add(arc.EndPoint());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return verts;
|
||||
}
|
||||
|
||||
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
|
||||
{
|
||||
foreach (var v in vertices)
|
||||
{
|
||||
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public double Left
|
||||
|
||||
@@ -601,10 +601,24 @@ namespace OpenNest
|
||||
for (var i = 0; i < realParts.Count; i++)
|
||||
{
|
||||
var part1 = realParts[i];
|
||||
var b1 = part1.BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < realParts.Count; j++)
|
||||
{
|
||||
var part2 = realParts[j];
|
||||
var b2 = part2.BoundingBox;
|
||||
|
||||
// Skip pairs whose bounding boxes don't meaningfully overlap.
|
||||
// Floating-point rounding can produce sub-epsilon overlaps for
|
||||
// parts that are merely edge-touching, so require the overlap
|
||||
// region to exceed Epsilon in both dimensions.
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
|
||||
@@ -47,13 +47,21 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
// The iterative pair adjustment can shift parts enough to cause
|
||||
// genuine overlap. Fall back to the unadjusted column when this happens.
|
||||
if (HasOverlappingParts(adjusted))
|
||||
{
|
||||
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
||||
Description = $"Extents: column {adjusted.Count} parts",
|
||||
});
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
@@ -386,5 +394,31 @@ namespace OpenNest.Engine.Fill
|
||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
||||
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,15 @@ public class StripeFiller
|
||||
if (gridParts.Count == 0)
|
||||
return null;
|
||||
|
||||
// Reject results where bounding boxes overlap — the angle convergence
|
||||
// can produce slightly off-axis rotations where FillLinear's copy
|
||||
// distance calculation doesn't fully account for the rotated geometry.
|
||||
if (HasOverlappingParts(gridParts))
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
|
||||
return null;
|
||||
}
|
||||
|
||||
var allParts = new List<Part>(gridParts);
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||
@@ -470,4 +479,34 @@ public class StripeFiller
|
||||
{
|
||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any pair of parts geometrically overlap. Uses bounding box
|
||||
/// pre-filtering for performance, then falls back to shape intersection.
|
||||
/// </summary>
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ namespace OpenNest.Engine.Strategies
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= FilterStrategies();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all registered strategies regardless of enabled/disabled state.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<IFillStrategy> AllStrategies =>
|
||||
strategies.OrderBy(s => s.Order).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all permanently disabled strategies.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DisabledNames => disabled;
|
||||
|
||||
private static List<IFillStrategy> FilterStrategies()
|
||||
{
|
||||
var source = enabledFilter != null
|
||||
|
||||
@@ -63,9 +63,72 @@ namespace OpenNest.IO.Bending
|
||||
bends.Add(bend);
|
||||
}
|
||||
|
||||
PropagateCollinearBendNotes(bends);
|
||||
|
||||
return bends;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For bends without a note (e.g. split by a cutout), copy angle/radius/direction
|
||||
/// from a collinear bend that does have a note.
|
||||
/// </summary>
|
||||
private static void PropagateCollinearBendNotes(List<Bend> bends)
|
||||
{
|
||||
const double angleTolerance = 0.01; // radians
|
||||
const double distanceTolerance = 0.01;
|
||||
|
||||
foreach (var bend in bends)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
continue;
|
||||
|
||||
foreach (var other in bends)
|
||||
{
|
||||
if (string.IsNullOrEmpty(other.NoteText))
|
||||
continue;
|
||||
|
||||
if (!AreCollinear(bend, other, angleTolerance, distanceTolerance))
|
||||
continue;
|
||||
|
||||
bend.Direction = other.Direction;
|
||||
bend.Angle = other.Angle;
|
||||
bend.Radius = other.Radius;
|
||||
bend.NoteText = other.NoteText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreCollinear(Bend a, Bend b, double angleTolerance, double distanceTolerance)
|
||||
{
|
||||
var angleA = a.StartPoint.AngleTo(a.EndPoint);
|
||||
var angleB = b.StartPoint.AngleTo(b.EndPoint);
|
||||
|
||||
// Normalize angle difference to [0, PI) since opposite directions are still collinear
|
||||
var diff = System.Math.Abs(angleA - angleB) % System.Math.PI;
|
||||
if (diff > angleTolerance && System.Math.PI - diff > angleTolerance)
|
||||
return false;
|
||||
|
||||
// Perpendicular distance from midpoint of A to the infinite line through B
|
||||
var midA = new Vector(
|
||||
(a.StartPoint.X + a.EndPoint.X) / 2.0,
|
||||
(a.StartPoint.Y + a.EndPoint.Y) / 2.0);
|
||||
|
||||
var dx = b.EndPoint.X - b.StartPoint.X;
|
||||
var dy = b.EndPoint.Y - b.StartPoint.Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (len < 1e-9)
|
||||
return false;
|
||||
|
||||
// 2D cross product gives signed perpendicular distance * length
|
||||
var vx = midA.X - b.StartPoint.X;
|
||||
var vy = midA.Y - b.StartPoint.Y;
|
||||
var perp = System.Math.Abs(vx * dy - vy * dx) / len;
|
||||
|
||||
return perp <= distanceTolerance;
|
||||
}
|
||||
|
||||
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
|
||||
{
|
||||
return document.Entities
|
||||
|
||||
@@ -221,8 +221,9 @@ namespace OpenNest.IO
|
||||
});
|
||||
}
|
||||
|
||||
// Close the ellipse if it's a full ellipse
|
||||
if (lines.Count >= 2)
|
||||
// Close only if it's a full ellipse (sweep ≈ 2π)
|
||||
var sweep = endParam - startParam;
|
||||
if (lines.Count >= 2 && System.Math.Abs(sweep - System.Math.PI * 2.0) < 0.01)
|
||||
{
|
||||
var first = lines.First();
|
||||
var last = lines.Last();
|
||||
|
||||
@@ -29,6 +29,84 @@ public class SolidWorksBendDetectorTests
|
||||
Assert.Empty(bends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simplifier_EllipseSegments_FewLargeArcs()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11 Test.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
|
||||
var result = importer.Import(path);
|
||||
|
||||
var shape = new OpenNest.Geometry.Shape();
|
||||
shape.Entities.AddRange(result.Entities);
|
||||
|
||||
// Default tolerance is 0.5 — should produce very few large arcs
|
||||
var simplifier = new OpenNest.Geometry.GeometrySimplifier();
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
// With 0.5 tolerance, 2 ellipses (~400 segments) should reduce to a handful of arcs
|
||||
// Dump for visibility then assert
|
||||
var info = string.Join(", ", candidates.Select(c => $"[{c.StartIndex}..{c.EndIndex}]={c.LineCount}lines R={c.FittedArc.Radius:F3}"));
|
||||
Assert.True(candidates.Count <= 10,
|
||||
$"Expected <=10 arcs but got {candidates.Count}: {info}");
|
||||
|
||||
// Each arc should cover many lines
|
||||
foreach (var c in candidates)
|
||||
Assert.True(c.LineCount >= 3, $"Arc [{c.StartIndex}..{c.EndIndex}] only covers {c.LineCount} lines");
|
||||
|
||||
// Arcs should connect to the original geometry within tolerance
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
var firstLine = (OpenNest.Geometry.Line)shape.Entities[c.StartIndex];
|
||||
var lastLine = (OpenNest.Geometry.Line)shape.Entities[c.EndIndex];
|
||||
var arc = c.FittedArc;
|
||||
|
||||
var startGap = firstLine.StartPoint.DistanceTo(arc.StartPoint());
|
||||
var endGap = lastLine.EndPoint.DistanceTo(arc.EndPoint());
|
||||
|
||||
Assert.True(startGap < 1e-9, $"Start gap {startGap} at candidate [{c.StartIndex}..{c.EndIndex}]");
|
||||
Assert.True(endGap < 1e-9, $"End gap {endGap} at candidate [{c.StartIndex}..{c.EndIndex}]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_TrimmedEllipse_NoClosingChord()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter();
|
||||
var result = importer.Import(path);
|
||||
|
||||
// The DXF has 2 trimmed ellipses forming an oblong slot.
|
||||
// Trimmed ellipses must not generate a closing chord line.
|
||||
// 83 = 72 lines + 4 arcs + 7 circles + ellipse segments (heavily merged by optimizer)
|
||||
Assert.Equal(83, result.Entities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectBends_SplitBendLine_PropagatesNote()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT23.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
|
||||
var detector = new SolidWorksBendDetector();
|
||||
var bends = detector.DetectBends(doc);
|
||||
|
||||
Assert.Equal(5, bends.Count);
|
||||
Assert.All(bends, b =>
|
||||
{
|
||||
Assert.NotNull(b.NoteText);
|
||||
Assert.Equal(BendDirection.Up, b.Direction);
|
||||
Assert.Equal(90.0, b.Angle);
|
||||
Assert.Equal(0.125, b.Radius);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectBends_RealDxf_ParsesNotesCorrectly()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class EngineOverlapTests
|
||||
{
|
||||
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public EngineOverlapTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static Drawing ImportDxf()
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
importer.GetGeometry(DxfPath, out var geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT15", pgm);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Default")]
|
||||
[InlineData("Strip")]
|
||||
[InlineData("Vertical Remnant")]
|
||||
[InlineData("Horizontal Remnant")]
|
||||
public void FillPlate_NoOverlaps(string engineName)
|
||||
{
|
||||
var drawing = ImportDxf();
|
||||
var plate = new Plate(60, 120);
|
||||
|
||||
NestEngineRegistry.ActiveEngineName = engineName;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var success = engine.Fill(item);
|
||||
|
||||
_output.WriteLine($"Engine: {engine.Name}, Parts: {plate.Parts.Count}, Utilization: {plate.Utilization():P1}");
|
||||
|
||||
if (engine is DefaultNestEngine defaultEngine)
|
||||
{
|
||||
_output.WriteLine($"Winner phase: {defaultEngine.WinnerPhase}");
|
||||
foreach (var pr in defaultEngine.PhaseResults)
|
||||
_output.WriteLine($" Phase {pr.Phase}: {pr.PartCount} parts in {pr.TimeMs}ms");
|
||||
}
|
||||
|
||||
// Show rotation distribution
|
||||
var rotGroups = plate.Parts
|
||||
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
|
||||
.OrderBy(g => g.Key);
|
||||
foreach (var g in rotGroups)
|
||||
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var collisionPoints);
|
||||
_output.WriteLine($"Overlaps: {hasOverlaps} ({collisionPoints.Count} collision pts)");
|
||||
|
||||
if (hasOverlaps)
|
||||
{
|
||||
for (var i = 0; i < System.Math.Min(collisionPoints.Count, 10); i++)
|
||||
_output.WriteLine($" ({collisionPoints[i].X:F2}, {collisionPoints[i].Y:F2})");
|
||||
}
|
||||
|
||||
Assert.False(hasOverlaps,
|
||||
$"Engine '{engineName}' produced {collisionPoints.Count} collision point(s) with {plate.Parts.Count} parts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentParts_ShouldNotOverlap()
|
||||
{
|
||||
var plate = TestHelpers.MakePlate(60, 120,
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(10, 0, 10));
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var pts);
|
||||
_output.WriteLine($"Adjacent squares: overlaps={hasOverlaps}, collision count={pts.Count}");
|
||||
|
||||
Assert.False(hasOverlaps, "Adjacent edge-touching parts should not be reported as overlapping");
|
||||
}
|
||||
}
|
||||
@@ -5,42 +5,6 @@ namespace OpenNest.Tests;
|
||||
|
||||
public class GeometrySimplifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void FitCircle_PointsOnKnownCircle_ReturnsCorrectCenterAndRadius()
|
||||
{
|
||||
// 21 points on a semicircle centered at (5, 3) with radius 10
|
||||
var center = new Vector(5, 3);
|
||||
var radius = 10.0;
|
||||
var points = new List<Vector>();
|
||||
for (var i = 0; i <= 20; i++)
|
||||
{
|
||||
var angle = i * System.Math.PI / 20;
|
||||
points.Add(new Vector(
|
||||
center.X + radius * System.Math.Cos(angle),
|
||||
center.Y + radius * System.Math.Sin(angle)));
|
||||
}
|
||||
|
||||
var (fitCenter, fitRadius) = GeometrySimplifier.FitCircle(points);
|
||||
|
||||
Assert.InRange(fitCenter.X, 4.999, 5.001);
|
||||
Assert.InRange(fitCenter.Y, 2.999, 3.001);
|
||||
Assert.InRange(fitRadius, 9.999, 10.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FitCircle_CollinearPoints_ReturnsInvalidCenter()
|
||||
{
|
||||
// Collinear points should produce degenerate result
|
||||
var points = new List<Vector>
|
||||
{
|
||||
new(0, 0), new(1, 0), new(2, 0), new(3, 0), new(4, 0)
|
||||
};
|
||||
|
||||
var (fitCenter, _) = GeometrySimplifier.FitCircle(points);
|
||||
|
||||
Assert.False(fitCenter.IsValid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_LinesFromSemicircle_FindsOneCandidate()
|
||||
{
|
||||
@@ -77,9 +41,10 @@ public class GeometrySimplifierTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_MixedEntitiesWithArc_OnlyAnalyzesLines()
|
||||
public void Analyze_MixedEntitiesWithArc_FindsSeparateCandidates()
|
||||
{
|
||||
// Line, Line, Line, Arc, Line, Line, Line — should find candidates only in line runs
|
||||
// Lines on one curve, then an arc at a different center, then lines on another curve
|
||||
// The arc is included in the run but can't merge with lines on different curves
|
||||
var shape = new Shape();
|
||||
// First run: 5 lines on a curve
|
||||
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
|
||||
|
||||
@@ -134,7 +134,7 @@ public class StripeFillerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ProducesPartsForSimpleDrawing()
|
||||
public void Fill_ProducesNonOverlappingPartsForSimpleDrawing()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
@@ -158,11 +158,19 @@ public class StripeFillerTests
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
|
||||
// StripeFiller may return empty if the converged angle produces
|
||||
// overlapping parts that fail the overlap validation check.
|
||||
// The important thing is that any returned parts are overlap-free.
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(parts);
|
||||
var hasOverlaps = plate.HasOverlappingParts(out _);
|
||||
Assert.False(hasOverlaps, "Stripe fill should not produce overlapping parts");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_VerticalProducesParts()
|
||||
public void Fill_VerticalProducesNonOverlappingParts()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
@@ -186,7 +194,12 @@ public class StripeFillerTests
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from column fill");
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(parts);
|
||||
var hasOverlaps = plate.HasOverlappingParts(out _);
|
||||
Assert.False(hasOverlaps, "Column fill should not produce overlapping parts");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class StrategyOverlapTests
|
||||
{
|
||||
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public StrategyOverlapTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static Drawing ImportDxf()
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
importer.GetGeometry(DxfPath, out var geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT15", pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachStrategy_CheckOverlaps()
|
||||
{
|
||||
var drawing = ImportDxf();
|
||||
_output.WriteLine($"Drawing bbox: {drawing.Program.BoundingBox().Width:F2} x {drawing.Program.BoundingBox().Length:F2}");
|
||||
|
||||
var strategies = FillStrategyRegistry.Strategies.ToList();
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var comparer = new DefaultFillComparer();
|
||||
var policy = new FillPolicy(comparer);
|
||||
var context = new FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = plate.WorkArea(),
|
||||
Plate = plate,
|
||||
PlateNumber = 0,
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Policy = policy,
|
||||
};
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
||||
item, bestRotation, context.WorkArea);
|
||||
|
||||
var parts = strategy.Fill(context);
|
||||
var count = parts?.Count ?? 0;
|
||||
|
||||
_output.WriteLine($"\n{strategy.GetType().Name} (Phase: {strategy.Phase}, Order: {strategy.Order}): {count} parts");
|
||||
|
||||
if (count == 0)
|
||||
continue;
|
||||
|
||||
plate.Parts.AddRange(parts);
|
||||
_output.WriteLine($" Utilization: {plate.Utilization():P1}");
|
||||
|
||||
var rotGroups = parts
|
||||
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
|
||||
.OrderBy(g => g.Key);
|
||||
foreach (var g in rotGroups)
|
||||
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
|
||||
|
||||
var hasOverlaps = plate.HasOverlappingParts(out var pts);
|
||||
_output.WriteLine($" Overlaps: {hasOverlaps} ({pts.Count} collision pts)");
|
||||
|
||||
if (hasOverlaps)
|
||||
{
|
||||
failures.Add($"{strategy.GetType().Name} ({strategy.Phase}): {pts.Count} collision pts, {count} parts");
|
||||
|
||||
// Show overlapping pair details
|
||||
for (var a = 0; a < parts.Count; a++)
|
||||
{
|
||||
for (var b = a + 1; b < parts.Count; b++)
|
||||
{
|
||||
var ba = parts[a].BoundingBox;
|
||||
var bb = parts[b].BoundingBox;
|
||||
var oX = System.Math.Min(ba.Right, bb.Right) - System.Math.Max(ba.Left, bb.Left);
|
||||
var oY = System.Math.Min(ba.Top, bb.Top) - System.Math.Max(ba.Bottom, bb.Bottom);
|
||||
if (oX <= OpenNest.Math.Tolerance.Epsilon || oY <= OpenNest.Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[a].Intersects(parts[b], out var pairPts) && pairPts.Count > 0)
|
||||
{
|
||||
_output.WriteLine($" [{a}] vs [{b}]: {pairPts.Count} pts, bbox overlap: {oX:F4} x {oY:F4}");
|
||||
_output.WriteLine($" [{a}]: loc=({parts[a].Location.X:F4},{parts[a].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[a].Rotation):F2}°");
|
||||
_output.WriteLine($" [{b}]: loc=({parts[b].Location.X:F4},{parts[b].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[b].Rotation):F2}°");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"\n=== SUMMARY ===");
|
||||
foreach (var f in failures)
|
||||
_output.WriteLine($" OVERLAP: {f}");
|
||||
|
||||
Assert.Empty(failures);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ namespace OpenNest.Controls
|
||||
}
|
||||
|
||||
public Arc SimplifierPreview { get; set; }
|
||||
public List<Entity> SimplifierToleranceLeft { get; set; }
|
||||
public List<Entity> SimplifierToleranceRight { get; set; }
|
||||
public List<Entity> OriginalEntities { get; set; }
|
||||
|
||||
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||
@@ -79,6 +82,17 @@ namespace OpenNest.Controls
|
||||
|
||||
e.Graphics.TranslateTransform(origin.X, origin.Y);
|
||||
|
||||
// Draw original geometry overlay (faded, behind current)
|
||||
if (OriginalEntities != null)
|
||||
{
|
||||
using var origPen = new Pen(Color.FromArgb(50, 255, 140, 40));
|
||||
foreach (var entity in OriginalEntities)
|
||||
{
|
||||
if (!IsEtchLayer(entity.Layer))
|
||||
DrawEntity(e.Graphics, entity, origPen);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in Entities)
|
||||
{
|
||||
if (IsEtchLayer(entity.Layer)) continue;
|
||||
@@ -102,6 +116,25 @@ namespace OpenNest.Controls
|
||||
|
||||
if (SimplifierPreview != null)
|
||||
{
|
||||
// Draw tolerance zone (offset lines each side of original geometry)
|
||||
if (SimplifierToleranceLeft != null)
|
||||
{
|
||||
using var zonePen = new Pen(Color.FromArgb(40, 100, 200, 100));
|
||||
foreach (var entity in SimplifierToleranceLeft)
|
||||
DrawEntity(e.Graphics, entity, zonePen);
|
||||
foreach (var entity in SimplifierToleranceRight)
|
||||
DrawEntity(e.Graphics, entity, zonePen);
|
||||
}
|
||||
|
||||
// Draw old geometry (highlighted lines) in orange dashed
|
||||
if (simplifierHighlightSet != null)
|
||||
{
|
||||
using var oldPen = new Pen(Color.FromArgb(180, 255, 160, 50), 1f / ViewScale) { DashPattern = new float[] { 6, 3 } };
|
||||
foreach (var entity in simplifierHighlightSet)
|
||||
DrawEntity(e.Graphics, entity, oldPen);
|
||||
}
|
||||
|
||||
// Draw the new arc in bright green
|
||||
using var previewPen = new Pen(Color.FromArgb(0, 200, 80), 2f / ViewScale);
|
||||
DrawArc(e.Graphics, SimplifierPreview, previewPen);
|
||||
}
|
||||
@@ -260,20 +293,26 @@ namespace OpenNest.Controls
|
||||
{
|
||||
DashPattern = new float[] { 6, 4 }
|
||||
};
|
||||
using var noteFont = new Font("Segoe UI", 9f);
|
||||
using var noteBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
||||
using var selectedNoteBrush = new SolidBrush(Color.FromArgb(220, 255, 180, 100));
|
||||
|
||||
for (var i = 0; i < Bends.Count; i++)
|
||||
{
|
||||
var bend = Bends[i];
|
||||
var pt1 = PointWorldToGraph(bend.StartPoint);
|
||||
var pt2 = PointWorldToGraph(bend.EndPoint);
|
||||
var isSelected = i == SelectedBendIndex;
|
||||
|
||||
if (i == SelectedBendIndex)
|
||||
{
|
||||
if (isSelected)
|
||||
g.DrawLine(glowPen, pt1, pt2);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.DrawLine(bendPen, pt1, pt2);
|
||||
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
{
|
||||
var mid = new PointF((pt1.X + pt2.X) / 2f, (pt1.Y + pt2.Y) / 2f);
|
||||
g.DrawString(bend.NoteText, noteFont, isSelected ? selectedNoteBrush : noteBrush, mid.X + 4, mid.Y + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +463,8 @@ namespace OpenNest.Controls
|
||||
{
|
||||
SimplifierHighlight = null;
|
||||
SimplifierPreview = null;
|
||||
SimplifierToleranceLeft = null;
|
||||
SimplifierToleranceRight = null;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace OpenNest.Controls
|
||||
public int Quantity { get; set; } = 1;
|
||||
public string Path { get; set; }
|
||||
public List<Entity> Entities { get; set; } = new();
|
||||
public List<Entity> OriginalEntities { get; set; }
|
||||
public List<Bend> Bends { get; set; } = new();
|
||||
public Box Bounds { get; set; }
|
||||
public int EntityCount { get; set; }
|
||||
|
||||
+23
@@ -29,6 +29,8 @@ 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();
|
||||
bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
@@ -129,6 +131,8 @@ 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);
|
||||
detailBar.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
@@ -225,6 +229,23 @@ 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;
|
||||
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
chkShowOriginal.Text = "Original";
|
||||
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
||||
chkShowOriginal.CheckedChanged += new System.EventHandler(this.OnShowOriginalChanged);
|
||||
//
|
||||
// lblDetect
|
||||
//
|
||||
lblDetect.AutoSize = true;
|
||||
@@ -324,6 +345,8 @@ 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;
|
||||
private System.Windows.Forms.Label lblCust;
|
||||
|
||||
@@ -141,6 +141,7 @@ namespace OpenNest.Forms
|
||||
entityView1.IsPickingBendLine = false;
|
||||
filterPanel.SetPickMode(false);
|
||||
}
|
||||
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
|
||||
entityView1.Entities.Clear();
|
||||
entityView1.Entities.AddRange(item.Entities);
|
||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||
@@ -162,6 +163,50 @@ namespace OpenNest.Forms
|
||||
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||
|
||||
entityView1.ZoomToFit();
|
||||
CheckSimplifiable(item);
|
||||
}
|
||||
|
||||
private void CheckSimplifiable(FileListItem item)
|
||||
{
|
||||
ResetSimplifyButton();
|
||||
|
||||
// Only check original (unsimplified) entities
|
||||
var entities = item.OriginalEntities ?? item.Entities;
|
||||
if (entities == null || entities.Count < 10) return;
|
||||
|
||||
// Quick line count check — need at least MinLines consecutive lines
|
||||
var lineCount = entities.Count(e => e is Geometry.Line);
|
||||
if (lineCount < 3) return;
|
||||
|
||||
// Run a quick analysis on a background thread
|
||||
var capturedEntities = new List<Entity>(entities);
|
||||
Task.Run(() =>
|
||||
{
|
||||
var shapes = ShapeBuilder.GetShapes(capturedEntities);
|
||||
var simplifier = new GeometrySimplifier();
|
||||
var count = 0;
|
||||
foreach (var shape in shapes)
|
||||
count += simplifier.Analyze(shape).Count;
|
||||
return count;
|
||||
}).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully && t.Result > 0)
|
||||
HighlightSimplifyButton(t.Result);
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
private void HighlightSimplifyButton(int candidateCount)
|
||||
{
|
||||
btnSimplify.Text = $"Simplify ({candidateCount})";
|
||||
btnSimplify.BackColor = Color.FromArgb(60, 120, 60);
|
||||
btnSimplify.ForeColor = Color.White;
|
||||
}
|
||||
|
||||
private void ResetSimplifyButton()
|
||||
{
|
||||
btnSimplify.Text = "Simplify...";
|
||||
btnSimplify.BackColor = SystemColors.Control;
|
||||
btnSimplify.ForeColor = SystemColors.ControlText;
|
||||
}
|
||||
|
||||
private void ClearDetailBar()
|
||||
@@ -384,7 +429,13 @@ namespace OpenNest.Forms
|
||||
if (entityView1.Entities == null || entityView1.Entities.Count == 0)
|
||||
return;
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(entityView1.Entities);
|
||||
// Always simplify from original geometry to prevent tolerance creep
|
||||
var item = CurrentItem;
|
||||
if (item != null && item.OriginalEntities == null)
|
||||
item.OriginalEntities = new List<Entity>(item.Entities);
|
||||
|
||||
var sourceEntities = item?.OriginalEntities ?? entityView1.Entities;
|
||||
var shapes = ShapeBuilder.GetShapes(sourceEntities);
|
||||
if (shapes.Count == 0)
|
||||
return;
|
||||
|
||||
@@ -411,8 +462,77 @@ namespace OpenNest.Forms
|
||||
entityView1.ZoomToFit();
|
||||
entityView1.Invalidate();
|
||||
|
||||
// Update entity count label
|
||||
var item = CurrentItem;
|
||||
if (item != null)
|
||||
{
|
||||
item.Entities = entities;
|
||||
item.EntityCount = entities.Count;
|
||||
item.Bounds = entities.GetBoundingBox();
|
||||
}
|
||||
|
||||
lblEntityCount.Text = $"{entities.Count} entities";
|
||||
ResetSimplifyButton();
|
||||
}
|
||||
|
||||
private void OnShowOriginalChanged(object sender, EventArgs e)
|
||||
{
|
||||
var item = CurrentItem;
|
||||
entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OriginalEntities : null;
|
||||
entityView1.Invalidate();
|
||||
}
|
||||
|
||||
private void OnExportDxfClick(object sender, EventArgs e)
|
||||
{
|
||||
var item = CurrentItem;
|
||||
if (item == null) return;
|
||||
|
||||
using var dlg = new SaveFileDialog
|
||||
{
|
||||
Filter = "DXF Files|*.dxf",
|
||||
FileName = Path.ChangeExtension(item.Name, ".dxf"),
|
||||
};
|
||||
|
||||
if (dlg.ShowDialog() != DialogResult.OK) return;
|
||||
|
||||
var doc = new ACadSharp.CadDocument();
|
||||
foreach (var entity in item.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Geometry.Line line:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new CSMath.XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
|
||||
EndPoint = new CSMath.XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
|
||||
});
|
||||
break;
|
||||
|
||||
case Geometry.Arc arc:
|
||||
var startAngle = arc.StartAngle;
|
||||
var endAngle = arc.EndAngle;
|
||||
if (arc.IsReversed)
|
||||
OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
|
||||
doc.Entities.Add(new ACadSharp.Entities.Arc
|
||||
{
|
||||
Center = new CSMath.XYZ(arc.Center.X, arc.Center.Y, 0),
|
||||
Radius = arc.Radius,
|
||||
StartAngle = startAngle,
|
||||
EndAngle = endAngle,
|
||||
});
|
||||
break;
|
||||
|
||||
case Geometry.Circle circle:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Circle
|
||||
{
|
||||
Center = new CSMath.XYZ(circle.Center.X, circle.Center.Y, 0),
|
||||
Radius = circle.Radius,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
using var writer = new ACadSharp.IO.DxfWriter(dlg.FileName, doc, false);
|
||||
writer.Write();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -58,6 +58,8 @@ namespace OpenNest.Forms
|
||||
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
|
||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
OptionsForm.ApplyDisabledStrategies();
|
||||
|
||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||
engineComboBox.Items.Add(engine.Name);
|
||||
|
||||
@@ -79,7 +81,7 @@ namespace OpenNest.Forms
|
||||
private string GetNestName(DateTime date, int id)
|
||||
{
|
||||
var year = (date.Year % 100).ToString("D2");
|
||||
var seq = ToBase36(id).PadLeft(3, '0');
|
||||
var seq = ToBase36(id).PadLeft(3, '2');
|
||||
|
||||
return $"N{year}-{seq}";
|
||||
}
|
||||
@@ -87,13 +89,13 @@ namespace OpenNest.Forms
|
||||
private static string ToBase36(int value)
|
||||
{
|
||||
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
|
||||
if (value == 0) return "0";
|
||||
if (value == 0) return chars[0].ToString();
|
||||
|
||||
var result = "";
|
||||
while (value > 0)
|
||||
{
|
||||
result = chars[value % 36] + result;
|
||||
value /= 36;
|
||||
result = chars[value % chars.Length] + result;
|
||||
value /= chars.Length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Generated
+14
-2
@@ -42,6 +42,7 @@
|
||||
this.saveButton = new System.Windows.Forms.Button();
|
||||
this.cancelButton = new System.Windows.Forms.Button();
|
||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||
this.tableLayoutPanel1.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown2)).BeginInit();
|
||||
@@ -210,13 +211,23 @@
|
||||
this.bottomPanel1.Name = "bottomPanel1";
|
||||
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
||||
this.bottomPanel1.TabIndex = 1;
|
||||
//
|
||||
//
|
||||
// strategyGroupBox
|
||||
//
|
||||
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
|
||||
this.strategyGroupBox.Name = "strategyGroupBox";
|
||||
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
||||
this.strategyGroupBox.TabIndex = 2;
|
||||
this.strategyGroupBox.TabStop = false;
|
||||
this.strategyGroupBox.Text = "Fill Strategies";
|
||||
//
|
||||
// OptionsForm
|
||||
//
|
||||
//
|
||||
this.AcceptButton = this.saveButton;
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
this.CancelButton = this.cancelButton;
|
||||
this.ClientSize = new System.Drawing.Size(708, 418);
|
||||
this.Controls.Add(this.strategyGroupBox);
|
||||
this.Controls.Add(this.tableLayoutPanel1);
|
||||
this.Controls.Add(this.bottomPanel1);
|
||||
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
||||
@@ -252,5 +263,6 @@
|
||||
private System.Windows.Forms.TextBox textBox1;
|
||||
private System.Windows.Forms.Label label3;
|
||||
private System.Windows.Forms.Button button1;
|
||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,58 @@
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Properties;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
public partial class OptionsForm : Form
|
||||
{
|
||||
private readonly List<CheckBox> _strategyCheckBoxes = new();
|
||||
|
||||
public OptionsForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
BuildStrategyCheckBoxes();
|
||||
}
|
||||
|
||||
protected override void OnLoad(System.EventArgs e)
|
||||
protected override void OnLoad(EventArgs e)
|
||||
{
|
||||
base.OnLoad(e);
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void BuildStrategyCheckBoxes()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.AllStrategies;
|
||||
var y = 20;
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
{
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Text = strategy.Name,
|
||||
Tag = strategy.Name,
|
||||
AutoSize = true,
|
||||
Location = new System.Drawing.Point(10, y),
|
||||
};
|
||||
strategyGroupBox.Controls.Add(cb);
|
||||
_strategyCheckBoxes.Add(cb);
|
||||
y += 24;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
textBox1.Text = Settings.Default.NestTemplatePath;
|
||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||
numericUpDown2.Value = (decimal)Settings.Default.ImportSplinePrecision;
|
||||
|
||||
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
||||
foreach (var cb in _strategyCheckBoxes)
|
||||
cb.Checked = !disabledNames.Contains((string)cb.Tag);
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
@@ -30,15 +61,47 @@ namespace OpenNest.Forms
|
||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||
Settings.Default.ImportSplinePrecision = (int)numericUpDown2.Value;
|
||||
|
||||
var disabledNames = _strategyCheckBoxes
|
||||
.Where(cb => !cb.Checked)
|
||||
.Select(cb => (string)cb.Tag);
|
||||
Settings.Default.DisabledStrategies = string.Join(",", disabledNames);
|
||||
|
||||
Settings.Default.Save();
|
||||
ApplyDisabledStrategies();
|
||||
}
|
||||
|
||||
private void SaveSettings_Click(object sender, System.EventArgs e)
|
||||
/// <summary>
|
||||
/// Applies the DisabledStrategies setting to the FillStrategyRegistry.
|
||||
/// Called on save and at startup from MainForm.
|
||||
/// </summary>
|
||||
public static void ApplyDisabledStrategies()
|
||||
{
|
||||
// Re-enable all, then disable the persisted set.
|
||||
var all = FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray();
|
||||
FillStrategyRegistry.Enable(all);
|
||||
|
||||
var disabled = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
|
||||
if (disabled.Count > 0)
|
||||
FillStrategyRegistry.Disable(disabled.ToArray());
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseDisabledStrategies(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new HashSet<string>(
|
||||
value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SaveSettings_Click(object sender, EventArgs e)
|
||||
{
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void BrowseNestTemplatePath_Click(object sender, System.EventArgs e)
|
||||
private void BrowseNestTemplatePath_Click(object sender, EventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog();
|
||||
dlg.Filter = "Template File|*.nstdot";
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class SimplifierViewerForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.listView = new System.Windows.Forms.ListView();
|
||||
this.columnLines = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnRadius = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnDeviation = new System.Windows.Forms.ColumnHeader();
|
||||
this.columnLocation = new System.Windows.Forms.ColumnHeader();
|
||||
this.bottomPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
this.lblTolerance = new System.Windows.Forms.Label();
|
||||
this.numTolerance = new System.Windows.Forms.NumericUpDown();
|
||||
this.lblCount = new System.Windows.Forms.Label();
|
||||
this.btnApply = new System.Windows.Forms.Button();
|
||||
this.bottomPanel.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).BeginInit();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// listView
|
||||
//
|
||||
this.listView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
|
||||
this.columnLines,
|
||||
this.columnRadius,
|
||||
this.columnDeviation,
|
||||
this.columnLocation});
|
||||
this.listView.CheckBoxes = true;
|
||||
this.listView.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.listView.FullRowSelect = true;
|
||||
this.listView.GridLines = true;
|
||||
this.listView.Location = new System.Drawing.Point(0, 0);
|
||||
this.listView.Name = "listView";
|
||||
this.listView.Size = new System.Drawing.Size(404, 378);
|
||||
this.listView.TabIndex = 0;
|
||||
this.listView.UseCompatibleStateImageBehavior = false;
|
||||
this.listView.View = System.Windows.Forms.View.Details;
|
||||
this.listView.ItemChecked += new System.Windows.Forms.ItemCheckedEventHandler(this.OnItemChecked);
|
||||
this.listView.ItemSelectionChanged += new System.Windows.Forms.ListViewItemSelectionChangedEventHandler(this.OnItemSelected);
|
||||
//
|
||||
// columnLines
|
||||
//
|
||||
this.columnLines.Text = "Lines";
|
||||
this.columnLines.Width = 50;
|
||||
//
|
||||
// columnRadius
|
||||
//
|
||||
this.columnRadius.Text = "Radius";
|
||||
this.columnRadius.Width = 70;
|
||||
//
|
||||
// columnDeviation
|
||||
//
|
||||
this.columnDeviation.Text = "Deviation";
|
||||
this.columnDeviation.Width = 75;
|
||||
//
|
||||
// columnLocation
|
||||
//
|
||||
this.columnLocation.Text = "Location";
|
||||
this.columnLocation.Width = 100;
|
||||
//
|
||||
// bottomPanel
|
||||
//
|
||||
this.bottomPanel.Controls.Add(this.lblTolerance);
|
||||
this.bottomPanel.Controls.Add(this.numTolerance);
|
||||
this.bottomPanel.Controls.Add(this.lblCount);
|
||||
this.bottomPanel.Controls.Add(this.btnApply);
|
||||
this.bottomPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
this.bottomPanel.Location = new System.Drawing.Point(0, 378);
|
||||
this.bottomPanel.Name = "bottomPanel";
|
||||
this.bottomPanel.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
|
||||
this.bottomPanel.Size = new System.Drawing.Size(404, 36);
|
||||
this.bottomPanel.TabIndex = 1;
|
||||
this.bottomPanel.WrapContents = false;
|
||||
//
|
||||
// lblTolerance
|
||||
//
|
||||
this.lblTolerance.AutoSize = true;
|
||||
this.lblTolerance.Location = new System.Drawing.Point(7, 9);
|
||||
this.lblTolerance.Margin = new System.Windows.Forms.Padding(0, 3, 2, 0);
|
||||
this.lblTolerance.Name = "lblTolerance";
|
||||
this.lblTolerance.Size = new System.Drawing.Size(61, 15);
|
||||
this.lblTolerance.TabIndex = 0;
|
||||
this.lblTolerance.Text = "Tolerance:";
|
||||
//
|
||||
// numTolerance
|
||||
//
|
||||
this.numTolerance.DecimalPlaces = 3;
|
||||
this.numTolerance.Increment = new decimal(new int[] {
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.Location = new System.Drawing.Point(73, 6);
|
||||
this.numTolerance.Maximum = new decimal(new int[] {
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0});
|
||||
this.numTolerance.Minimum = new decimal(new int[] {
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.Name = "numTolerance";
|
||||
this.numTolerance.Size = new System.Drawing.Size(70, 23);
|
||||
this.numTolerance.TabIndex = 1;
|
||||
this.numTolerance.Value = new decimal(new int[] {
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
196608});
|
||||
this.numTolerance.ValueChanged += new System.EventHandler(this.OnToleranceChanged);
|
||||
//
|
||||
// lblCount
|
||||
//
|
||||
this.lblCount.AutoSize = true;
|
||||
this.lblCount.Location = new System.Drawing.Point(155, 9);
|
||||
this.lblCount.Margin = new System.Windows.Forms.Padding(8, 3, 4, 0);
|
||||
this.lblCount.Name = "lblCount";
|
||||
this.lblCount.Size = new System.Drawing.Size(84, 15);
|
||||
this.lblCount.TabIndex = 2;
|
||||
this.lblCount.Text = "0 of 0 selected";
|
||||
//
|
||||
// btnApply
|
||||
//
|
||||
this.btnApply.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnApply.Location = new System.Drawing.Point(247, 6);
|
||||
this.btnApply.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
this.btnApply.Name = "btnApply";
|
||||
this.btnApply.Size = new System.Drawing.Size(60, 25);
|
||||
this.btnApply.TabIndex = 3;
|
||||
this.btnApply.Text = "Apply";
|
||||
this.btnApply.UseVisualStyleBackColor = true;
|
||||
this.btnApply.Click += new System.EventHandler(this.OnApplyClick);
|
||||
//
|
||||
// SimplifierViewerForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(404, 414);
|
||||
this.Controls.Add(this.listView);
|
||||
this.Controls.Add(this.bottomPanel);
|
||||
this.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
|
||||
this.Name = "SimplifierViewerForm";
|
||||
this.ShowInTaskbar = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.Manual;
|
||||
this.Text = "Geometry Simplifier";
|
||||
this.TopMost = true;
|
||||
this.bottomPanel.ResumeLayout(false);
|
||||
this.bottomPanel.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).EndInit();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.ListView listView;
|
||||
private System.Windows.Forms.ColumnHeader columnLines;
|
||||
private System.Windows.Forms.ColumnHeader columnRadius;
|
||||
private System.Windows.Forms.ColumnHeader columnDeviation;
|
||||
private System.Windows.Forms.ColumnHeader columnLocation;
|
||||
private System.Windows.Forms.FlowLayoutPanel bottomPanel;
|
||||
private System.Windows.Forms.Label lblTolerance;
|
||||
private System.Windows.Forms.NumericUpDown numTolerance;
|
||||
private System.Windows.Forms.Label lblCount;
|
||||
private System.Windows.Forms.Button btnApply;
|
||||
}
|
||||
}
|
||||
@@ -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 = 1.000m,
|
||||
DecimalPlaces = 3,
|
||||
Increment = 0.001m,
|
||||
Value = 0.005m,
|
||||
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.005)
|
||||
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();
|
||||
@@ -167,7 +93,28 @@ public class SimplifierViewerForm : Form
|
||||
|
||||
entityView.SimplifierHighlight = highlightEntities;
|
||||
entityView.SimplifierPreview = candidate.FittedArc;
|
||||
entityView.ZoomToArea(candidate.BoundingBox);
|
||||
|
||||
// Build tolerance zone by offsetting each original line both directions
|
||||
var tol = simplifier.Tolerance;
|
||||
var leftEntities = new List<Entity>();
|
||||
var rightEntities = new List<Entity>();
|
||||
foreach (var entity in highlightEntities)
|
||||
{
|
||||
var left = entity.OffsetEntity(tol, OffsetSide.Left);
|
||||
var right = entity.OffsetEntity(tol, OffsetSide.Right);
|
||||
if (left != null) leftEntities.Add(left);
|
||||
if (right != null) rightEntities.Add(right);
|
||||
}
|
||||
entityView.SimplifierToleranceLeft = leftEntities;
|
||||
entityView.SimplifierToleranceRight = rightEntities;
|
||||
|
||||
// Zoom with padding for the tolerance zone
|
||||
var padded = new Box(
|
||||
candidate.BoundingBox.X - tol * 2,
|
||||
candidate.BoundingBox.Y - tol * 2,
|
||||
candidate.BoundingBox.Width + tol * 4,
|
||||
candidate.BoundingBox.Length + tol * 4);
|
||||
entityView.ZoomToArea(padded);
|
||||
}
|
||||
|
||||
private void OnItemChecked(object sender, ItemCheckedEventArgs e)
|
||||
@@ -181,6 +128,7 @@ public class SimplifierViewerForm : Form
|
||||
|
||||
private void OnToleranceChanged(object sender, System.EventArgs e)
|
||||
{
|
||||
if (simplifier == null) return;
|
||||
simplifier.Tolerance = (double)numTolerance.Value;
|
||||
entityView?.ClearSimplifierPreview();
|
||||
RunAnalysis();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
Generated
+12
@@ -214,5 +214,17 @@ namespace OpenNest.Properties {
|
||||
this["LastPierceTime"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("")]
|
||||
public string DisabledStrategies {
|
||||
get {
|
||||
return ((string)(this["DisabledStrategies"]));
|
||||
}
|
||||
set {
|
||||
this["DisabledStrategies"] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
<Setting Name="LastPierceTime" Type="System.Decimal" Scope="User">
|
||||
<Value Profile="(Default)">0</Value>
|
||||
</Setting>
|
||||
<Setting Name="DisabledStrategies" Type="System.String" Scope="User">
|
||||
<Value Profile="(Default)" />
|
||||
</Setting>
|
||||
</Settings>
|
||||
</SettingsFile>
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenNest
|
||||
|
||||
A Windows desktop app for CNC nesting — imports DXF drawings, arranges parts on plates and exports layouts as DXF or G-code for cutting.
|
||||
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
||||
|
||||

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

|
||||
|
||||
@@ -46,12 +52,11 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
|
||||
### Quick Walkthrough
|
||||
|
||||
1. **Create a nest** — File > New Nest
|
||||
2. **Add drawings** — Import DXF files or create built-in shapes (rectangles, circles, etc.). DXF drawings should be 1:1 scale CAD files.
|
||||
3. **Set up a plate** — Define the plate size and material
|
||||
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
|
||||
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||
|
||||
<!-- TODO: Add screenshots for each step -->
|
||||
2. **Add drawings** — Import DXF files via the CAD Converter (handles bend detection, layer filtering, and color/linetype exclusion) or create built-in shapes
|
||||
3. **Set up a plate** — Define the plate size, material, quadrant, and spacing
|
||||
4. **Fill the plate** — The nesting engine arranges parts automatically using the active fill strategy
|
||||
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
||||
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||
|
||||
## Command-Line Interface
|
||||
|
||||
@@ -95,6 +100,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| `--keep-parts` | Keep existing parts instead of clearing before fill |
|
||||
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
|
||||
| `--engine <name>` | Select a registered nesting engine |
|
||||
| `--post <name>` | Post-process the result with the named post-processor plugin |
|
||||
| `--no-save` | Skip saving the output file |
|
||||
| `--no-log` | Skip writing the debug log |
|
||||
|
||||
@@ -102,26 +108,74 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
|
||||
```
|
||||
OpenNest.sln
|
||||
├── OpenNest/ # WinForms desktop application (UI)
|
||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
|
||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
|
||||
├── OpenNest.Training/ # ML training data collection
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
└── OpenNest.Tests/ # Unit tests
|
||||
├── OpenNest/ # WinForms desktop application (UI)
|
||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit)
|
||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||
├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)
|
||||
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
||||
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
||||
└── OpenNest.Tests/ # Unit tests (xUnit)
|
||||
```
|
||||
|
||||
For most users, only these matter:
|
||||
|
||||
| Project | What it does |
|
||||
|---------|-------------|
|
||||
| **OpenNest** | The app you run. WinForms UI with plate viewer, drawing list, and dialogs. |
|
||||
| **OpenNest** | The app you run. WinForms MDI interface with plate viewer, drawing list, CAD converter, and dialogs. |
|
||||
| **OpenNest.Console** | Command-line interface for batch nesting, scripting, and automation. |
|
||||
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
|
||||
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
|
||||
| **OpenNest.IO** | Reads and writes files — DXF (via ACadSharp), G-code, and the `.nest` ZIP format. |
|
||||
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. |
|
||||
| **OpenNest.Engine** | The brains — fill strategies (linear, pairs, rect best-fit, extents), NFP-based pair evaluation, gravity compaction, and a pluggable engine registry. |
|
||||
| **OpenNest.IO** | Reads and writes files — DXF/DWG (via ACadSharp), G-code, and the `.nest` ZIP format. |
|
||||
| **OpenNest.Api** | High-level API for running the full nesting pipeline programmatically (import, nest, export). |
|
||||
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
|
||||
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
|
||||
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
|
||||
| **OpenNest.Tests** | 75+ test files covering core geometry, fill strategies, splitting, bending, post-processing, and the API. |
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.
|
||||
|
||||
| Engine | Description |
|
||||
|--------|-------------|
|
||||
| **Default** | Multi-phase strategy: linear fill, pair fill, rect best-fit, then remainder. Balances density and speed. |
|
||||
| **Vertical Remnant** | Optimizes for a clean vertical drop on the right side of the plate. |
|
||||
| **Horizontal Remnant** | Optimizes for a clean horizontal drop on the top of the plate. |
|
||||
|
||||
Custom engines can be built by subclassing `NestEngineBase` and registering via `NestEngineRegistry` or dropping a plugin DLL in the `Engines/` directory.
|
||||
|
||||
### Fill Strategies
|
||||
|
||||
Each engine composes from a set of fill strategies:
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| **Linear** | Grid-based fill with geometry-aware copy distance and 4-config rotation/axis optimization |
|
||||
| **Pairs** | NFP-based interlocking pair evaluation — finds tight-fitting orientations between two parts |
|
||||
| **Rect Best-Fit** | Greedy rectangle bin-packing with horizontal and vertical orientation trials |
|
||||
| **Extents** | Extents-based pair tiling for simple rectangular arrangements |
|
||||
|
||||
## Drawing Splitting
|
||||
|
||||
Oversized parts that don't fit on a single plate can be split into smaller pieces:
|
||||
|
||||
- **Straight Split** — Clean cut with no joining features
|
||||
- **Weld-Gap Tabs** — Rectangular tab spacers on one side for weld alignment
|
||||
- **Spike-Groove** — Interlocking V-shaped spike and groove pairs for self-aligning joints
|
||||
|
||||
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
||||
|
||||
## Post-Processors
|
||||
|
||||
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
||||
|
||||
**Included:**
|
||||
|
||||
- **Cincinnati** — Full post-processor for Cincinnati CL-707/800/900/940/CLX laser cutting machines with variable declarations, material library resolution, speed classification, kerf compensation, and optional part sub-programs (M98).
|
||||
|
||||
Custom post-processors implement the `IPostProcessor` interface and are auto-discovered from DLLs in the `Posts/` directory.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
@@ -131,6 +185,7 @@ For most users, only these matter:
|
||||
| `F` | Zoom to fit the plate view |
|
||||
| `Shift` + Mouse Wheel | Rotate parts when a drawing is selected |
|
||||
| `Shift` + Left Click | Push the selected group of parts to the bottom-left most point |
|
||||
| Middle Mouse Click | Rotate selected parts 90 degrees |
|
||||
| `X` | Push selected parts left (negative X) |
|
||||
| `Shift+X` | Push selected parts right (positive X) |
|
||||
| `Y` | Push selected parts down (negative Y) |
|
||||
@@ -145,18 +200,26 @@ For most users, only these matter:
|
||||
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
|
||||
| DWG (AutoCAD Drawing) | Yes | No |
|
||||
| G-code | No | Yes (via post-processors) |
|
||||
| `.nest` (ZIP-based project format) | Yes | Yes |
|
||||
|
||||
## Nest File Format
|
||||
|
||||
Nest files (`.nest`) are ZIP archives containing:
|
||||
|
||||
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
|
||||
- `programs/program-N` — G-code text for each drawing's cut program
|
||||
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **NFP-based nesting** — No Fit Polygon algorithms and simulated annealing optimizer exist in the engine but aren't integrated into the UI or engine registry yet
|
||||
- **Lead-in/Lead-out UI** — Engine support for lead-ins, lead-outs, and tabs is implemented; needs a UI for configuration
|
||||
- **Sheet cut-offs** — Cut the sheet to size after nesting to reduce waste
|
||||
- **Post-processors** — Plugin interface (`IPostProcessor`) is in place; need to ship built-in post-processors for common CNC controllers
|
||||
- **Shape library UI** — Built-in shape generation code exists; needs a browsable library UI for quick access
|
||||
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
|
||||
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
|
||||
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
|
||||
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
|
||||
|
||||
## Status
|
||||
|
||||
OpenNest is under active development. The core nesting workflows function, but there's plenty of room for improvement in packing efficiency, UI polish, and format support. Contributions and feedback are welcome.
|
||||
OpenNest is under active development. The core nesting workflows function end-to-end — from DXF import through filling, splitting, cut-offs, and G-code post-processing. Contributions and feedback are welcome.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Reference in New Issue
Block a user