Compare commits

...

6 Commits

Author SHA1 Message Date
aj 838a247ef9 fix(geometry): replace closest-point heuristic with analytical arc-to-line directional distance
ArcToLineClosestDistance used geometric closest-point as a proxy for
directional push distance, which are fundamentally different queries.
The heuristic could overestimate the safe push distance when an arc
faces an inclined line, causing the Compactor to over-push parts into
overlapping positions.

Replace with analytical computation: for each arc/line pair, solve
dt/dθ = 0 to find the two critical angles where the directional
distance is stationary, evaluate both (if within the arc's angular
span), and fire a ray to verify the hit is within the line segment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:33:48 -04:00
aj a5e5e78c4e refactor(geometry): deduplicate axis branches in SpatialQuery.OneWayDistance
Merge the near-identical Left/Right and Up/Down pruning loops into a
single loop that selects the perpendicular axis via IsHorizontalDirection().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:58:45 -04:00
aj c386e462b2 docs(readme): add CAD converter section with screenshots
Add a CAD Converter workflow section and inline thumbnail screenshots.
Rearrange existing screenshots as side-by-side thumbnails with
click-to-enlarge links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:39 -04:00
aj 2c0457d503 feat(ui): add bend line editing to CAD converter
Add Edit link and double-click handler to the bend lines list so
existing bends can be modified without removing and re-adding them.
BendLineDialog gains a LoadBend method to populate fields from an
existing Bend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:26 -04:00
aj b03b3eb4d9 fix(bending): detect bend lines on layer "0" in addition to "BEND"
SolidWorks drawings sometimes place centerline bend markers on the
default layer instead of a dedicated BEND layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:21 -04:00
aj 29c2872819 fix(geometry): add Entity.Clone() and stop NormalizeEntities from mutating originals
ShapeProfile.NormalizeEntities called Shape.Reverse() which flipped arc
directions on the original entity objects shared with the CAD view. Switching
to the Program tab and back would leave arcs reversed. Clone entities before
normalizing so the originals stay untouched.

