Compare commits
6 Commits
036f723876
...
6ce501da11
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ce501da11 | |||
| 05037bc928 | |||
| f83df3a55a | |||
| 84ad39414a | |||
| fdb4a2373a | |||
| 3a0267c041 |
@@ -211,5 +211,8 @@ FakesAssemblies/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Documentation (manuals, templates, etc.)
|
||||
docs/
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
@@ -579,43 +579,38 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape outward by the given distance, detecting winding direction
|
||||
/// to choose the correct offset side. Falls back to the opposite side if the
|
||||
/// bounding box shrinks (indicating the offset went inward).
|
||||
/// Offsets the shape outward by the given distance.
|
||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetOutward(double distance)
|
||||
{
|
||||
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)
|
||||
return null;
|
||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||
var copy = new Shape();
|
||||
|
||||
UpdateBounds();
|
||||
var originalBB = BoundingBox;
|
||||
result.UpdateBounds();
|
||||
var offsetBB = result.BoundingBox;
|
||||
|
||||
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Trace.TraceWarning(
|
||||
"Shape.OffsetOutward: offset shrank bounding box " +
|
||||
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
|
||||
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
|
||||
"Retrying with opposite side.");
|
||||
|
||||
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
|
||||
var retry = OffsetEntity(distance, opposite) as Shape;
|
||||
|
||||
if (retry != null)
|
||||
result = retry;
|
||||
switch (Entities[i])
|
||||
{
|
||||
case Line l:
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
@@ -26,9 +27,9 @@ namespace OpenNest
|
||||
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)
|
||||
@@ -132,10 +133,12 @@ namespace OpenNest
|
||||
|
||||
protected virtual void RunPipeline(FillContext context)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
var classification = PartClassifier.Classify(context.Item.Drawing);
|
||||
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;
|
||||
|
||||
try
|
||||
|
||||
@@ -7,31 +7,68 @@ using System.Linq;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly HashSet<double> knownGoodAngles = new();
|
||||
|
||||
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)
|
||||
return BuildPrunedList(baseAngles);
|
||||
|
||||
var angles = new List<double>(baseAngles);
|
||||
|
||||
if (ForceFullSweep)
|
||||
AddSweepAngles(angles);
|
||||
// Full 5-degree sweep for irregular parts.
|
||||
AddSweepAngles(angles);
|
||||
|
||||
if (!ForceFullSweep && angles.Count > 2)
|
||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||
// ML prediction complements the sweep when available.
|
||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||
|
||||
return angles;
|
||||
}
|
||||
@@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill
|
||||
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)
|
||||
{
|
||||
foreach (var ar in angleResults)
|
||||
|
||||
@@ -26,9 +26,9 @@ namespace OpenNest
|
||||
|
||||
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)));
|
||||
return baseAngles;
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ namespace OpenNest
|
||||
|
||||
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) { }
|
||||
|
||||
@@ -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 System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -7,33 +7,50 @@ namespace OpenNest.RectanglePacking
|
||||
{
|
||||
internal class PackBottomLeft : PackEngine
|
||||
{
|
||||
private List<Vector> points;
|
||||
|
||||
public PackBottomLeft(Bin bin)
|
||||
: base(bin)
|
||||
{
|
||||
points = new List<Vector>();
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
|
||||
if (skip.Contains(item.Id))
|
||||
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 (item.IsRotated)
|
||||
item.Rotate();
|
||||
skip.Add(item.Id);
|
||||
continue;
|
||||
}
|
||||
@@ -44,23 +61,37 @@ namespace OpenNest.RectanglePacking
|
||||
points.Add(new Vector(item.Left, item.Top));
|
||||
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);
|
||||
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
var point = points[i];
|
||||
|
||||
item.Location = point;
|
||||
|
||||
if (!IsValid(item))
|
||||
if (!IsValid(item, placed))
|
||||
continue;
|
||||
|
||||
if (point.X < pt.X)
|
||||
@@ -75,12 +106,12 @@ namespace OpenNest.RectanglePacking
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsValid(Item item)
|
||||
private bool IsValid(Item item, List<Item> placed)
|
||||
{
|
||||
if (!Bin.Contains(item))
|
||||
return false;
|
||||
|
||||
foreach (var it in Bin.Items)
|
||||
foreach (var it in placed)
|
||||
{
|
||||
if (item.Intersects(it))
|
||||
return false;
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
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 bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
@@ -15,6 +16,7 @@ namespace OpenNest.Engine.Strategies
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
public FillPolicy Policy { get; init; }
|
||||
public PartType PartType { get; set; }
|
||||
|
||||
public List<Part> CurrentBest { get; set; }
|
||||
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||
|
||||
@@ -20,6 +20,9 @@ namespace OpenNest.Engine.Strategies
|
||||
if (active.Value)
|
||||
return null;
|
||||
|
||||
if (context.PartType == PartType.Circle)
|
||||
return null;
|
||||
|
||||
active.Value = true;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -24,9 +24,9 @@ namespace OpenNest
|
||||
|
||||
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)));
|
||||
return baseAngles;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
@@ -16,6 +18,9 @@ public class AngleCandidateBuilderTests
|
||||
return new Drawing("rect", pgm);
|
||||
}
|
||||
|
||||
private static ClassificationResult MakeClassification(double primaryAngle = 0, PartType type = PartType.Irregular)
|
||||
=> new ClassificationResult { PrimaryAngle = primaryAngle, Type = type };
|
||||
|
||||
[Fact]
|
||||
public void Build_ReturnsAtLeastTwoAngles()
|
||||
{
|
||||
@@ -23,21 +28,21 @@ public class AngleCandidateBuilderTests
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NarrowWorkArea_UsesBaseAnglesOnly()
|
||||
public void Build_RectangleType_NarrowWorkArea_UsesBaseAnglesOnly()
|
||||
{
|
||||
var builder = new AngleCandidateBuilder();
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -48,7 +53,7 @@ public class AngleCandidateBuilderTests
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
|
||||
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
|
||||
Assert.True(angles.Count > 10);
|
||||
@@ -62,7 +67,7 @@ public class AngleCandidateBuilderTests
|
||||
var workArea = new Box(0, 0, 100, 8);
|
||||
|
||||
// First build — full sweep
|
||||
var firstAngles = builder.Build(item, 0, workArea);
|
||||
var firstAngles = builder.Build(item, MakeClassification(), workArea);
|
||||
|
||||
// Record some as productive
|
||||
var productive = new List<AngleResult>
|
||||
@@ -74,9 +79,77 @@ public class AngleCandidateBuilderTests
|
||||
|
||||
// Second build — should be pruned to known-good + base angles
|
||||
builder.ForceFullSweep = false;
|
||||
var secondAngles = builder.Build(item, 0, workArea);
|
||||
var secondAngles = builder.Build(item, MakeClassification(), workArea);
|
||||
|
||||
Assert.True(secondAngles.Count < 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.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
@@ -33,7 +34,7 @@ public class StrategyOverlapTests
|
||||
|
||||
var strategies = FillStrategyRegistry.Strategies.ToList();
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
||||
var classification = PartClassifier.Classify(drawing);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var strategy in strategies)
|
||||
@@ -50,9 +51,10 @@ public class StrategyOverlapTests
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Policy = policy,
|
||||
};
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||
context.SharedState["Classification"] = classification;
|
||||
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
|
||||
item, bestRotation, context.WorkArea);
|
||||
item, classification, context.WorkArea);
|
||||
|
||||
var parts = strategy.Fill(context);
|
||||
var count = parts?.Count ?? 0;
|
||||
|
||||
+60
-37
@@ -49,41 +49,41 @@ namespace OpenNest.Forms
|
||||
((System.ComponentModel.ISupportInitialize)numQuantity).BeginInit();
|
||||
bottomPanel1.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
//
|
||||
// mainSplit
|
||||
//
|
||||
//
|
||||
mainSplit.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
mainSplit.FixedPanel = System.Windows.Forms.FixedPanel.Panel1;
|
||||
mainSplit.Location = new System.Drawing.Point(0, 0);
|
||||
mainSplit.Name = "mainSplit";
|
||||
//
|
||||
//
|
||||
// mainSplit.Panel1
|
||||
//
|
||||
//
|
||||
mainSplit.Panel1.Controls.Add(sidebarSplit);
|
||||
mainSplit.Panel1MinSize = 200;
|
||||
//
|
||||
//
|
||||
// mainSplit.Panel2
|
||||
//
|
||||
//
|
||||
mainSplit.Panel2.Controls.Add(entityView1);
|
||||
mainSplit.Panel2.Controls.Add(detailBar);
|
||||
mainSplit.Size = new System.Drawing.Size(1024, 670);
|
||||
mainSplit.SplitterDistance = 260;
|
||||
mainSplit.SplitterWidth = 5;
|
||||
mainSplit.TabIndex = 2;
|
||||
//
|
||||
//
|
||||
// sidebarSplit
|
||||
//
|
||||
//
|
||||
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
sidebarSplit.Location = new System.Drawing.Point(0, 0);
|
||||
sidebarSplit.Name = "sidebarSplit";
|
||||
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
|
||||
//
|
||||
//
|
||||
// sidebarSplit.Panel1
|
||||
//
|
||||
//
|
||||
sidebarSplit.Panel1.Controls.Add(fileList);
|
||||
//
|
||||
//
|
||||
// sidebarSplit.Panel2
|
||||
//
|
||||
//
|
||||
sidebarSplit.Panel2.Controls.Add(filterPanel);
|
||||
sidebarSplit.Size = new System.Drawing.Size(260, 670);
|
||||
sidebarSplit.SplitterDistance = 300;
|
||||
@@ -116,8 +116,15 @@ namespace OpenNest.Forms
|
||||
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
|
||||
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
|
||||
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
entityView1.IsPickingBendLine = false;
|
||||
entityView1.Location = new System.Drawing.Point(0, 0);
|
||||
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.TabIndex = 0;
|
||||
//
|
||||
@@ -215,52 +222,68 @@ namespace OpenNest.Forms
|
||||
//
|
||||
btnSplit.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
btnSplit.Font = new System.Drawing.Font("Segoe UI", 9F);
|
||||
btnSplit.Location = new System.Drawing.Point(291, 6);
|
||||
btnSplit.Margin = new System.Windows.Forms.Padding(2, 0, 8, 0);
|
||||
btnSplit.Location = new System.Drawing.Point(289, 6);
|
||||
btnSplit.Margin = new System.Windows.Forms.Padding(0, 0, 10, 0);
|
||||
btnSplit.Name = "btnSplit";
|
||||
btnSplit.Size = new System.Drawing.Size(60, 24);
|
||||
btnSplit.Size = new System.Drawing.Size(60, 27);
|
||||
btnSplit.TabIndex = 6;
|
||||
btnSplit.Text = "Split...";
|
||||
//
|
||||
//
|
||||
// btnSimplify
|
||||
//
|
||||
//
|
||||
btnSimplify.AutoSize = true;
|
||||
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
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.AutoSize = true;
|
||||
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
|
||||
//
|
||||
btnSimplify.Click += OnSimplifyClick;
|
||||
//
|
||||
// btnExportDxf
|
||||
//
|
||||
//
|
||||
btnExportDxf.AutoSize = true;
|
||||
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
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.AutoSize = true;
|
||||
btnExportDxf.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
|
||||
btnExportDxf.Click += new System.EventHandler(this.OnExportDxfClick);
|
||||
//
|
||||
btnExportDxf.Click += OnExportDxfClick;
|
||||
//
|
||||
// chkShowOriginal
|
||||
//
|
||||
//
|
||||
chkShowOriginal.AutoSize = true;
|
||||
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.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.AutoSize = true;
|
||||
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.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.AutoSize = true;
|
||||
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.Name = "lblDetect";
|
||||
lblDetect.Size = new System.Drawing.Size(42, 15);
|
||||
@@ -271,7 +294,7 @@ namespace OpenNest.Forms
|
||||
//
|
||||
cboBendDetector.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
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.Name = "cboBendDetector";
|
||||
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