feat: mirror axis simplifier, bend note propagation, ellipse fixes

Geometry Simplifier:
- Replace least-squares circle fitting with mirror axis algorithm
  that constrains center to perpendicular bisector of chord, guaranteeing
  zero-gap endpoint connectivity by construction
- Golden section search optimizes center position along the axis
- Increase default tolerance from 0.005 to 0.5 for practical CNC use
- Support existing arcs in simplification runs (sample arc points to
  find larger replacement arcs spanning lines + arcs together)
- Add tolerance zone visualization (offset original geometry ±tolerance)
- Show original geometry overlay with orange dashed lines in preview
- Add "Original" checkbox to CadConverter for comparing old vs new
- Store OriginalEntities on FileListItem to prevent tolerance creep
  when re-running simplifier with different settings

Bend Detection:
- Propagate bend notes to collinear bend lines split by cutouts
  using infinite-line perpendicular distance check
- Add bend note text rendering in EntityView at bend line midpoints

DXF Import:
- Fix trimmed ellipse closing chord: only close when sweep ≈ 2π,
  preventing phantom lines through slot cutouts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 20:27:46 -04:00
parent c6652f7707
commit 356b989424
14 changed files with 14400 additions and 85 deletions

View File

@@ -19,7 +19,7 @@ public class ArcCandidate
public class GeometrySimplifier
{
public double Tolerance { get; set; } = 0.005;
public double Tolerance { get; set; } = 0.5;
public int MinLines { get; set; } = 3;
public List<ArcCandidate> Analyze(Shape shape)
@@ -30,21 +30,26 @@ 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
// Collect consecutive lines and arcs on the same layer
var runStart = i;
var layer = firstLine.Layer;
while (i < entities.Count && entities[i] is Line line && line.Layer == layer)
var layer = entities[i].Layer;
var lineCount = 0;
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer == layer)
{
if (entities[i] is Line) lineCount++;
i++;
}
var runEnd = i - 1;
// Try to find arc candidates within this run
FindCandidatesInRun(entities, runStart, runEnd, candidates);
// Only analyze runs that have enough line entities to simplify
if (lineCount >= MinLines)
FindCandidatesInRun(entities, runStart, runEnd, candidates);
}
return candidates;
@@ -94,12 +99,20 @@ public class GeometrySimplifier
while (j <= runEnd - MinLines + 1)
{
// Start with MinLines lines
// Need at least MinLines entities ahead
var k = j + MinLines - 1;
var points = CollectPoints(entities, j, k);
var (center, radius) = FitCircle(points);
if (k > runEnd) break;
if (!center.IsValid() || MaxDeviation(points, center, radius) > Tolerance)
var points = CollectPoints(entities, j, k);
if (points.Count < 3)
{
j++;
continue;
}
var (center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
{
j++;
continue;
@@ -108,34 +121,33 @@ public class GeometrySimplifier
// Extend as far as possible
var prevCenter = center;
var prevRadius = radius;
var prevMaxDev = MaxDeviation(points, center, radius);
var prevDev = dev;
while (k + 1 <= runEnd)
{
k++;
points = CollectPoints(entities, j, k);
var (newCenter, newRadius) = FitCircle(points);
if (!newCenter.IsValid())
if (points.Count < 3)
{
k--;
break;
}
var newMaxDev = MaxDeviation(points, newCenter, newRadius);
if (newMaxDev > Tolerance)
var (nc, nr, nd) = FitMirrorAxis(points);
if (!nc.IsValid() || nd > Tolerance)
{
k--;
break;
}
prevCenter = newCenter;
prevRadius = newRadius;
prevMaxDev = newMaxDev;
prevCenter = nc;
prevRadius = nr;
prevDev = nd;
}
// Build the candidate
var finalPoints = CollectPoints(entities, j, k);
var arc = BuildArc(prevCenter, prevRadius, finalPoints, entities[j]);
var arc = CreateArc(prevCenter, prevRadius, finalPoints, entities[j]);
var bbox = ComputeBoundingBox(finalPoints);
candidates.Add(new ArcCandidate
@@ -143,7 +155,7 @@ public class GeometrySimplifier
StartIndex = j,
EndIndex = k,
FittedArc = arc,
MaxDeviation = prevMaxDev,
MaxDeviation = prevDev,
BoundingBox = bbox,
});
@@ -151,28 +163,142 @@ public class GeometrySimplifier
}
}
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
/// <summary>
/// Fits a circular arc through a set of points using the mirror axis approach.
/// The center is constrained to lie on the perpendicular bisector of the chord
/// (P1→Pn), guaranteeing the arc passes exactly through both endpoints.
/// Golden section search finds the optimal position along this axis.
/// </summary>
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
{
var points = new List<Vector>();
points.Add(((Line)entities[start]).StartPoint);
for (var i = start; i <= end; i++)
points.Add(((Line)entities[i]).EndPoint);
return points;
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
// Chord midpoint and length
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
// Unit normal (mirror axis direction, perpendicular to chord)
var nx = -dy / chordLen;
var ny = dx / chordLen;
// Find max signed projection onto the normal (sagitta with sign)
var maxSagitta = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
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); // collinear
// Initial d estimate from sagitta geometry:
// Center at M + d*N, radius R = sqrt(halfChord² + d²)
// For a point on the arc at perpendicular distance s from chord:
// (d - s)² = halfChord² + d² → d = (s² - halfChord²) / (2s)
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
// Golden section search around initial estimate
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
var dLow = dInit - range;
var dHigh = dInit + range;
var phi = (System.Math.Sqrt(5) - 1) / 2;
for (var iter = 0; iter < 50; iter++)
{
var d1 = dHigh - phi * (dHigh - dLow);
var d2 = dLow + phi * (dHigh - dLow);
var dev1 = EvalDeviation(points, mx, my, nx, ny, halfChord, d1);
var dev2 = EvalDeviation(points, mx, my, nx, ny, halfChord, d2);
if (dev1 < dev2)
dHigh = d2;
else
dLow = d1;
if (dHigh - dLow < 1e-12)
break;
}
var dOpt = (dLow + dHigh) / 2;
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
var deviation = EvalDeviation(points, mx, my, nx, ny, halfChord, dOpt);
return (center, radius, deviation);
}
private static double MaxDeviation(List<Vector> points, Vector center, double radius)
/// <summary>
/// Evaluates the max deviation of intermediate points from the circle
/// defined by center = M + d*N, radius = sqrt(halfChord² + d²).
/// Endpoints are excluded since they're on the circle by construction.
/// </summary>
private static double EvalDeviation(List<Vector> points,
double mx, double my, double nx, double ny, double halfChord, double d)
{
var cx = mx + d * nx;
var cy = my + d * ny;
var r = System.Math.Sqrt(halfChord * halfChord + d * d);
var maxDev = 0.0;
for (var i = 0; i < points.Count; i++)
for (var i = 1; i < points.Count - 1; i++)
{
var dev = System.Math.Abs(points[i].DistanceTo(center) - radius);
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - r);
if (dev > maxDev)
maxDev = dev;
}
return maxDev;
}
private static Arc BuildArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
{
var points = new List<Vector>();
for (var i = start; i <= end; i++)
{
switch (entities[i])
{
case Line line:
if (i == start)
points.Add(line.StartPoint);
points.Add(line.EndPoint);
break;
case Arc arc:
if (i == start)
points.Add(arc.StartPoint());
// Sample intermediate points so deviation is measured
// accurately across the full arc span
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
var arcPoints = arc.ToPoints(segments);
// Skip first (already added or connects to previous) and add the rest
for (var j = 1; j < arcPoints.Count; j++)
points.Add(arcPoints[j]);
break;
}
}
return points;
}
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
{
var firstPoint = points[0];
var lastPoint = points[^1];
@@ -246,9 +372,6 @@ public class GeometrySimplifier
sumZ += z;
}
// 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);