Adds abstract Entity.Clone() with implementations on Line, Arc, Circle,
Polygon, and Shape (deep-clones children). Also adds CloneAll() extension
and replaces manual duplication in PartGeometry.CopyEntitiesAtLocation and
ProgramEditorControl.CloneEntity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:35:13 -04:00
18 changed files with 273 additions and 75 deletions
+7
View File
@@ -267,6 +267,13 @@ namespace OpenNest.Geometry
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
}
public override Entity Clone()
{
var copy = new Arc(center, radius, startAngle, endAngle, reversed);
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction.
/// </summary>
+7
View File
@@ -165,6 +165,13 @@ namespace OpenNest.Geometry
get { return Circumference(); }
}
public override Entity Clone()
{
var copy = new Circle(center, radius) { Rotation = Rotation };
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction.
/// </summary>
+25
View File
@@ -251,6 +251,23 @@ namespace OpenNest.Geometry
/// <returns></returns>
public abstract bool Intersects(Shape shape, out List<Vector> pts);
/// <summary>
/// Creates a deep copy of the entity with a new Id.
/// </summary>
public abstract Entity Clone();
/// <summary>
/// Copies common Entity properties from this instance to the target.
/// </summary>
protected void CopyBaseTo(Entity target)
{
target.Color = Color;
target.Layer = Layer;
target.LineTypeName = LineTypeName;
target.IsVisible = IsVisible;
target.Tag = Tag;
}
/// <summary>
/// Type of entity.
/// </summary>
@@ -259,6 +276,14 @@ namespace OpenNest.Geometry
public static class EntityExtensions
{
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
{
var result = new List<Entity>();
foreach (var e in entities)
result.Add(e.Clone());
return result;
}
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
{
var points = new List<Vector>();
+7
View File
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
}
}
public override Entity Clone()
{
var copy = new Line(pt1, pt2);
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reversed the line.
/// </summary>
+7
View File
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
get { return Perimeter(); }
}
public override Entity Clone()
{
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction of the polygon.
/// </summary>
+9
View File
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
return polygon;
}
public override Entity Clone()
{
var copy = new Shape();
foreach (var e in Entities)
copy.Entities.Add(e.Clone());
CopyBaseTo(copy);
return copy;
}
/// <summary>
/// Reverses the rotation direction of the shape.
/// </summary>
+2 -1
View File
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
/// </summary>
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{
var profile = new ShapeProfile(entities.ToList());
var cloned = entities.CloneAll();
var profile = new ShapeProfile(cloned);
return profile.ToNormalizedEntities();
}
+54 -38
View File
@@ -306,50 +306,39 @@ namespace OpenNest.Geometry
var minDist = double.MaxValue;
var vx = vertex.X;
var vy = vertex.Y;
var horizontal = IsHorizontalDirection(direction);
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
if (direction == PushDirection.Left || direction == PushDirection.Right)
{
// Pruning: edges are sorted by their perpendicular min-coordinate.
// For horizontal push, prune by Y range; for vertical push, prune by X range.
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
double perpValue, edgeMin, edgeMax;
if (horizontal)
{
perpValue = vy;
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
}
else
{
perpValue = vx;
edgeMin = e1.X < e2.X ? e1.X : e2.X;
edgeMax = e1.X > e2.X ? e1.X : e2.X;
}
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
if (vy < minY - Tolerance.Epsilon)
// Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
if (perpValue < edgeMin - Tolerance.Epsilon)
break;
if (vy > maxY + Tolerance.Epsilon)
if (perpValue > edgeMax + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minX = e1.X < e2.X ? e1.X : e2.X;
var maxX = e1.X > e2.X ? e1.X : e2.X;
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
if (vx < minX - Tolerance.Epsilon)
break;
if (vx > maxX + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
return minDist;
}
@@ -642,22 +631,49 @@ namespace OpenNest.Geometry
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is Arc arc)
{
if (arcEntities[i] is not Arc arc)
continue;
var cx = arc.Center.X;
var cy = arc.Center.Y;
var r = arc.Radius;
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is Line line)
if (lineEntities[j] is not Line line)
continue;
var p1x = line.pt1.X;
var p1y = line.pt1.Y;
var ex = line.pt2.X - p1x;
var ey = line.pt2.Y - p1y;
var det = ex * dirY - ey * dirX;
if (System.Math.Abs(det) < Tolerance.Epsilon)
continue;
// The directional distance from an arc point at angle θ to the
// line is t(θ) = [A + r·(ey·cosθ ex·sinθ)] / det.
// dt/dθ = 0 at θ = atan2(ex, ey) and θ + π.
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
for (var k = 0; k < 2; k++)
{
var linePt = line.ClosestPointTo(arc.Center);
var arcPt = arc.ClosestPointTo(linePt);
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
var theta = k == 0 ? theta1 : theta2;
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
continue;
var qx = cx + r * System.Math.Cos(theta);
var qy = cy + r * System.Math.Sin(theta);
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y,
dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
}
}
}
}
return minDist;
}
+3 -13
View File
@@ -126,20 +126,10 @@ namespace OpenNest
{
var result = new List<Entity>(source.Count);
for (var i = 0; i < source.Count; i++)
foreach (var entity in source)
{
var entity = source[i];
Entity copy;
if (entity is Line line)
copy = new Line(line.StartPoint + location, line.EndPoint + location);
else if (entity is Arc arc)
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
else if (entity is Circle circle)
copy = new Circle(circle.Center + location, circle.Radius);
else
continue;
var copy = entity.Clone();
copy.Offset(location);
result.Add(copy);
}
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
{
return document.Entities
.OfType<ACadSharp.Entities.Line>()
.Where(l => l.Layer?.Name == "BEND"
.Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0")
&& (l.LineType?.Name?.Contains("CENTER") == true
|| l.LineType?.Name == "CENTERX2"))
.ToList();
+70
View File
@@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill
{
public class CompactorTests
{
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush()
{
// Arc (top semicircle) pushed upward toward a 45° inclined line.
// The critical angle on the arc gives a shorter distance than any
// sampled vertex (endpoints + cardinal extremes).
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var direction = new Vector(0, 1); // push up
var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction);
// Move the arc up by the computed distance, then verify no overlap.
// The topmost reachable point on the arc at the critical angle θ ≈ 2.034
// (between π/2 and π) should just touch the line.
Assert.True(dist < double.MaxValue, "Should find a finite distance");
Assert.True(dist > 0, "Should be a positive distance");
// Verify: after moving, the closest point on the arc should be within
// tolerance of the line, not past it.
var theta = System.Math.Atan2(
line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y));
theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI);
var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta);
var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist;
// The moved point should be on or just touching the line, not past it.
// Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5
// y = 0.5x + 2.5
var lineYAtQx = 0.5 * qx + 2.5;
Assert.True(qy <= lineYAtQx + 0.001,
$"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " +
$"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}");
}
[Fact]
public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling()
{
// Same geometry — verify the analytical Phase 3 finds a shorter
// distance than the Phase 1/2 vertex sampling alone would.
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
var line = new Line(new Vector(3, 4), new Vector(7, 6));
// Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme.
var vertices = new[]
{
new Vector(7, 0), // arc endpoint θ=0
new Vector(3, 0), // arc endpoint θ=π
new Vector(5, 2), // cardinal extreme θ=π/2
};
var vertexMin = double.MaxValue;
foreach (var v in vertices)
{
var d = SpatialQuery.RayEdgeDistance(v.X, v.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1);
if (d < vertexMin) vertexMin = d;
}
// Full directional distance (includes Phase 3 arc-to-line).
var moving = new List<Entity> { arc };
var stationary = new List<Entity> { line };
var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1));
Assert.True(fullDist < vertexMin,
$"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})");
}
private static Drawing MakeRectDrawing(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
+20
View File
@@ -27,6 +27,7 @@ namespace OpenNest.Controls
public event EventHandler FilterChanged;
public event EventHandler<int> BendLineSelected;
public event EventHandler<int> BendLineRemoved;
public event EventHandler<int> BendLineEdited;
public event EventHandler AddBendLineClicked;
public FilterPanel()
@@ -51,6 +52,18 @@ namespace OpenNest.Controls
bendLinesList.SelectedIndexChanged += (s, e) =>
BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex);
var bendEditLink = new LinkLabel
{
Text = "Edit",
AutoSize = true,
Font = new Font("Segoe UI", 8f)
};
bendEditLink.LinkClicked += (s, e) =>
{
if (bendLinesList.SelectedIndex >= 0)
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
};
var bendDeleteLink = new LinkLabel
{
Text = "Remove",
@@ -63,6 +76,12 @@ namespace OpenNest.Controls
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
};
bendLinesList.DoubleClick += (s, e) =>
{
if (bendLinesList.SelectedIndex >= 0)
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
};
bendAddLink = new LinkLabel
{
Text = "Add Bend Line",
@@ -80,6 +99,7 @@ namespace OpenNest.Controls
WrapContents = false
};
bendLinksPanel.Controls.Add(bendAddLink);
bendLinksPanel.Controls.Add(bendEditLink);
bendLinksPanel.Controls.Add(bendDeleteLink);
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
+1 -8
View File
@@ -209,14 +209,7 @@ namespace OpenNest.Controls
private static Entity CloneEntity(Entity entity, Color color)
{
Entity clone = entity switch
{
Line line => new Line(line.StartPoint, line.EndPoint) { Layer = line.Layer, IsVisible = line.IsVisible },
Arc arc => new Arc(arc.Center, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed) { Layer = arc.Layer, IsVisible = arc.IsVisible },
Circle circle => new Circle(circle.Center, circle.Radius) { Layer = circle.Layer, IsVisible = circle.IsVisible },
_ => null,
};
if (clone != null)
var clone = entity.Clone();
clone.Color = color;
return clone;
}
+12
View File
@@ -99,5 +99,17 @@ namespace OpenNest.Forms
public double BendAngle => (double)numAngle.Value;
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
public void LoadBend(Bend bend)
{
cboDirection.SelectedIndex = bend.Direction == BendDirection.Up ? 1 : 0;
if (bend.Angle.HasValue)
numAngle.Value = (decimal)bend.Angle.Value;
if (bend.Radius.HasValue)
{
chkRadius.Checked = true;
numRadius.Value = (decimal)bend.Radius.Value;
}
}
}
}
+24
View File
@@ -41,6 +41,7 @@ namespace OpenNest.Forms
filterPanel.FilterChanged += OnFilterChanged;
filterPanel.BendLineSelected += OnBendLineSelected;
filterPanel.BendLineRemoved += OnBendLineRemoved;
filterPanel.BendLineEdited += OnBendLineEdited;
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
@@ -292,6 +293,29 @@ namespace OpenNest.Forms
entityView1.Invalidate();
}
private void OnBendLineEdited(object sender, int index)
{
var item = CurrentItem;
if (item == null || index < 0 || index >= item.Bends.Count) return;
var bend = item.Bends[index];
using var dialog = new BendLineDialog();
dialog.LoadBend(bend);
if (dialog.ShowDialog(this) != DialogResult.OK) return;
bend.Direction = dialog.Direction;
bend.Angle = dialog.BendAngle;
bend.Radius = dialog.BendRadius;
Bend.UpdateEtchEntities(item.Entities, item.Bends);
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends;
filterPanel.LoadItem(item.Entities, item.Bends);
entityView1.Invalidate();
}
private void OnQuantityChanged(object sender, EventArgs e)
{
var item = CurrentItem;
+13 -3
View File
@@ -2,7 +2,10 @@
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)
<p>
<a href="screenshots/screenshot-nest-1.png"><img src="screenshots/screenshot-nest-1.png" width="420" alt="OpenNest - parts nested on a 36x36 plate"></a>
<a href="screenshots/screenshot-nest-2.png"><img src="screenshots/screenshot-nest-2.png" width="420" alt="OpenNest - 44 parts nested on a 60x120 plate"></a>
</p>
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
@@ -46,8 +49,6 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and
| **User-Defined Variables** | Named G-code variables (`$name`) emitted as machine variables (`#200+`) at post time |
| **Post-Processors** | Plugin-based G-code generation; Cincinnati CL-707/800/900/940/CLX included |
![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png)
## Prerequisites
- **Windows 10 or later**
@@ -80,6 +81,15 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
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
### CAD Converter
The CAD Converter turns DXF/DWG files into nest-ready drawings. Toggle layers, colors, and linetypes to exclude construction geometry; review detected bend lines; and preview the generated cut program with contour ordering before accepting the drawing into the nest.
<p>
<a href="screenshots/screenshot-cad-converter-1.png"><img src="screenshots/screenshot-cad-converter-1.png" width="420" alt="CAD Converter — layer, color, and linetype filtering"></a>
<a href="screenshots/screenshot-cad-converter-2.png"><img src="screenshots/screenshot-cad-converter-2.png" width="420" alt="CAD Converter — contour list and G-code preview"></a>
</p>
## Command-Line Interface
OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB