Compare commits
6 Commits
036f723876
...
6ce501da11
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ce501da11 | |||
| 05037bc928 | |||
| f83df3a55a | |||
| 84ad39414a | |||
| fdb4a2373a | |||
| 3a0267c041 |
@@ -211,5 +211,8 @@ FakesAssemblies/
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
|
# Documentation (manuals, templates, etc.)
|
||||||
|
docs/
|
||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|||||||
@@ -579,43 +579,38 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Offsets the shape outward by the given distance, detecting winding direction
|
/// Offsets the shape outward by the given distance.
|
||||||
/// to choose the correct offset side. Falls back to the opposite side if the
|
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||||
/// bounding box shrinks (indicating the offset went inward).
|
/// making the method independent of the original contour winding direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Shape OffsetOutward(double distance)
|
public Shape OffsetOutward(double distance)
|
||||||
{
|
{
|
||||||
var poly = ToPolygon();
|
var poly = ToPolygon();
|
||||||
var side = poly.Vertices.Count >= 3 && poly.RotationDirection() == RotationType.CW
|
|
||||||
? OffsetSide.Left
|
|
||||||
: OffsetSide.Right;
|
|
||||||
|
|
||||||
var result = OffsetEntity(distance, side) as Shape;
|
if (poly == null || poly.Vertices.Count < 3
|
||||||
|
|| poly.RotationDirection() == RotationType.CW)
|
||||||
|
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||||
|
|
||||||
if (result == null)
|
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||||
return null;
|
var copy = new Shape();
|
||||||
|
|
||||||
UpdateBounds();
|
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||||
var originalBB = BoundingBox;
|
|
||||||
result.UpdateBounds();
|
|
||||||
var offsetBB = result.BoundingBox;
|
|
||||||
|
|
||||||
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
|
|
||||||
{
|
{
|
||||||
Trace.TraceWarning(
|
switch (Entities[i])
|
||||||
"Shape.OffsetOutward: offset shrank bounding box " +
|
{
|
||||||
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
|
case Line l:
|
||||||
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
|
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||||
"Retrying with opposite side.");
|
break;
|
||||||
|
case Arc a:
|
||||||
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
|
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||||
var retry = OffsetEntity(distance, opposite) as Shape;
|
break;
|
||||||
|
case Circle c:
|
||||||
if (retry != null)
|
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
|
||||||
result = retry;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -26,9 +27,9 @@ namespace OpenNest
|
|||||||
set => angleBuilder.ForceFullSweep = value;
|
set => angleBuilder.ForceFullSweep = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
return angleBuilder.Build(item, bestRotation, workArea);
|
return angleBuilder.Build(item, classification, workArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
||||||
@@ -132,10 +133,12 @@ namespace OpenNest
|
|||||||
|
|
||||||
protected virtual void RunPipeline(FillContext context)
|
protected virtual void RunPipeline(FillContext context)
|
||||||
{
|
{
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
var classification = PartClassifier.Classify(context.Item.Drawing);
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
context.PartType = classification.Type;
|
||||||
|
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||||
|
context.SharedState["Classification"] = classification;
|
||||||
|
|
||||||
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
|
var angles = BuildAngles(context.Item, classification, context.WorkArea);
|
||||||
context.SharedState["AngleCandidates"] = angles;
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -7,31 +7,68 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Builds candidate rotation angles for single-item fill. Encapsulates the
|
|
||||||
/// full pipeline: base angles, narrow-area sweep, ML prediction, and
|
|
||||||
/// known-good pruning across fills.
|
|
||||||
/// </summary>
|
|
||||||
public class AngleCandidateBuilder
|
public class AngleCandidateBuilder
|
||||||
{
|
{
|
||||||
private readonly HashSet<double> knownGoodAngles = new();
|
private readonly HashSet<double> knownGoodAngles = new();
|
||||||
|
|
||||||
public bool ForceFullSweep { get; set; }
|
public bool ForceFullSweep { get; set; }
|
||||||
|
|
||||||
public List<double> Build(NestItem item, double bestRotation, Box workArea)
|
public List<double> Build(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
var baseAngles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
// User constraints always take precedence over classification.
|
||||||
|
if (HasExplicitConstraints(item))
|
||||||
|
return BuildFromConstraints(item);
|
||||||
|
|
||||||
|
switch (classification.Type)
|
||||||
|
{
|
||||||
|
case PartType.Circle:
|
||||||
|
return new List<double> { 0 };
|
||||||
|
|
||||||
|
case PartType.Rectangle:
|
||||||
|
return new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return BuildIrregularAngles(item, classification.PrimaryAngle, workArea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasExplicitConstraints(NestItem item)
|
||||||
|
{
|
||||||
|
// Default NestConstraints: Start=0, End=0. Both zero = no constraints.
|
||||||
|
return !(item.RotationStart.IsEqualTo(0) && item.RotationEnd.IsEqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<double> BuildFromConstraints(NestItem item)
|
||||||
|
{
|
||||||
|
var angles = new List<double>();
|
||||||
|
var step = item.StepAngle > Tolerance.Epsilon ? item.StepAngle : Angle.ToRadians(5);
|
||||||
|
|
||||||
|
for (var a = item.RotationStart; a <= item.RotationEnd + Tolerance.Epsilon; a += step)
|
||||||
|
{
|
||||||
|
if (!ContainsAngle(angles, a))
|
||||||
|
angles.Add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (angles.Count == 0)
|
||||||
|
angles.Add(item.RotationStart);
|
||||||
|
|
||||||
|
return angles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<double> BuildIrregularAngles(NestItem item, double primaryAngle, Box workArea)
|
||||||
|
{
|
||||||
|
var baseAngles = new[] { primaryAngle, primaryAngle + Angle.HalfPI };
|
||||||
|
|
||||||
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
||||||
return BuildPrunedList(baseAngles);
|
return BuildPrunedList(baseAngles);
|
||||||
|
|
||||||
var angles = new List<double>(baseAngles);
|
var angles = new List<double>(baseAngles);
|
||||||
|
|
||||||
if (ForceFullSweep)
|
// Full 5-degree sweep for irregular parts.
|
||||||
AddSweepAngles(angles);
|
AddSweepAngles(angles);
|
||||||
|
|
||||||
if (!ForceFullSweep && angles.Count > 2)
|
// ML prediction complements the sweep when available.
|
||||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||||
|
|
||||||
return angles;
|
return angles;
|
||||||
}
|
}
|
||||||
@@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill
|
|||||||
mlAngles.Add(b);
|
mlAngles.Add(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} angles -> {mlAngles.Count} predicted");
|
// Merge ML angles into the existing sweep so both contribute.
|
||||||
|
foreach (var a in fallback)
|
||||||
|
{
|
||||||
|
if (!ContainsAngle(mlAngles, a))
|
||||||
|
mlAngles.Add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} sweep + {predicted.Count} predicted = {mlAngles.Count} total");
|
||||||
return mlAngles;
|
return mlAngles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill
|
|||||||
return angles.Any(existing => existing.IsEqualTo(angle));
|
return angles.Any(existing => existing.IsEqualTo(angle));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records angles that produced results. These are used to prune
|
|
||||||
/// subsequent Build() calls.
|
|
||||||
/// </summary>
|
|
||||||
public void RecordProductive(List<AngleResult> angleResults)
|
public void RecordProductive(List<AngleResult> angleResults)
|
||||||
{
|
{
|
||||||
foreach (var ar in angleResults)
|
foreach (var ar in angleResults)
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
|
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
|
||||||
|
|
||||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
|
||||||
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
|
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
|
||||||
return baseAngles;
|
return baseAngles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
|
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
|
||||||
|
|
||||||
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
public virtual List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
|
return new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + OpenNest.Math.Angle.HalfPI };
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
|
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
public enum PartType { Rectangle, Circle, Irregular }
|
||||||
|
|
||||||
|
public struct ClassificationResult
|
||||||
|
{
|
||||||
|
public PartType Type;
|
||||||
|
public double Rectangularity;
|
||||||
|
public double Circularity;
|
||||||
|
public double PerimeterRatio;
|
||||||
|
public double PrimaryAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PartClassifier
|
||||||
|
{
|
||||||
|
public const double RectangularityThreshold = 0.92;
|
||||||
|
public const double PerimeterRatioThreshold = 0.85;
|
||||||
|
public const double CircularityThreshold = 0.95;
|
||||||
|
|
||||||
|
public static ClassificationResult Classify(Drawing drawing)
|
||||||
|
{
|
||||||
|
var result = new ClassificationResult { Type = PartType.Irregular };
|
||||||
|
|
||||||
|
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// Find the largest shape (outer perimeter).
|
||||||
|
var perimeter = shapes[0];
|
||||||
|
var perimeterArea = perimeter.Area();
|
||||||
|
|
||||||
|
for (var i = 1; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var area = shapes[i].Area();
|
||||||
|
if (area > perimeterArea)
|
||||||
|
{
|
||||||
|
perimeter = shapes[i];
|
||||||
|
perimeterArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to polygon for hull/MBR computation.
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(0.1);
|
||||||
|
|
||||||
|
if (polygon == null || polygon.Vertices.Count < 3)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// Compute convex hull.
|
||||||
|
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||||
|
var hullArea = hull.Area();
|
||||||
|
|
||||||
|
// Compute MBR via rotating calipers.
|
||||||
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||||
|
var mbrArea = mbr.Area;
|
||||||
|
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
|
||||||
|
|
||||||
|
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
|
||||||
|
result.PrimaryAngle = -mbr.Angle;
|
||||||
|
|
||||||
|
// Drawing perimeter for circularity and perimeter ratio.
|
||||||
|
var drawingPerimeter = polygon.Perimeter();
|
||||||
|
|
||||||
|
// Circularity: 4*PI*area / perimeter^2. Circles ~ 1.0.
|
||||||
|
if (drawingPerimeter > Tolerance.Epsilon)
|
||||||
|
result.Circularity = 4 * System.Math.PI * perimeterArea / (drawingPerimeter * drawingPerimeter);
|
||||||
|
|
||||||
|
// Check circle first (rotationally invariant).
|
||||||
|
if (result.Circularity >= CircularityThreshold)
|
||||||
|
{
|
||||||
|
result.Type = PartType.Circle;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectangularity: hull area / MBR area.
|
||||||
|
if (mbrArea > Tolerance.Epsilon)
|
||||||
|
result.Rectangularity = hullArea / mbrArea;
|
||||||
|
|
||||||
|
// Perimeter ratio: MBR perimeter / drawing perimeter.
|
||||||
|
if (drawingPerimeter > Tolerance.Epsilon)
|
||||||
|
result.PerimeterRatio = mbrPerimeter / drawingPerimeter;
|
||||||
|
|
||||||
|
// Rectangle: both metrics pass thresholds.
|
||||||
|
if (result.Rectangularity >= RectangularityThreshold
|
||||||
|
&& result.PerimeterRatio >= PerimeterRatioThreshold)
|
||||||
|
{
|
||||||
|
result.Type = PartType.Rectangle;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Type = PartType.Irregular;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -7,33 +7,50 @@ namespace OpenNest.RectanglePacking
|
|||||||
{
|
{
|
||||||
internal class PackBottomLeft : PackEngine
|
internal class PackBottomLeft : PackEngine
|
||||||
{
|
{
|
||||||
private List<Vector> points;
|
|
||||||
|
|
||||||
public PackBottomLeft(Bin bin)
|
public PackBottomLeft(Bin bin)
|
||||||
: base(bin)
|
: base(bin)
|
||||||
{
|
{
|
||||||
points = new List<Vector>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Pack(List<Item> items)
|
public override void Pack(List<Item> items)
|
||||||
{
|
{
|
||||||
items = items.OrderBy(i => -i.Area()).ToList();
|
var byArea = items.Select(i => i.Clone() as Item).OrderByDescending(i => i.Area()).ToList();
|
||||||
|
var byLength = items.Select(i => i.Clone() as Item).OrderByDescending(i => System.Math.Max(i.Width, i.Length)).ToList();
|
||||||
|
|
||||||
points.Add(Bin.Location);
|
var resultA = PackWithOrder(byArea);
|
||||||
|
var resultB = PackWithOrder(byLength);
|
||||||
|
|
||||||
|
var winner = PickWinner(resultA, resultB);
|
||||||
|
|
||||||
|
Bin.Items.AddRange(winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Item> PackWithOrder(List<Item> items)
|
||||||
|
{
|
||||||
|
var points = new List<Vector> { Bin.Location };
|
||||||
|
var placed = new List<Item>();
|
||||||
var skip = new List<int>();
|
var skip = new List<int>();
|
||||||
|
|
||||||
for (int i = 0; i < items.Count; i++)
|
for (var i = 0; i < items.Count; i++)
|
||||||
{
|
{
|
||||||
var item = items[i];
|
var item = items[i];
|
||||||
|
|
||||||
if (skip.Contains(item.Id))
|
if (skip.Contains(item.Id))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var pt = FindPointVertical(item);
|
var pt = FindPointVertical(item, points, placed);
|
||||||
|
|
||||||
|
// If it doesn't fit, try rotated.
|
||||||
|
if (pt == null)
|
||||||
|
{
|
||||||
|
item.Rotate();
|
||||||
|
pt = FindPointVertical(item, points, placed);
|
||||||
|
}
|
||||||
|
|
||||||
if (pt == null)
|
if (pt == null)
|
||||||
{
|
{
|
||||||
|
if (item.IsRotated)
|
||||||
|
item.Rotate();
|
||||||
skip.Add(item.Id);
|
skip.Add(item.Id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -44,23 +61,37 @@ namespace OpenNest.RectanglePacking
|
|||||||
points.Add(new Vector(item.Left, item.Top));
|
points.Add(new Vector(item.Left, item.Top));
|
||||||
points.Add(new Vector(item.Right, item.Bottom));
|
points.Add(new Vector(item.Right, item.Bottom));
|
||||||
|
|
||||||
Bin.Items.Add(item);
|
placed.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
points.Clear();
|
return placed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Vector? FindPointVertical(Item item)
|
private static List<Item> PickWinner(List<Item> a, List<Item> b)
|
||||||
|
{
|
||||||
|
if (a.Count != b.Count)
|
||||||
|
return a.Count > b.Count ? a : b;
|
||||||
|
|
||||||
|
if (a.Count == 0)
|
||||||
|
return a;
|
||||||
|
|
||||||
|
var areaA = a.GetBoundingBox().Area();
|
||||||
|
var areaB = b.GetBoundingBox().Area();
|
||||||
|
|
||||||
|
return areaB < areaA ? b : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector? FindPointVertical(Item item, List<Vector> points, List<Item> placed)
|
||||||
{
|
{
|
||||||
var pt = new Vector(double.MaxValue, double.MaxValue);
|
var pt = new Vector(double.MaxValue, double.MaxValue);
|
||||||
|
|
||||||
for (int i = 0; i < points.Count; i++)
|
for (var i = 0; i < points.Count; i++)
|
||||||
{
|
{
|
||||||
var point = points[i];
|
var point = points[i];
|
||||||
|
|
||||||
item.Location = point;
|
item.Location = point;
|
||||||
|
|
||||||
if (!IsValid(item))
|
if (!IsValid(item, placed))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (point.X < pt.X)
|
if (point.X < pt.X)
|
||||||
@@ -75,12 +106,12 @@ namespace OpenNest.RectanglePacking
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsValid(Item item)
|
private bool IsValid(Item item, List<Item> placed)
|
||||||
{
|
{
|
||||||
if (!Bin.Contains(item))
|
if (!Bin.Contains(item))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
foreach (var it in Bin.Items)
|
foreach (var it in placed)
|
||||||
{
|
{
|
||||||
if (item.Intersects(it))
|
if (item.Intersects(it))
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ namespace OpenNest.Engine.Strategies
|
|||||||
|
|
||||||
public List<Part> Fill(FillContext context)
|
public List<Part> Fill(FillContext context)
|
||||||
{
|
{
|
||||||
|
if (context.PartType == PartType.Rectangle || context.PartType == PartType.Circle)
|
||||||
|
return null;
|
||||||
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
||||||
|
|
||||||
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
@@ -15,6 +16,7 @@ namespace OpenNest.Engine.Strategies
|
|||||||
public CancellationToken Token { get; init; }
|
public CancellationToken Token { get; init; }
|
||||||
public IProgress<NestProgress> Progress { get; init; }
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
public FillPolicy Policy { get; init; }
|
public FillPolicy Policy { get; init; }
|
||||||
|
public PartType PartType { get; set; }
|
||||||
|
|
||||||
public List<Part> CurrentBest { get; set; }
|
public List<Part> CurrentBest { get; set; }
|
||||||
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ namespace OpenNest.Engine.Strategies
|
|||||||
if (active.Value)
|
if (active.Value)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
if (context.PartType == PartType.Circle)
|
||||||
|
return null;
|
||||||
|
|
||||||
active.Value = true;
|
active.Value = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
|
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
|
||||||
|
|
||||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||||
{
|
{
|
||||||
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
|
||||||
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
|
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
|
||||||
return baseAngles;
|
return baseAngles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Tests;
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
@@ -16,6 +18,9 @@ public class AngleCandidateBuilderTests
|
|||||||
return new Drawing("rect", pgm);
|
return new Drawing("rect", pgm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ClassificationResult MakeClassification(double primaryAngle = 0, PartType type = PartType.Irregular)
|
||||||
|
=> new ClassificationResult { PrimaryAngle = primaryAngle, Type = type };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_ReturnsAtLeastTwoAngles()
|
public void Build_ReturnsAtLeastTwoAngles()
|
||||||
{
|
{
|
||||||
@@ -23,21 +28,21 @@ public class AngleCandidateBuilderTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var workArea = new Box(0, 0, 100, 100);
|
var workArea = new Box(0, 0, 100, 100);
|
||||||
|
|
||||||
var angles = builder.Build(item, 0, workArea);
|
var angles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
Assert.True(angles.Count >= 2);
|
Assert.True(angles.Count >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_NarrowWorkArea_UsesBaseAnglesOnly()
|
public void Build_RectangleType_NarrowWorkArea_UsesBaseAnglesOnly()
|
||||||
{
|
{
|
||||||
var builder = new AngleCandidateBuilder();
|
var builder = new AngleCandidateBuilder();
|
||||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
|
var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
|
||||||
|
|
||||||
var angles = builder.Build(item, 0, narrowArea);
|
var angles = builder.Build(item, MakeClassification(0, PartType.Rectangle), narrowArea);
|
||||||
|
|
||||||
// Without ForceFullSweep, narrow areas use only base angles (0° and 90°)
|
// Rectangle classification always returns exactly 2 angles regardless of work area
|
||||||
Assert.Equal(2, angles.Count);
|
Assert.Equal(2, angles.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@ public class AngleCandidateBuilderTests
|
|||||||
var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
|
var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
|
||||||
var workArea = new Box(0, 0, 100, 100);
|
var workArea = new Box(0, 0, 100, 100);
|
||||||
|
|
||||||
var angles = builder.Build(item, 0, workArea);
|
var angles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
// Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles
|
// Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles
|
||||||
Assert.True(angles.Count > 10);
|
Assert.True(angles.Count > 10);
|
||||||
@@ -62,7 +67,7 @@ public class AngleCandidateBuilderTests
|
|||||||
var workArea = new Box(0, 0, 100, 8);
|
var workArea = new Box(0, 0, 100, 8);
|
||||||
|
|
||||||
// First build — full sweep
|
// First build — full sweep
|
||||||
var firstAngles = builder.Build(item, 0, workArea);
|
var firstAngles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
// Record some as productive
|
// Record some as productive
|
||||||
var productive = new List<AngleResult>
|
var productive = new List<AngleResult>
|
||||||
@@ -74,9 +79,77 @@ public class AngleCandidateBuilderTests
|
|||||||
|
|
||||||
// Second build — should be pruned to known-good + base angles
|
// Second build — should be pruned to known-good + base angles
|
||||||
builder.ForceFullSweep = false;
|
builder.ForceFullSweep = false;
|
||||||
var secondAngles = builder.Build(item, 0, workArea);
|
var secondAngles = builder.Build(item, MakeClassification(), workArea);
|
||||||
|
|
||||||
Assert.True(secondAngles.Count < firstAngles.Count,
|
Assert.True(secondAngles.Count < firstAngles.Count,
|
||||||
$"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
|
$"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_RectanglePart_ReturnsTwoAngles()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var workArea = new Box(0, 0, 100, 100);
|
||||||
|
var classification = MakeClassification(0, PartType.Rectangle);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, classification, workArea);
|
||||||
|
|
||||||
|
Assert.Equal(2, angles.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_CirclePart_ReturnsOneAngle()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(10, 10) };
|
||||||
|
var workArea = new Box(0, 0, 100, 100);
|
||||||
|
var classification = MakeClassification(0, PartType.Circle);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, classification, workArea);
|
||||||
|
|
||||||
|
Assert.Single(angles);
|
||||||
|
Assert.Equal(0, angles[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_UserConstraints_OverrideRectangleClassification()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem
|
||||||
|
{
|
||||||
|
Drawing = MakeRectDrawing(100, 50),
|
||||||
|
RotationStart = Angle.ToRadians(10),
|
||||||
|
RotationEnd = Angle.ToRadians(90),
|
||||||
|
StepAngle = Angle.ToRadians(10),
|
||||||
|
};
|
||||||
|
var classification = MakeClassification(0, PartType.Rectangle);
|
||||||
|
var workArea = new Box(0, 0, 1000, 500);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, classification, workArea);
|
||||||
|
|
||||||
|
Assert.True(angles.Count > 2,
|
||||||
|
$"User constraints should override rect classification, got {angles.Count} angles");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_UserConstraints_StartingAtZero_AreRespected()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem
|
||||||
|
{
|
||||||
|
Drawing = MakeRectDrawing(100, 50),
|
||||||
|
RotationStart = 0,
|
||||||
|
RotationEnd = System.Math.PI,
|
||||||
|
StepAngle = Angle.ToRadians(45),
|
||||||
|
};
|
||||||
|
var classification = MakeClassification(0, PartType.Rectangle);
|
||||||
|
var workArea = new Box(0, 0, 1000, 500);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, classification, workArea);
|
||||||
|
|
||||||
|
// Start=0, End=PI is NOT "no constraints" — it's a real 0-180 range
|
||||||
|
Assert.True(angles.Count > 2,
|
||||||
|
$"0-to-PI constraint should produce multiple angles, got {angles.Count}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PartClassifierTests
|
||||||
|
{
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Drawing MakeRectDrawing(double w, double h)
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
return new Drawing("rect", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_PureRectangle_ReturnsRectangle()
|
||||||
|
{
|
||||||
|
var drawing = MakeRectDrawing(100, 50);
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Rectangle, result.Type);
|
||||||
|
Assert.True(result.Rectangularity >= 0.99, $"Expected rectangularity>=0.99, got {result.Rectangularity:F4}");
|
||||||
|
Assert.True(result.PerimeterRatio >= 0.99, $"Expected perimeterRatio>=0.99, got {result.PerimeterRatio:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_RoundedRectangle_ReturnsRectangle()
|
||||||
|
{
|
||||||
|
// Use the built-in shape builder so arc geometry is constructed correctly.
|
||||||
|
var shape = new RoundedRectangleShape { Length = 100, Width = 50, Radius = 5 };
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Rectangle, result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_RectWithSmallNotches_ReturnsRectangle()
|
||||||
|
{
|
||||||
|
// 100x50 rectangle with a 5x2 notch cut into the bottom edge near the centre.
|
||||||
|
// The notch is small relative to the overall perimeter so both metrics still pass.
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
// Bottom edge left section -> notch -> bottom edge right section
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(45, 0))); // along bottom to notch start
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(45, 2))); // up into notch
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 2))); // across notch (5 wide)
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 0))); // back down
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 0))); // remainder of bottom edge
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 50))); // right edge
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 50))); // top edge
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0))); // left edge back to start
|
||||||
|
var drawing = new Drawing("rect-notch", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Rectangle, result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_Circle_ReturnsCircle()
|
||||||
|
{
|
||||||
|
var shape = new CircleShape { Diameter = 50 };
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Circle, result.Type);
|
||||||
|
Assert.True(result.Circularity >= PartClassifier.CircularityThreshold,
|
||||||
|
$"Expected circularity>={PartClassifier.CircularityThreshold}, got {result.Circularity:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_LShape_ReturnsIrregular()
|
||||||
|
{
|
||||||
|
// 100x80 L-shape: full rect minus a 50x40 block from the top-right corner.
|
||||||
|
// Outline (CCW): (0,0) → (100,0) → (100,40) → (50,40) → (50,80) → (0,80) → (0,0)
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 40)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 40)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 80)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 80)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
var drawing = new Drawing("lshape", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Irregular, result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_Triangle_ReturnsIrregular()
|
||||||
|
{
|
||||||
|
// Right triangle: base 100, height 80.
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 80)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
var drawing = new Drawing("triangle", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Irregular, result.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_SerratedEdge_CaughtByPerimeterRatio()
|
||||||
|
{
|
||||||
|
// 100x30 rectangle with 20 teeth of depth 6 along the bottom edge.
|
||||||
|
// Each tooth is 5 wide, 6 deep → adds 12 units of extra perimeter per tooth.
|
||||||
|
// Total extra = 20 * 12 = 240 mm extra over a plain 100mm bottom edge.
|
||||||
|
// MBR perimeter ≈ 2*(100+30) = 260. Actual perimeter ≈ 260 - 100 + 100 + 240 = 500.
|
||||||
|
// PerimeterRatio ≈ 260/500 = 0.52 — well below the 0.85 threshold.
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
|
||||||
|
// Serrated bottom edge: 20 teeth, each 5 wide and 6 deep.
|
||||||
|
var toothCount = 20;
|
||||||
|
var toothWidth = 5.0;
|
||||||
|
var toothDepth = 6.0;
|
||||||
|
var w = toothCount * toothWidth; // = 100
|
||||||
|
var h = 30.0;
|
||||||
|
|
||||||
|
for (var i = 0; i < toothCount; i++)
|
||||||
|
{
|
||||||
|
var x0 = i * toothWidth;
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(x0 + toothWidth / 2, -toothDepth)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(x0 + toothWidth, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
var drawing = new Drawing("serrated", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Irregular, result.Type);
|
||||||
|
Assert.True(result.PerimeterRatio < PartClassifier.PerimeterRatioThreshold,
|
||||||
|
$"Expected perimeterRatio<{PartClassifier.PerimeterRatioThreshold}, got {result.PerimeterRatio:F4}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_PrimaryAngle_MatchesMbrAlignment()
|
||||||
|
{
|
||||||
|
// A rectangle rotated 30° around the origin — no edge is axis-aligned, so
|
||||||
|
// RotatingCalipers must find a non-zero MBR angle.
|
||||||
|
var tiltDeg = 30.0;
|
||||||
|
var tiltRad = Angle.ToRadians(tiltDeg);
|
||||||
|
var w = 80.0;
|
||||||
|
var h = 30.0;
|
||||||
|
var cos = System.Math.Cos(tiltRad);
|
||||||
|
var sin = System.Math.Sin(tiltRad);
|
||||||
|
|
||||||
|
// Rotate each corner of an 80×30 rectangle by 30°.
|
||||||
|
Vector Rot(double x, double y) => new Vector(x * cos - y * sin, x * sin + y * cos);
|
||||||
|
|
||||||
|
var p0 = Rot(0, 0);
|
||||||
|
var p1 = Rot(w, 0);
|
||||||
|
var p2 = Rot(w, h);
|
||||||
|
var p3 = Rot(0, h);
|
||||||
|
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(p0));
|
||||||
|
pgm.Codes.Add(new LinearMove(p1));
|
||||||
|
pgm.Codes.Add(new LinearMove(p2));
|
||||||
|
pgm.Codes.Add(new LinearMove(p3));
|
||||||
|
pgm.Codes.Add(new LinearMove(p0));
|
||||||
|
var drawing = new Drawing("tilted-rect", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
// The MBR must be tilted — primary angle should be non-zero.
|
||||||
|
Assert.True(System.Math.Abs(result.PrimaryAngle) > 0.01,
|
||||||
|
$"Expected non-zero primary angle for 30°-tilted rect, got {result.PrimaryAngle:F4} rad");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Classify_EmptyDrawing_ReturnsIrregularDefault()
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
var drawing = new Drawing("empty", pgm);
|
||||||
|
|
||||||
|
var result = PartClassifier.Classify(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(PartType.Irregular, result.Type);
|
||||||
|
Assert.Equal(0.0, result.Rectangularity);
|
||||||
|
Assert.Equal(0.0, result.Circularity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -33,7 +34,7 @@ public class StrategyOverlapTests
|
|||||||
|
|
||||||
var strategies = FillStrategyRegistry.Strategies.ToList();
|
var strategies = FillStrategyRegistry.Strategies.ToList();
|
||||||
var item = new NestItem { Drawing = drawing };
|
var item = new NestItem { Drawing = drawing };
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
var classification = PartClassifier.Classify(drawing);
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
|
|
||||||
foreach (var strategy in strategies)
|
foreach (var strategy in strategies)
|
||||||
@@ -50,9 +51,10 @@ public class StrategyOverlapTests
|
|||||||
Token = System.Threading.CancellationToken.None,
|
Token = System.Threading.CancellationToken.None,
|
||||||
Policy = policy,
|
Policy = policy,
|
||||||
};
|
};
|
||||||
context.SharedState["BestRotation"] = bestRotation;
|
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||||
|
context.SharedState["Classification"] = classification;
|
||||||
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
||||||
item, bestRotation, context.WorkArea);
|
item, classification, context.WorkArea);
|
||||||
|
|
||||||
var parts = strategy.Fill(context);
|
var parts = strategy.Fill(context);
|
||||||
var count = parts?.Count ?? 0;
|
var count = parts?.Count ?? 0;
|
||||||
|
|||||||
+38
-15
@@ -116,8 +116,15 @@ namespace OpenNest.Forms
|
|||||||
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
||||||
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
||||||
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
|
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
|
entityView1.IsPickingBendLine = false;
|
||||||
entityView1.Location = new System.Drawing.Point(0, 0);
|
entityView1.Location = new System.Drawing.Point(0, 0);
|
||||||
entityView1.Name = "entityView1";
|
entityView1.Name = "entityView1";
|
||||||
|
entityView1.OriginalEntities = null;
|
||||||
|
entityView1.ShowEntityLabels = false;
|
||||||
|
entityView1.SimplifierHighlight = null;
|
||||||
|
entityView1.SimplifierPreview = null;
|
||||||
|
entityView1.SimplifierToleranceLeft = null;
|
||||||
|
entityView1.SimplifierToleranceRight = null;
|
||||||
entityView1.Size = new System.Drawing.Size(759, 634);
|
entityView1.Size = new System.Drawing.Size(759, 634);
|
||||||
entityView1.TabIndex = 0;
|
entityView1.TabIndex = 0;
|
||||||
//
|
//
|
||||||
@@ -215,52 +222,68 @@ namespace OpenNest.Forms
|
|||||||
//
|
//
|
||||||
btnSplit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
btnSplit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||||
btnSplit.Font = new System.Drawing.Font("Segoe UI", 9F);
|
btnSplit.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
btnSplit.Location = new System.Drawing.Point(291, 6);
|
btnSplit.Location = new System.Drawing.Point(289, 6);
|
||||||
btnSplit.Margin = new System.Windows.Forms.Padding(2, 0, 8, 0);
|
btnSplit.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
|
||||||
btnSplit.Name = "btnSplit";
|
btnSplit.Name = "btnSplit";
|
||||||
btnSplit.Size = new System.Drawing.Size(60, 24);
|
btnSplit.Size = new System.Drawing.Size(60, 27);
|
||||||
btnSplit.TabIndex = 6;
|
btnSplit.TabIndex = 6;
|
||||||
btnSplit.Text = "Split...";
|
btnSplit.Text = "Split...";
|
||||||
//
|
//
|
||||||
// btnSimplify
|
// btnSimplify
|
||||||
//
|
//
|
||||||
|
btnSimplify.AutoSize = true;
|
||||||
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||||
btnSimplify.Font = new System.Drawing.Font("Segoe UI", 9F);
|
btnSimplify.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
|
btnSimplify.Location = new System.Drawing.Point(359, 6);
|
||||||
|
btnSimplify.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
|
||||||
|
btnSimplify.Name = "btnSimplify";
|
||||||
|
btnSimplify.Size = new System.Drawing.Size(75, 27);
|
||||||
|
btnSimplify.TabIndex = 7;
|
||||||
btnSimplify.Text = "Simplify...";
|
btnSimplify.Text = "Simplify...";
|
||||||
btnSimplify.AutoSize = true;
|
btnSimplify.Click += OnSimplifyClick;
|
||||||
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
|
||||||
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
|
|
||||||
//
|
//
|
||||||
// btnExportDxf
|
// btnExportDxf
|
||||||
//
|
//
|
||||||
|
btnExportDxf.AutoSize = true;
|
||||||
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||||
btnExportDxf.Font = new System.Drawing.Font("Segoe UI", 9F);
|
btnExportDxf.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
|
btnExportDxf.Location = new System.Drawing.Point(444, 6);
|
||||||
|
btnExportDxf.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
|
||||||
|
btnExportDxf.Name = "btnExportDxf";
|
||||||
|
btnExportDxf.Size = new System.Drawing.Size(76, 27);
|
||||||
|
btnExportDxf.TabIndex = 8;
|
||||||
btnExportDxf.Text = "Export DXF";
|
btnExportDxf.Text = "Export DXF";
|
||||||
btnExportDxf.AutoSize = true;
|
btnExportDxf.Click += OnExportDxfClick;
|
||||||
btnExportDxf.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
|
||||||
btnExportDxf.Click += new System.EventHandler(this.OnExportDxfClick);
|
|
||||||
//
|
//
|
||||||
// chkShowOriginal
|
// chkShowOriginal
|
||||||
//
|
//
|
||||||
chkShowOriginal.AutoSize = true;
|
chkShowOriginal.AutoSize = true;
|
||||||
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
|
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
chkShowOriginal.Text = "Original";
|
chkShowOriginal.Location = new System.Drawing.Point(536, 9);
|
||||||
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
||||||
chkShowOriginal.CheckedChanged += new System.EventHandler(this.OnShowOriginalChanged);
|
chkShowOriginal.Name = "chkShowOriginal";
|
||||||
|
chkShowOriginal.Size = new System.Drawing.Size(68, 19);
|
||||||
|
chkShowOriginal.TabIndex = 9;
|
||||||
|
chkShowOriginal.Text = "Original";
|
||||||
|
chkShowOriginal.CheckedChanged += OnShowOriginalChanged;
|
||||||
//
|
//
|
||||||
// chkLabels
|
// chkLabels
|
||||||
//
|
//
|
||||||
chkLabels.AutoSize = true;
|
chkLabels.AutoSize = true;
|
||||||
chkLabels.Font = new System.Drawing.Font("Segoe UI", 9F);
|
chkLabels.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
chkLabels.Text = "Labels";
|
chkLabels.Location = new System.Drawing.Point(610, 9);
|
||||||
chkLabels.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
chkLabels.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
||||||
chkLabels.CheckedChanged += new System.EventHandler(this.OnLabelsChanged);
|
chkLabels.Name = "chkLabels";
|
||||||
|
chkLabels.Size = new System.Drawing.Size(59, 19);
|
||||||
|
chkLabels.TabIndex = 10;
|
||||||
|
chkLabels.Text = "Labels";
|
||||||
|
chkLabels.CheckedChanged += OnLabelsChanged;
|
||||||
//
|
//
|
||||||
// lblDetect
|
// lblDetect
|
||||||
//
|
//
|
||||||
lblDetect.AutoSize = true;
|
lblDetect.AutoSize = true;
|
||||||
lblDetect.Font = new System.Drawing.Font("Segoe UI", 9F);
|
lblDetect.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
lblDetect.Location = new System.Drawing.Point(361, 9);
|
lblDetect.Location = new System.Drawing.Point(671, 9);
|
||||||
lblDetect.Margin = new System.Windows.Forms.Padding(2, 3, 0, 0);
|
lblDetect.Margin = new System.Windows.Forms.Padding(2, 3, 0, 0);
|
||||||
lblDetect.Name = "lblDetect";
|
lblDetect.Name = "lblDetect";
|
||||||
lblDetect.Size = new System.Drawing.Size(42, 15);
|
lblDetect.Size = new System.Drawing.Size(42, 15);
|
||||||
@@ -271,7 +294,7 @@ namespace OpenNest.Forms
|
|||||||
//
|
//
|
||||||
cboBendDetector.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
cboBendDetector.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
cboBendDetector.Font = new System.Drawing.Font("Segoe UI", 9F);
|
cboBendDetector.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||||
cboBendDetector.Location = new System.Drawing.Point(405, 6);
|
cboBendDetector.Location = new System.Drawing.Point(715, 6);
|
||||||
cboBendDetector.Margin = new System.Windows.Forms.Padding(2, 0, 0, 0);
|
cboBendDetector.Margin = new System.Windows.Forms.Padding(2, 0, 0, 0);
|
||||||
cboBendDetector.Name = "cboBendDetector";
|
cboBendDetector.Name = "cboBendDetector";
|
||||||
cboBendDetector.Size = new System.Drawing.Size(90, 23);
|
cboBendDetector.Size = new System.Drawing.Size(90, 23);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
|||||||
# Direct Arc Conversion for Spline and Ellipse Import
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
During DXF import, splines and ellipses are converted to many small line segments (200 for ellipses, control-point polygons for splines), then optionally reconstructed back to arcs via GeometrySimplifier in the CAD converter. This is wasteful and lossy:
|
|
||||||
|
|
||||||
- **Ellipses** are sampled into 200 line segments, discarding the known parametric form.
|
|
||||||
- **Splines** connect control points with lines, which is geometrically incorrect for B-splines (control points don't lie on the curve).
|
|
||||||
- Reconstructing arcs from approximate line segments is less accurate than fitting arcs to the exact curve.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Convert splines and ellipses directly to circular arcs (and lines where necessary) during import, using the exact curve geometry. No user review step — the import produces the best representation automatically.
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| When to convert | During import (automatic) | User didn't ask for 200 lines; produce best representation |
|
|
||||||
| Tolerance | 0.001" default | Tighter than simplifier's 0.004" because we have exact curves |
|
|
||||||
| Ellipse method | Analytical (curvature-based) | We have the exact parametric form |
|
|
||||||
| Spline method | Sample-then-fit | ACadSharp provides `PolygonalVertexes()` for accurate curve points |
|
|
||||||
| Fallback | Keep line segments where arcs can't fit | Handles rapid curvature changes in splines |
|
|
||||||
| Junction continuity | G1 (tangent) continuity | Normal-constrained arc centers prevent serrated edges |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two new classes in `OpenNest.Core/Geometry/`:
|
|
||||||
|
|
||||||
### EllipseConverter
|
|
||||||
|
|
||||||
**Input:** Ellipse parameters — center, semi-major axis length, semi-minor axis length, rotation angle, start parameter, end parameter, tolerance.
|
|
||||||
|
|
||||||
**Output:** `List<Entity>` containing arcs that approximate the ellipse within tolerance.
|
|
||||||
|
|
||||||
**Algorithm — normal-constrained arc fitting:**
|
|
||||||
|
|
||||||
1. Compute an initial set of split parameters along the ellipse. Start with quadrant boundaries (points of maximum/minimum curvature) as natural split candidates.
|
|
||||||
2. For each pair of consecutive split points (t_start, t_end):
|
|
||||||
a. Compute the ellipse normal at both endpoints analytically.
|
|
||||||
b. Find the arc center at the intersection of the two normals. This guarantees the arc is tangent to the ellipse at both endpoints (G1 continuity).
|
|
||||||
c. Compute the arc radius from the center to either endpoint.
|
|
||||||
d. Sample several points on the ellipse between t_start and t_end, and measure the maximum radial deviation from the fitted arc.
|
|
||||||
e. If deviation exceeds tolerance, subdivide: insert a split point at the midpoint and retry both halves.
|
|
||||||
f. If deviation is within tolerance, emit the arc.
|
|
||||||
3. Continue until all segments are within tolerance.
|
|
||||||
|
|
||||||
**Ellipse analytical formulas (in local coordinates before rotation):**
|
|
||||||
|
|
||||||
- Point: `P(t) = (a cos t, b sin t)`
|
|
||||||
- Tangent: `T(t) = (-a sin t, b cos t)`
|
|
||||||
- Normal (inward): perpendicular to tangent, pointing toward center of curvature
|
|
||||||
- Curvature: `k(t) = ab / (a^2 sin^2 t + b^2 cos^2 t)^(3/2)`
|
|
||||||
|
|
||||||
After computing in local coordinates, rotate by the ellipse's major axis angle and translate to center.
|
|
||||||
|
|
||||||
**Arc count:** Depends on eccentricity and tolerance. A nearly-circular ellipse needs 1-2 arcs. A highly eccentric one (ratio < 0.3) may need 8-16. Tolerance drives this automatically via subdivision.
|
|
||||||
|
|
||||||
**Closed ellipse handling:** When the ellipse sweep is approximately 2pi, ensure the last arc's endpoint connects back to the first arc's start point. Tangent continuity wraps around.
|
|
||||||
|
|
||||||
### SplineConverter
|
|
||||||
|
|
||||||
**Input:** List of points evaluated on the spline curve (from ACadSharp's `PolygonalVertexes`), tolerance, and whether the spline is closed.
|
|
||||||
|
|
||||||
**Output:** `List<Entity>` containing arcs and lines that approximate the spline within tolerance.
|
|
||||||
|
|
||||||
**Algorithm — tangent-chained greedy arc fitting:**
|
|
||||||
|
|
||||||
1. Evaluate the spline at high density using `PolygonalVertexes(precision)` where precision comes from the existing `SplinePrecision` setting.
|
|
||||||
2. Walk the evaluated points from the start:
|
|
||||||
a. At the current segment start, compute the tangent direction from the first two points (or from the chained tangent of the previous arc).
|
|
||||||
b. Fit an arc constrained to be tangent at the start point:
|
|
||||||
- The arc center lies on the normal to the tangent at the start point.
|
|
||||||
- Use the perpendicular bisector of the chord from start to candidate end point, intersected with the start normal, to find the center.
|
|
||||||
c. Extend the arc forward point-by-point. At each extension, recompute the center (intersection of start normal and chord bisector to the new endpoint) and check that all intermediate points are within tolerance of the arc.
|
|
||||||
d. When adding the next point would exceed tolerance, finalize the arc with the last good endpoint.
|
|
||||||
e. Compute the tangent at the arc's end point (perpendicular to the radius at that point) and chain it to the next segment.
|
|
||||||
3. If fewer than 3 points remain in a run where no arc fits (curvature changes too rapidly), emit line segments instead.
|
|
||||||
4. For closed splines, chain the final arc's tangent back to constrain the first arc.
|
|
||||||
|
|
||||||
**This is essentially the same approach GeometrySimplifier uses** (tangent chaining via `chainedTangent`), but operating on densely-sampled curve points rather than pre-existing line segments.
|
|
||||||
|
|
||||||
### Changes to Existing Code
|
|
||||||
|
|
||||||
#### Extensions.cs
|
|
||||||
|
|
||||||
```
|
|
||||||
// Before: returns List<Line>
|
|
||||||
public static List<Geometry.Line> ToOpenNest(this Spline spline)
|
|
||||||
|
|
||||||
// After: returns List<Entity>
|
|
||||||
public static List<Entity> ToOpenNest(this Spline spline, int precision)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Extracts ACadSharp spline data, calls `SplineConverter.Convert()`
|
|
||||||
- Now accepts `precision` parameter (was ignored before)
|
|
||||||
|
|
||||||
```
|
|
||||||
// Before: returns List<Line>
|
|
||||||
public static List<Geometry.Line> ToOpenNest(this Ellipse ellipse, int precision = 200)
|
|
||||||
|
|
||||||
// After: returns List<Entity>
|
|
||||||
public static List<Entity> ToOpenNest(this Ellipse ellipse, double tolerance = 0.001)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Extracts ACadSharp ellipse parameters, calls `EllipseConverter.Convert()`
|
|
||||||
- Precision parameter replaced by tolerance (precision is no longer relevant)
|
|
||||||
|
|
||||||
Both methods preserve Layer, Color, and LineTypeName on the output entities.
|
|
||||||
|
|
||||||
#### DxfImporter.cs
|
|
||||||
|
|
||||||
Currently collects `List<Line>` and `List<Arc>` separately. The new converters return `List<Entity>` (mixed arcs and lines). Options:
|
|
||||||
|
|
||||||
- Sort returned entities into the existing `lines` and `arcs` lists by type, OR
|
|
||||||
- Switch to a single `List<Entity>` collection
|
|
||||||
|
|
||||||
The simpler change is to sort into existing lists so downstream code (GeometryOptimizer, ShapeBuilder) is unaffected.
|
|
||||||
|
|
||||||
### What Stays the Same
|
|
||||||
|
|
||||||
- **GeometrySimplifier** — still exists for user-triggered simplification of genuinely line-based geometry (e.g., DXF files that actually contain line segments, or polylines)
|
|
||||||
- **GeometryOptimizer** — still merges collinear lines and coradial arcs post-import. May merge adjacent arcs produced by the new converters if they happen to be coradial.
|
|
||||||
- **ShapeBuilder, ConvertGeometry, CNC pipeline** — unchanged, they already handle mixed Line/Arc entities
|
|
||||||
- **SplinePrecision setting** — still used for spline point evaluation density
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **EllipseConverter unit tests:**
|
|
||||||
- Circle (ratio = 1.0) produces 1-2 arcs
|
|
||||||
- Moderate ellipse produces arcs within tolerance
|
|
||||||
- Highly eccentric ellipse produces more arcs, all within tolerance
|
|
||||||
- Partial ellipse (elliptical arc) works correctly
|
|
||||||
- Endpoint continuity: each arc's end matches the next arc's start
|
|
||||||
- Tangent continuity: no discontinuities at junctions
|
|
||||||
- Closed ellipse: last arc connects back to first
|
|
||||||
|
|
||||||
- **SplineConverter unit tests:**
|
|
||||||
- Circular arc spline produces a single arc
|
|
||||||
- S-curve spline produces arcs + lines where needed
|
|
||||||
- Straight-line spline produces a line (not degenerate arcs)
|
|
||||||
- Closed spline: endpoints connect
|
|
||||||
- Tangent chaining: smooth transitions between consecutive arcs
|
|
||||||
|
|
||||||
- **Integration test:**
|
|
||||||
- Import a DXF with splines and ellipses, verify the result contains arcs (not 200 lines)
|
|
||||||
- Compare bounding boxes to ensure geometry is preserved
|
|
||||||
Reference in New Issue
Block a user