Compare commits

..

5 Commits

Author SHA1 Message Date
aj a801a05e49 Merge remote-tracking branch 'origin/master' into feature/dxf-import-fixes
# Conflicts:
#	OpenNest.IO/Dxf.cs
2026-05-23 06:40:01 -04:00
aj da77cc9270 Fix best-fit viewer bounds for angled pairs 2026-05-18 22:17:47 -04:00
aj 27f0685058 fix(engine): skip intersecting parts as obstacles during compactor push
Parts that already overlap the moving group are now excluded from the
obstacle list so they don't block the push direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:07:42 -04:00
aj 53988acefc fix(io): deduplicate circles and full-circle arcs during DXF import
Duplicate circle entities at the same location inflated pierce counts
and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:03 -04:00
aj a8d90be2ea feat: add layer filter overloads to Dxf.GetGeometry()
Add optional Func<string, bool> layerFilter parameter to ConvertEntities
and two new GetGeometry overloads (path and stream) that accept a layer
filter. This lets callers control which layers to exclude instead of
being limited to the hardcoded IsNonCutLayer check. Existing overloads
without the filter continue to use the default IsNonCutLayer behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:02 -04:00
7 changed files with 319 additions and 8 deletions
+65
View File
@@ -1,6 +1,9 @@
using OpenNest.Engine;
using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
return new List<Part> { part1, part2 }; return new List<Part> { part1, part2 };
} }
public List<Part> BuildCanonicalParts()
{
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
}
public List<Part> BuildSourceParts(Drawing drawing)
{
var parts = BuildCanonicalParts();
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
}
public Box GetCutBounds(List<Part> parts)
{
return GetCutBoundingBox(parts);
}
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
{
if (parts == null || parts.Count == 0)
return parts;
var bounds = GetCutBoundingBox(parts);
var offset = new Vector(-bounds.Left, -bounds.Bottom);
foreach (var part in parts)
part.Offset(offset);
return parts;
}
private static Box GetCutBoundingBox(List<Part> parts)
{
var entities = new List<IBoundable>();
foreach (var part in parts)
{
var partEntities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
foreach (var entity in partEntities)
{
entity.Offset(part.Location);
entities.Add(entity);
}
}
return entities.GetBoundingBox();
}
} }
public enum BestFitSortField public enum BestFitSortField
+36 -3
View File
@@ -1,6 +1,7 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Engine.Fill namespace OpenNest.Engine.Fill
{ {
@@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction) public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -26,7 +27,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, double angle) public static double Push(List<Part> movingParts, Plate plate, double angle)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
@@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(obstacleParts[i]); : PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction); var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
{
continue;
}
if (d < distance) if (d < distance)
distance = d; distance = d;
} }
@@ -115,6 +123,31 @@ namespace OpenNest.Engine.Fill
return 0; return 0;
} }
private static bool IntersectsAny(Part candidate, List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
if (candidate.Intersects(parts[i], out _))
return true;
}
return false;
}
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
{
var nudge = direction * (Tolerance.Epsilon * 10);
moving.Offset(nudge);
try
{
return !moving.Intersects(obstacle, out _);
}
finally
{
moving.Offset(-nudge);
}
}
public static double Push(List<Part> movingParts, List<Part> obstacleParts, public static double Push(List<Part> movingParts, List<Part> obstacleParts,
Box workArea, double partSpacing, PushDirection direction) Box workArea, double partSpacing, PushDirection direction)
{ {
@@ -130,7 +163,7 @@ namespace OpenNest.Engine.Fill
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction) public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
+33 -2
View File
@@ -65,6 +65,36 @@ namespace OpenNest.IO
} }
} }
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion #endregion
#region Export #region Export
@@ -128,16 +158,17 @@ namespace OpenNest.IO
} }
} }
private static List<Entity> ConvertEntities(CadDocument doc) private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
{ {
var entities = new List<Entity>(); var entities = new List<Entity>();
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
var circles = new List<Circle>(); var circles = new List<Circle>();
var filter = layerFilter ?? IsNonCutLayer;
foreach (var entity in doc.Entities) foreach (var entity in doc.Entities)
{ {
if (IsNonCutLayer(entity.Layer?.Name)) if (filter(entity.Layer?.Name))
continue; continue;
switch (entity) switch (entity)
@@ -0,0 +1,72 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests.BestFit;
public class BestFitResultFrameTests
{
[Fact]
public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
Assert.True(IsNonAxisAligned(result.OptimalRotation),
$"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees.");
var parts = result.BuildCanonicalParts();
var bounds = result.GetCutBounds(parts);
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
}
[Fact]
public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
var parts = result.BuildSourceParts(drawing);
var bounds = result.GetCutBounds(parts);
Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.False(parts[0].Intersects(parts[1], out _));
}
private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset)
{
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = System.Math.PI,
Part2Offset = offset,
Spacing = 0.25
};
return new PairEvaluator().Evaluate(candidate);
}
private static bool IsNonAxisAligned(double angle)
{
var normalized = Angle.NormalizeRad(angle);
var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI);
var delta = System.Math.Abs(normalized - nearestQuadrant);
delta = System.Math.Min(delta, Angle.HalfPI - delta);
return delta > Angle.ToRadians(1);
}
}
+107
View File
@@ -97,6 +97,33 @@ namespace OpenNest.Tests.Fill
return part; return part;
} }
private static Drawing MakeTriangleDrawing(params Vector[] points)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0]));
for (var i = 1; i < points.Length; i++)
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i]));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0]));
return new Drawing("triangle", pgm);
}
private static Part MakeTrianglePart(params Vector[] points)
{
var part = new Part(MakeTriangleDrawing(points));
part.UpdateBounds();
return part;
}
private static Part MakeTrianglePart(double x, double y, params Vector[] points)
{
var part = MakeTrianglePart(points);
part.Location = new Vector(x, y);
part.UpdateBounds();
return part;
}
[Fact] [Fact]
public void Push_Left_MovesPartTowardEdge() public void Push_Left_MovesPartTowardEdge()
{ {
@@ -171,6 +198,86 @@ namespace OpenNest.Tests.Fill
Assert.NotEqual(distNoSpacing, distWithSpacing); Assert.NotEqual(distNoSpacing, distWithSpacing);
} }
[Fact]
public void Push_Up_AllowsSharedDiagonalEdgeToSeparate()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Up);
Assert.True(distance > 0);
Assert.True(movingPart.BoundingBox.Top > 19.9);
Assert.False(movingPart.Intersects(obstacle, out _));
}
[Fact]
public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge()
{
var workArea = new Box(0, 0, 24, 24);
var leftTriangle = MakeTrianglePart(
2, 2,
new Vector(0, 0),
new Vector(8, 0),
new Vector(4, 10));
var rightTriangle = MakeTrianglePart(
14, 4,
new Vector(0, 10),
new Vector(8, 10),
new Vector(4, 0));
var moving = new List<Part> { rightTriangle };
var obstacles = new List<Part> { leftTriangle };
var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
var yBeforePushUp = rightTriangle.Location.Y;
var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom;
var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up);
Assert.True(leftDistance > 0);
Assert.True(upDistance > 0);
Assert.True(rightTriangle.Location.Y > yBeforePushUp);
Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp);
Assert.False(rightTriangle.Intersects(leftTriangle, out _));
}
[Fact]
public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Left);
Assert.Equal(0, distance);
Assert.Equal(0, movingPart.BoundingBox.Left);
}
[Fact] [Fact]
public void Push_AngleLeft_MovesPartTowardEdge() public void Push_AngleLeft_MovesPartTowardEdge()
{ {
+4 -2
View File
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
public BestFitResult SelectedResult { get; private set; } public BestFitResult SelectedResult { get; private set; }
public Drawing SelectedDrawing => activeDrawing; public Drawing SelectedDrawing => activeDrawing;
public List<Part> SelectedParts { get; private set; }
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches) public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
{ {
@@ -318,12 +319,12 @@ namespace OpenNest.Forms
var cell = new BestFitCell(colorScheme); var cell = new BestFitCell(colorScheme);
cell.PartColor = partColor; cell.PartColor = partColor;
cell.Dock = DockStyle.Fill; cell.Dock = DockStyle.Fill;
var parts = result.BuildCanonicalParts();
cell.Plate.Size = new Geometry.Size( cell.Plate.Size = new Geometry.Size(
result.BoundingHeight, result.BoundingHeight,
result.BoundingWidth); result.BoundingWidth);
var parts = result.BuildParts(drawing);
foreach (var part in parts) foreach (var part in parts)
cell.Plate.Parts.Add(part); cell.Plate.Parts.Add(part);
@@ -332,6 +333,7 @@ namespace OpenNest.Forms
cell.DoubleClick += (sender, e) => cell.DoubleClick += (sender, e) =>
{ {
SelectedResult = result; SelectedResult = result;
SelectedParts = result.BuildSourceParts(drawing);
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
}; };
+2 -1
View File
@@ -686,7 +686,8 @@ namespace OpenNest.Forms
{ {
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null) if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
{ {
var parts = form.SelectedResult.BuildParts(form.SelectedDrawing); var parts = form.SelectedParts
?? form.SelectedResult.BuildSourceParts(form.SelectedDrawing);
activeForm.PlateView.SetAction(typeof(ActionClone), parts); activeForm.PlateView.SetAction(typeof(ActionClone), parts);
} }
} }