View File

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

View File

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

View File

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

View File

@@ -77,9 +77,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);

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ namespace OpenNest.Forms
lblEntityCount = new System.Windows.Forms.Label();
btnSplit = new System.Windows.Forms.Button();
btnSimplify = new System.Windows.Forms.Button();
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 +130,7 @@ namespace OpenNest.Forms
detailBar.Controls.Add(lblEntityCount);
detailBar.Controls.Add(btnSplit);
detailBar.Controls.Add(btnSimplify);
detailBar.Controls.Add(chkShowOriginal);
detailBar.Controls.Add(lblDetect);
detailBar.Controls.Add(cboBendDetector);
detailBar.Dock = System.Windows.Forms.DockStyle.Bottom;
@@ -225,6 +227,14 @@ namespace OpenNest.Forms
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
//
// 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 +334,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.TextBox txtCustomer;
private System.Windows.Forms.Button btnSplit;
private System.Windows.Forms.Button btnSimplify;
private System.Windows.Forms.CheckBox chkShowOriginal;
private System.Windows.Forms.ComboBox cboBendDetector;
private System.Windows.Forms.Label lblQty;
private System.Windows.Forms.Label lblCust;

View File

@@ -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>();
@@ -384,7 +385,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;
@@ -422,6 +429,13 @@ namespace OpenNest.Forms
lblEntityCount.Text = $"{entities.Count} entities";
}
private void OnShowOriginalChanged(object sender, EventArgs e)
{
var item = CurrentItem;
entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OriginalEntities : null;
entityView1.Invalidate();
}
#endregion
#region Output

View File

@@ -54,10 +54,10 @@ public class SimplifierViewerForm : Form
numTolerance = new System.Windows.Forms.NumericUpDown
{
Minimum = 0.001m,
Maximum = 1.000m,
Maximum = 5.000m,
DecimalPlaces = 3,
Increment = 0.001m,
Value = 0.005m,
Increment = 0.05m,
Value = 0.500m,
Width = 70,
};
numTolerance.ValueChanged += OnToleranceChanged;
@@ -100,7 +100,7 @@ public class SimplifierViewerForm : Form
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.5)
{
this.shapes = shapes;
this.entityView = view;
@@ -167,7 +167,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)

135
README.md
View File

@@ -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.
![OpenNest - parts nested on a 36x36 plate](screenshots/screenshot-nest-1.png)
@@ -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
![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png)
@@ -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