Compare commits
21 Commits
d2f9597b0c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c25b6bc23a | |||
| 1c994718fb | |||
| 9d58e6fba8 | |||
| 2bae5340f0 | |||
| 0b322817d7 | |||
| e41f335c63 | |||
| 0ab33af5d3 | |||
| e04c9381f3 | |||
| ceb9cc0b44 | |||
| 4cecaba83a | |||
| 4053f1f989 | |||
| ca67b1bd29 | |||
| 199095ee43 | |||
| eb493d501a | |||
| 6c98732117 | |||
| a2e9fd4d14 | |||
| d228b6b812 | |||
| c634aecd4b | |||
| 14b7c1cf32 | |||
| 402af91af5 | |||
| 9a6b656e3c |
@@ -151,7 +151,8 @@ static class NestConsole
|
|||||||
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|
||||||
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||||
var dxfFiles = options.InputFiles.Where(f =>
|
var dxfFiles = options.InputFiles.Where(f =>
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
// If we have a nest file, load it and optionally add DXFs.
|
// If we have a nest file, load it and optionally add DXFs.
|
||||||
if (nestFile != null)
|
if (nestFile != null)
|
||||||
@@ -187,7 +188,7 @@ static class NestConsole
|
|||||||
// DXF-only mode: create a fresh nest.
|
// DXF-only mode: create a fresh nest.
|
||||||
if (dxfFiles.Count == 0)
|
if (dxfFiles.Count == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
|
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +462,7 @@ static class NestConsole
|
|||||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Arguments:");
|
Console.Error.WriteLine("Arguments:");
|
||||||
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
|
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
|
||||||
Console.Error.WriteLine();
|
Console.Error.WriteLine();
|
||||||
Console.Error.WriteLine("Modes:");
|
Console.Error.WriteLine("Modes:");
|
||||||
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
|
||||||
|
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
|
||||||
|
/// a circular dependency on OpenNest.Engine.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalAngle
|
||||||
|
{
|
||||||
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
|
public const double SnapToZero = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
||||||
|
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
||||||
|
/// across both callers.
|
||||||
|
/// </summary>
|
||||||
|
public static double FromMbr(BoundingRectangleResult mbr)
|
||||||
|
{
|
||||||
|
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
// The MBR edge angle can represent any of four equivalent orientations
|
||||||
|
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
|
||||||
|
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
|
||||||
|
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
|
||||||
|
// ANY of the equivalent orientations.
|
||||||
|
var angle = -mbr.Angle;
|
||||||
|
const double halfPi = System.Math.PI / 2.0;
|
||||||
|
angle -= halfPi * System.Math.Round(angle / halfPi);
|
||||||
|
|
||||||
|
if (System.Math.Abs(angle) < SnapToZero)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Compute(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing?.Program == null)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var polygon = perimeter.ToPolygonWithTolerance(0.1);
|
||||||
|
if (polygon == null || polygon.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||||
|
if (hull.Vertices.Count < 3)
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||||
|
return FromMbr(mbr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
@@ -81,12 +82,21 @@ namespace OpenNest.Converters
|
|||||||
var startpt = arc.StartPoint();
|
var startpt = arc.StartPoint();
|
||||||
var endpt = arc.EndPoint();
|
var endpt = arc.EndPoint();
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
lastpt = endpt;
|
lastpt = endpt;
|
||||||
|
|
||||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
|
{
|
||||||
|
pgm.LineTo(endpt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||||
|
}
|
||||||
|
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +104,7 @@ namespace OpenNest.Converters
|
|||||||
{
|
{
|
||||||
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||||
|
|
||||||
if (startpt != lastpt)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||||
@@ -105,7 +115,7 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
|
||||||
{
|
{
|
||||||
if (line.StartPoint != lastpt)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
var move = new LinearMove(line.EndPoint);
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ namespace OpenNest
|
|||||||
Id = Interlocked.Increment(ref nextId);
|
Id = Interlocked.Increment(ref nextId);
|
||||||
Name = name;
|
Name = name;
|
||||||
Material = new Material();
|
Material = new Material();
|
||||||
Program = pgm;
|
|
||||||
Constraints = new NestConstraints();
|
Constraints = new NestConstraints();
|
||||||
Source = new SourceInfo();
|
Source = new SourceInfo();
|
||||||
|
Program = pgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Id { get; }
|
public int Id { get; }
|
||||||
@@ -78,9 +78,29 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
program = value;
|
program = value;
|
||||||
UpdateArea();
|
UpdateArea();
|
||||||
|
RecomputeCanonicalAngle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes and stores the canonical angle from the current Program.
|
||||||
|
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
|
||||||
|
/// Cut-off drawings are left with Angle=0.
|
||||||
|
/// </summary>
|
||||||
|
public void RecomputeCanonicalAngle()
|
||||||
|
{
|
||||||
|
if (Source == null)
|
||||||
|
Source = new SourceInfo();
|
||||||
|
|
||||||
|
if (program == null || IsCutOff)
|
||||||
|
{
|
||||||
|
Source.Angle = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Source.Angle = CanonicalAngle.Compute(this);
|
||||||
|
}
|
||||||
|
|
||||||
public Color Color { get; set; }
|
public Color Color { get; set; }
|
||||||
|
|
||||||
public bool IsCutOff { get; set; }
|
public bool IsCutOff { get; set; }
|
||||||
@@ -163,5 +183,15 @@ namespace OpenNest
|
|||||||
/// Offset distances to the original location.
|
/// Offset distances to the original location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Vector Offset { get; set; }
|
public Vector Offset { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotation (radians) that maps the source program geometry to its canonical
|
||||||
|
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
|
||||||
|
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
|
||||||
|
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
|
||||||
|
/// <see cref="Drawing.Program"/> in place must invoke
|
||||||
|
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
|
||||||
|
/// </summary>
|
||||||
|
public double Angle { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,26 +404,29 @@ namespace OpenNest.Geometry
|
|||||||
maxY = startpt.Y;
|
maxY = startpt.Y;
|
||||||
}
|
}
|
||||||
|
|
||||||
var angle1 = StartAngle;
|
var sweep = SweepAngle();
|
||||||
var angle2 = EndAngle;
|
if (sweep > Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var angle1 = StartAngle;
|
||||||
|
var angle2 = EndAngle;
|
||||||
|
|
||||||
// switch the angle to counter clockwise.
|
if (IsReversed)
|
||||||
if (IsReversed)
|
Generic.Swap(ref angle1, ref angle2);
|
||||||
Generic.Swap(ref angle1, ref angle2);
|
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
|
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
|
||||||
maxY = Center.Y + Radius;
|
maxY = Center.Y + Radius;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
|
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
|
||||||
minX = Center.X - Radius;
|
minX = Center.X - Radius;
|
||||||
|
|
||||||
const double oneHalfPI = System.Math.PI * 1.5;
|
const double oneHalfPI = System.Math.PI * 1.5;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
|
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
|
||||||
minY = Center.Y - Radius;
|
minY = Center.Y - Radius;
|
||||||
|
|
||||||
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
|
||||||
maxX = Center.X + Radius;
|
maxX = Center.X + Radius;
|
||||||
|
}
|
||||||
|
|
||||||
boundingBox.X = minX;
|
boundingBox.X = minX;
|
||||||
boundingBox.Y = minY;
|
boundingBox.Y = minY;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using OpenNest.Math;
|
using System;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public class Box
|
public class Box : IComparable<Box>
|
||||||
{
|
{
|
||||||
public static readonly Box Empty = new Box();
|
public static readonly Box Empty = new Box();
|
||||||
|
|
||||||
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int CompareTo(Box other)
|
||||||
|
{
|
||||||
|
var cmp = Width.CompareTo(other.Width);
|
||||||
|
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
|
||||||
|
|
||||||
|
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
|
||||||
|
|
||||||
|
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
|
||||||
|
|
||||||
|
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
if (maxDev <= tolerance)
|
if (maxDev <= tolerance)
|
||||||
{
|
{
|
||||||
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
|
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
|
||||||
|
if (arc.SweepAngle() < Tolerance.Epsilon)
|
||||||
|
results.Add(new Line(p0, p1));
|
||||||
|
else
|
||||||
|
results.Add(arc);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public static class ShapeBuilder
|
public static class ShapeBuilder
|
||||||
{
|
{
|
||||||
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
|
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
|
||||||
{
|
{
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var arcs = new List<Arc>();
|
var arcs = new List<Arc>();
|
||||||
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
|
|||||||
entityList.AddRange(lines);
|
entityList.AddRange(lines);
|
||||||
entityList.AddRange(arcs);
|
entityList.AddRange(arcs);
|
||||||
|
|
||||||
|
if (weldTolerance.HasValue)
|
||||||
|
WeldEndpoints(entityList, weldTolerance.Value);
|
||||||
|
|
||||||
while (entityList.Count > 0)
|
while (entityList.Count > 0)
|
||||||
{
|
{
|
||||||
var next = entityList[0];
|
var next = entityList[0];
|
||||||
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
|
|||||||
return shapes;
|
return shapes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void WeldEndpoints(List<Entity> entities, double tolerance)
|
||||||
|
{
|
||||||
|
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
var (start, end) = GetEndpoints(entity);
|
||||||
|
if (!start.IsValid() || !end.IsValid())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AddToGroup(endpointGroups, entity, true, start, tolerance);
|
||||||
|
AddToGroup(endpointGroups, entity, false, end, tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in endpointGroups)
|
||||||
|
{
|
||||||
|
if (group.Count <= 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var avgX = group.Average(g => g.point.X);
|
||||||
|
var avgY = group.Average(g => g.point.Y);
|
||||||
|
var weldedPoint = new Vector(avgX, avgY);
|
||||||
|
|
||||||
|
foreach (var (entity, isStart, _) in group)
|
||||||
|
ApplyWeld(entity, isStart, weldedPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToGroup(
|
||||||
|
List<List<(Entity entity, bool isStart, Vector point)>> groups,
|
||||||
|
Entity entity, bool isStart, Vector point, double tolerance)
|
||||||
|
{
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group[0].point.DistanceTo(point) <= tolerance)
|
||||||
|
{
|
||||||
|
group.Add((entity, isStart, point));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Vector start, Vector end) GetEndpoints(Entity entity)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
return (arc.StartPoint(), arc.EndPoint());
|
||||||
|
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
return (line.StartPoint, line.EndPoint);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (Vector.Invalid, Vector.Invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
|
||||||
|
{
|
||||||
|
switch (entity.Type)
|
||||||
|
{
|
||||||
|
case EntityType.Line:
|
||||||
|
var line = (Line)entity;
|
||||||
|
if (isStart)
|
||||||
|
line.StartPoint = weldedPoint;
|
||||||
|
else
|
||||||
|
line.EndPoint = weldedPoint;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EntityType.Arc:
|
||||||
|
var arc = (Arc)entity;
|
||||||
|
var deltaX = weldedPoint.X - arc.Center.X;
|
||||||
|
var deltaY = weldedPoint.Y - arc.Center.Y;
|
||||||
|
var angle = System.Math.Atan2(deltaY, deltaX);
|
||||||
|
|
||||||
|
if (isStart)
|
||||||
|
arc.StartAngle = angle;
|
||||||
|
else
|
||||||
|
arc.EndAngle = angle;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
|
||||||
{
|
{
|
||||||
var tol = Tolerance.ChainTolerance;
|
var tol = Tolerance.ChainTolerance;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace OpenNest.IO.Bom
|
namespace OpenNest.Math
|
||||||
{
|
{
|
||||||
public static class Fraction
|
public static class Fraction
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (_cache.TryGetValue(key, out var cached))
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
return cached;
|
return cached;
|
||||||
|
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
IPairEvaluator evaluator = null;
|
IPairEvaluator evaluator = null;
|
||||||
ISlideComputer slideComputer = null;
|
ISlideComputer slideComputer = null;
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
|
|
||||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||||
var results = finder.FindBestFits(drawing, spacing, StepSize);
|
var results = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
_cache.TryAdd(key, results);
|
_cache.TryAdd(key, results);
|
||||||
return results;
|
return results;
|
||||||
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
if (CreateEvaluator != null)
|
if (CreateEvaluator != null)
|
||||||
{
|
{
|
||||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
try { evaluator = CreateEvaluator(canonical, spacing); }
|
||||||
catch { /* fall back to default evaluator */ }
|
catch { /* fall back to default evaluator */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
// Compute candidates and evaluate once with the largest plate.
|
// Compute candidates and evaluate once with the largest plate.
|
||||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||||
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
|
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
|
||||||
|
|
||||||
// Cache a filtered copy for each plate size.
|
// Cache a filtered copy for each plate size.
|
||||||
foreach (var size in needed)
|
foreach (var size in needed)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
|
||||||
|
/// and un-rotates placed parts back to the drawing's original frame.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
||||||
|
/// The source drawing is not mutated.
|
||||||
|
/// </summary>
|
||||||
|
public static Drawing AsCanonicalCopy(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var angle = drawing.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
|
// Clone program (never mutate the source).
|
||||||
|
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
|
||||||
|
?? new OpenNest.CNC.Program();
|
||||||
|
|
||||||
|
if (!Tolerance.IsEqualTo(angle, 0))
|
||||||
|
pgm.Rotate(angle, pgm.BoundingBox().Center);
|
||||||
|
|
||||||
|
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
|
||||||
|
{
|
||||||
|
Color = drawing.Color,
|
||||||
|
Constraints = drawing.Constraints,
|
||||||
|
Material = drawing.Material,
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
Customer = drawing.Customer,
|
||||||
|
IsCutOff = drawing.IsCutOff,
|
||||||
|
Source = new SourceInfo
|
||||||
|
{
|
||||||
|
Path = drawing.Source?.Path,
|
||||||
|
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
|
||||||
|
Angle = 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the source drawing's canonical angle onto each placed part so the
|
||||||
|
/// returned list is in the drawing's original (visible) frame.
|
||||||
|
///
|
||||||
|
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
|
||||||
|
/// Canonical part at rotation R shows visible orientation R.
|
||||||
|
/// Source part at rotation R' shows visible orientation R' + (-S), because the
|
||||||
|
/// source geometry is already rotated by -S relative to canonical.
|
||||||
|
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
|
||||||
|
///
|
||||||
|
/// Rotation is performed around the part's Location so its placement position is preserved;
|
||||||
|
/// only the orientation composes.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (placed == null || placed.Count == 0)
|
||||||
|
return placed;
|
||||||
|
if (Tolerance.IsEqualTo(sourceAngle, 0))
|
||||||
|
return placed;
|
||||||
|
|
||||||
|
foreach (var p in placed)
|
||||||
|
p.Rotate(sourceAngle, p.Location);
|
||||||
|
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,14 +47,29 @@ namespace OpenNest
|
|||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
|
|
||||||
// Fast path: for very small quantities, skip the full strategy pipeline.
|
// Replace the item's Drawing with a canonical copy for the duration of this fill.
|
||||||
if (item.Quantity > 0 && item.Quantity <= 2)
|
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
|
||||||
|
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
||||||
|
var originalDrawing = item.Drawing;
|
||||||
|
var canonicalItem = new NestItem
|
||||||
{
|
{
|
||||||
var fast = TryFillSmallQuantity(item, workArea);
|
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
|
||||||
if (fast != null && fast.Count >= item.Quantity)
|
Quantity = item.Quantity,
|
||||||
|
Priority = item.Priority,
|
||||||
|
RotationStart = item.RotationStart,
|
||||||
|
RotationEnd = item.RotationEnd,
|
||||||
|
StepAngle = item.StepAngle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fast path for qty 1-2.
|
||||||
|
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
|
||||||
|
{
|
||||||
|
var fast = TryFillSmallQuantity(canonicalItem, workArea);
|
||||||
|
if (fast != null && fast.Count >= canonicalItem.Quantity)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
|
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
|
||||||
WinnerPhase = NestPhase.Pairs;
|
WinnerPhase = NestPhase.Pairs;
|
||||||
|
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
|
||||||
ReportProgress(progress, new ProgressReport
|
ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
Phase = WinnerPhase,
|
Phase = WinnerPhase,
|
||||||
@@ -68,32 +83,30 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For low quantities, shrink the work area in both dimensions to avoid
|
|
||||||
// running expensive strategies against the full plate.
|
|
||||||
var effectiveWorkArea = workArea;
|
var effectiveWorkArea = workArea;
|
||||||
if (item.Quantity > 0)
|
if (canonicalItem.Quantity > 0)
|
||||||
{
|
{
|
||||||
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
|
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
|
||||||
|
|
||||||
if (effectiveWorkArea != workArea)
|
if (effectiveWorkArea != workArea)
|
||||||
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " +
|
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
|
||||||
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
||||||
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
|
var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
|
||||||
|
|
||||||
// Fallback: if the reduced area didn't yield enough, retry with full area.
|
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
|
||||||
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
|
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
|
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
|
||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
best = RunFillPipeline(item, workArea, progress, token);
|
best = RunFillPipeline(canonicalItem, workArea, progress, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
|
||||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
|
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
|
||||||
|
|
||||||
|
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
|
||||||
|
|
||||||
ReportProgress(progress, new ProgressReport
|
ReportProgress(progress, new ProgressReport
|
||||||
{
|
{
|
||||||
@@ -108,6 +121,31 @@ namespace OpenNest
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
|
||||||
|
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
|
||||||
|
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
|
||||||
|
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
|
||||||
|
var rebound = Part.CreateAtOrigin(original, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalFrame.FromCanonical(parts, sourceAngle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
||||||
/// without running the full strategy pipeline.
|
/// without running the full strategy pipeline.
|
||||||
@@ -139,6 +177,10 @@ namespace OpenNest
|
|||||||
var bestFits = BestFitCache.GetOrCompute(
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
|
|
||||||
|
// Build pair candidates with a canonical drawing so their geometry matches
|
||||||
|
// the coordinate frame of the cached fit results.
|
||||||
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
List<Part> bestPlacement = null;
|
List<Part> bestPlacement = null;
|
||||||
|
|
||||||
foreach (var fit in bestFits)
|
foreach (var fit in bestFits)
|
||||||
@@ -152,7 +194,7 @@ namespace OpenNest
|
|||||||
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var landscape = fit.BuildParts(drawing);
|
var landscape = fit.BuildParts(canonicalDrawing);
|
||||||
var portrait = RotatePair90(landscape);
|
var portrait = RotatePair90(landscape);
|
||||||
|
|
||||||
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
||||||
@@ -174,6 +216,8 @@ namespace OpenNest
|
|||||||
bestPlacement = candidate;
|
bestPlacement = candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parts are returned in canonical frame, bound to the canonical drawing.
|
||||||
|
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
|
||||||
return bestPlacement;
|
return bestPlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
|
|||||||
{
|
{
|
||||||
public static PartFeatures Extract(Drawing drawing)
|
public static PartFeatures Extract(Drawing drawing)
|
||||||
{
|
{
|
||||||
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
|
// Normalize to canonical frame so features are invariant to import orientation.
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
|
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
|
|||||||
|
|
||||||
var features = new PartFeatures
|
var features = new PartFeatures
|
||||||
{
|
{
|
||||||
Area = drawing.Area,
|
Area = canonical.Area,
|
||||||
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
|
Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
|
||||||
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
|
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
|
||||||
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
||||||
VertexCount = polygon.Vertices.Count,
|
VertexCount = polygon.Vertices.Count,
|
||||||
Bitmask = GenerateBitmask(polygon, 32)
|
Bitmask = GenerateBitmask(polygon, 32)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Circularity = 4 * PI * Area / Perimeter^2
|
// Circularity = 4 * PI * Area / Perimeter^2
|
||||||
var perimeterLen = polygon.Perimeter();
|
var perimeterLen = polygon.Perimeter();
|
||||||
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
|
features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
|
||||||
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
|
features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
|
||||||
|
|
||||||
return features;
|
return features;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,6 +334,12 @@ namespace OpenNest
|
|||||||
var bestFits = BestFitCache.GetOrCompute(
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
|
|
||||||
|
// BestFitCache stores pair coordinates in canonical frame. Build candidates
|
||||||
|
// from a canonical drawing copy so geometry and coords share a frame; rebind
|
||||||
|
// + un-rotate winning pair to the original drawing's frame before returning.
|
||||||
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
|
||||||
|
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
List<Part> bestPlacement = null;
|
List<Part> bestPlacement = null;
|
||||||
Box bestTarget = null;
|
Box bestTarget = null;
|
||||||
|
|
||||||
@@ -342,7 +348,7 @@ namespace OpenNest
|
|||||||
if (!fit.Keep)
|
if (!fit.Keep)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var parts = fit.BuildParts(item.Drawing);
|
var parts = fit.BuildParts(canonicalDrawing);
|
||||||
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||||
var pairW = pairBbox.Width;
|
var pairW = pairBbox.Width;
|
||||||
var pairL = pairBbox.Length;
|
var pairL = pairBbox.Length;
|
||||||
@@ -374,6 +380,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
if (bestPlacement == null) continue;
|
if (bestPlacement == null) continue;
|
||||||
|
|
||||||
|
// Rebind to the original drawing and compose sourceAngle onto rotation so the
|
||||||
|
// final placed parts sit in the user's visible frame.
|
||||||
|
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
|
||||||
|
|
||||||
result.AddRange(bestPlacement);
|
result.AddRange(bestPlacement);
|
||||||
item.Quantity = 0;
|
item.Quantity = 0;
|
||||||
|
|
||||||
@@ -388,6 +398,30 @@ namespace OpenNest
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
|
||||||
|
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
|
||||||
|
/// the returned list is in the original drawing's visible frame. Mirrors
|
||||||
|
/// DefaultNestEngine.RebindAndUnCanonicalize.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
var rebound = Part.CreateAtOrigin(original, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalFrame.FromCanonical(parts, sourceAngle);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
|
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
|
||||||
/// Low-quantity items whose total area is a small fraction of the plate are
|
/// Low-quantity items whose total area is a small fraction of the plate are
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
|
|||||||
var mbrArea = mbr.Area;
|
var mbrArea = mbr.Area;
|
||||||
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
|
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
|
||||||
|
|
||||||
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
|
// Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
|
||||||
result.PrimaryAngle = -mbr.Angle;
|
result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
|
||||||
|
|
||||||
// Drawing perimeter for circularity and perimeter ratio.
|
// Drawing perimeter for circularity and perimeter ratio.
|
||||||
var drawingPerimeter = polygon.Perimeter();
|
var drawingPerimeter = polygon.Perimeter();
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
|
|||||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||||
dxfFiles[nameWithoutExt] = file;
|
dxfFiles[nameWithoutExt] = file;
|
||||||
}
|
}
|
||||||
|
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
|
||||||
|
{
|
||||||
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||||
|
dxfFiles.TryAdd(nameWithoutExt, file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partition items into: skipped, unmatched, or matched (grouped)
|
// Partition items into: skipped, unmatched, or matched (grouped)
|
||||||
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
|
|||||||
|
|
||||||
var lookupName = item.FileName;
|
var lookupName = item.FileName;
|
||||||
|
|
||||||
// Strip .dxf extension if the BOM includes it
|
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
|
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
|
||||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||||
|
|
||||||
if (!folderExists)
|
if (!folderExists)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using ACadSharp;
|
||||||
using OpenNest.Bending;
|
using OpenNest.Bending;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -38,5 +39,11 @@ namespace OpenNest.IO
|
|||||||
/// Default drawing name (filename without extension, unless overridden).
|
/// Default drawing name (filename without extension, unless overridden).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The raw CAD document from the source file. Available for callers
|
||||||
|
/// that need access to non-geometry entities (e.g., text annotations).
|
||||||
|
/// </summary>
|
||||||
|
public CadDocument Document { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using OpenNest.Bending;
|
|||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO.Bending;
|
using OpenNest.IO.Bending;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.IO
|
namespace OpenNest.IO
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,8 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
var dxf = Dxf.Import(path);
|
var dxf = Dxf.Import(path);
|
||||||
|
|
||||||
|
RemoveDuplicateArcs(dxf.Entities);
|
||||||
|
|
||||||
var bends = new List<Bend>();
|
var bends = new List<Bend>();
|
||||||
if (options.DetectBends && dxf.Document != null)
|
if (options.DetectBends && dxf.Document != null)
|
||||||
{
|
{
|
||||||
@@ -44,6 +47,7 @@ namespace OpenNest.IO
|
|||||||
Bounds = dxf.Entities.GetBoundingBox(),
|
Bounds = dxf.Entities.GetBoundingBox(),
|
||||||
SourcePath = path,
|
SourcePath = path,
|
||||||
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
||||||
|
Document = dxf.Document,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,5 +140,33 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
return drawing;
|
return drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static void RemoveDuplicateArcs(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var circles = entities.OfType<Circle>().ToList();
|
||||||
|
var arcs = entities.OfType<Arc>().ToList();
|
||||||
|
var arcsToRemove = new List<Arc>();
|
||||||
|
|
||||||
|
foreach (var arc in arcs)
|
||||||
|
{
|
||||||
|
foreach (var circle in circles)
|
||||||
|
{
|
||||||
|
if (arc.Layer?.Name != circle.Layer?.Name)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!arc.Radius.IsEqualTo(circle.Radius))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
arcsToRemove.Add(arc);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var arc in arcsToRemove)
|
||||||
|
entities.Remove(arc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -27,8 +27,7 @@ namespace OpenNest.IO
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static DxfImportResult Import(string path)
|
public static DxfImportResult Import(string path)
|
||||||
{
|
{
|
||||||
using var reader = new DxfReader(path);
|
var doc = ReadDocument(path);
|
||||||
var doc = reader.Read();
|
|
||||||
|
|
||||||
return new DxfImportResult
|
return new DxfImportResult
|
||||||
{
|
{
|
||||||
@@ -41,8 +40,7 @@ namespace OpenNest.IO
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var reader = new DxfReader(path);
|
var doc = ReadDocument(path);
|
||||||
var doc = reader.Read();
|
|
||||||
return ConvertEntities(doc);
|
return ConvertEntities(doc);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -113,6 +111,23 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
#region Private
|
#region Private
|
||||||
|
|
||||||
|
private static bool IsDwg(string path) =>
|
||||||
|
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static CadDocument ReadDocument(string path)
|
||||||
|
{
|
||||||
|
if (IsDwg(path))
|
||||||
|
{
|
||||||
|
using var reader = new DwgReader(path);
|
||||||
|
return reader.Read();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var reader = new DxfReader(path);
|
||||||
|
return reader.Read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static List<Entity> ConvertEntities(CadDocument doc)
|
private static List<Entity> ConvertEntities(CadDocument doc)
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|||||||
@@ -181,13 +181,22 @@ namespace OpenNest.IO
|
|||||||
{
|
{
|
||||||
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
||||||
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
||||||
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
|
||||||
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
|
||||||
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
|
||||||
|
|
||||||
var startParam = ellipse.StartParameter;
|
var startParam = ellipse.StartParameter;
|
||||||
var endParam = ellipse.EndParameter;
|
var endParam = ellipse.EndParameter;
|
||||||
|
|
||||||
|
if (ellipse.Normal.Z < 0)
|
||||||
|
{
|
||||||
|
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
|
||||||
|
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
|
||||||
|
startParam = newStart;
|
||||||
|
endParam = newEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||||
|
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
||||||
|
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
||||||
|
|
||||||
var layer = ellipse.Layer.ToOpenNest();
|
var layer = ellipse.Layer.ToOpenNest();
|
||||||
var color = ellipse.ResolveColor();
|
var color = ellipse.ResolveColor();
|
||||||
var lineTypeName = ellipse.ResolveLineTypeName();
|
var lineTypeName = ellipse.ResolveLineTypeName();
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<RootNamespace>OpenNest.IO</RootNamespace>
|
<RootNamespace>OpenNest.IO</RootNamespace>
|
||||||
<AssemblyName>OpenNest.IO</AssemblyName>
|
<AssemblyName>OpenNest.IO</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CanonicalAngleTests
|
||||||
|
{
|
||||||
|
private const double AngleTol = 0.002; // ~0.11°
|
||||||
|
|
||||||
|
private static Drawing MakeRect(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Drawing RotateCopy(Drawing src, double angle)
|
||||||
|
{
|
||||||
|
var pgm = src.Program.Clone() as OpenNest.CNC.Program;
|
||||||
|
pgm.Rotate(angle, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("rotated", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AxisAlignedRectangle_ReturnsZero()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
|
||||||
|
// only decrease, so programs whose extents stay in the positive half-plane report a
|
||||||
|
// too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
|
||||||
|
// outer perimeter polygon and compute its true AABB from vertices.
|
||||||
|
private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
var outer = shapes.OrderByDescending(s => s.Area()).First();
|
||||||
|
var poly = outer.ToPolygonWithTolerance(0.1);
|
||||||
|
var minX = poly.Vertices.Min(v => v.X);
|
||||||
|
var maxX = poly.Vertices.Max(v => v.X);
|
||||||
|
var minY = poly.Vertices.Min(v => v.Y);
|
||||||
|
var maxY = poly.Vertices.Max(v => v.Y);
|
||||||
|
return (maxX - minX, maxY - minY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.3)]
|
||||||
|
[InlineData(0.7)]
|
||||||
|
[InlineData(1.2)]
|
||||||
|
public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
|
||||||
|
{
|
||||||
|
var rotated = RotateCopy(MakeRect(100, 50), theta);
|
||||||
|
var angle = CanonicalAngle.Compute(rotated);
|
||||||
|
|
||||||
|
// Applying the returned angle should leave MBR axis-aligned.
|
||||||
|
var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
|
||||||
|
canonical.Rotate(angle, canonical.BoundingBox().Center);
|
||||||
|
|
||||||
|
var (length, width) = TrueAabb(canonical);
|
||||||
|
var longer = System.Math.Max(length, width);
|
||||||
|
var shorter = System.Math.Min(length, width);
|
||||||
|
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
|
||||||
|
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NearZeroInput_SnapsToZero()
|
||||||
|
{
|
||||||
|
var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DegeneratePolygon_ReturnsZero()
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||||
|
var d = new Drawing("line", pgm);
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyProgram_ReturnsZero()
|
||||||
|
{
|
||||||
|
var d = new Drawing("empty", new OpenNest.CNC.Program());
|
||||||
|
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DrawingCanonicalAngleWiringTests
|
||||||
|
{
|
||||||
|
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
|
||||||
|
{
|
||||||
|
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)));
|
||||||
|
if (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
|
||||||
|
pgm.Rotate(theta, pgm.BoundingBox().Center);
|
||||||
|
return pgm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ComputesAngleOnProgramAssignment()
|
||||||
|
{
|
||||||
|
var pgm = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
var d = new Drawing("r", pgm);
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetProgram_RecomputesAngle()
|
||||||
|
{
|
||||||
|
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
|
||||||
|
d.Program = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsCutOff_SkipsAngleComputation()
|
||||||
|
{
|
||||||
|
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
|
||||||
|
// Re-assign after flag is set so the setter observes IsCutOff.
|
||||||
|
d.Program = RotatedRectProgram(100, 50, 0.5);
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
|
||||||
|
{
|
||||||
|
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6);
|
||||||
|
|
||||||
|
// Mutate in-place (doesn't trigger setter).
|
||||||
|
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
|
||||||
|
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
|
||||||
|
|
||||||
|
d.RecomputeCanonicalAngle();
|
||||||
|
Assert.InRange(d.Source.Angle, -0.52, -0.48);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CanonicalFrameTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeRect(double w, double h, double rotation)
|
||||||
|
{
|
||||||
|
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)));
|
||||||
|
if (!Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
pgm.Rotate(rotation, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AsCanonicalCopy_AxisAlignsMbr()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.6);
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
var bb = canonical.Program.BoundingBox();
|
||||||
|
var longer = System.Math.Max(bb.Length, bb.Width);
|
||||||
|
var shorter = System.Math.Min(bb.Length, bb.Width);
|
||||||
|
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
|
||||||
|
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
|
||||||
|
Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AsCanonicalCopy_DoesNotMutateSource()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.6);
|
||||||
|
var originalBbox = d.Program.BoundingBox();
|
||||||
|
var originalAngle = d.Source.Angle;
|
||||||
|
|
||||||
|
CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
var afterBbox = d.Program.BoundingBox();
|
||||||
|
Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
|
||||||
|
Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
|
||||||
|
Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromCanonical_ComposesSourceAngleOntoRotation()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.0);
|
||||||
|
var part = new Part(d);
|
||||||
|
part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
|
||||||
|
|
||||||
|
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, sourceAngle: -0.5);
|
||||||
|
|
||||||
|
// R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
|
||||||
|
// Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
|
||||||
|
// so compare after normalizing the expected value as well.
|
||||||
|
Assert.Single(placed);
|
||||||
|
Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_RestoresGeometry()
|
||||||
|
{
|
||||||
|
var d = MakeRect(100, 50, 0.4);
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(d);
|
||||||
|
|
||||||
|
// Place a part at origin in the canonical frame.
|
||||||
|
var part = Part.CreateAtOrigin(canonical);
|
||||||
|
var canonicalBbox = part.BoundingBox;
|
||||||
|
|
||||||
|
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, d.Source.Angle);
|
||||||
|
|
||||||
|
var originalBbox = d.Program.BoundingBox();
|
||||||
|
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
|
||||||
|
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class NestInvarianceTests
|
||||||
|
{
|
||||||
|
private static OpenNest.CNC.Program MakeLShapedProgram()
|
||||||
|
{
|
||||||
|
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
|
||||||
|
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, 20)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
return pgm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Drawing MakeImportedAt(double rotation)
|
||||||
|
{
|
||||||
|
var pgm = MakeLShapedProgram();
|
||||||
|
if (!Tolerance.IsEqualTo(rotation, 0))
|
||||||
|
pgm.Rotate(rotation, pgm.BoundingBox().Center);
|
||||||
|
return new Drawing("L", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plate MakePlate() => new Plate(new Size(500, 500))
|
||||||
|
{
|
||||||
|
Quadrant = 1,
|
||||||
|
PartSpacing = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int RunFillCount(Drawing drawing, Plate plate)
|
||||||
|
{
|
||||||
|
BestFitCache.Clear();
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = drawing };
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
|
||||||
|
return parts?.Count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0)]
|
||||||
|
[InlineData(0.3)]
|
||||||
|
[InlineData(0.8)]
|
||||||
|
[InlineData(1.2)]
|
||||||
|
public void Fill_SameCount_AcrossImportOrientations(double theta)
|
||||||
|
{
|
||||||
|
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
|
||||||
|
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
|
||||||
|
|
||||||
|
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
|
||||||
|
Assert.InRange(rotated, baseline - 1, baseline + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
|
||||||
|
{
|
||||||
|
var plate = MakePlate();
|
||||||
|
var workArea = plate.WorkArea();
|
||||||
|
|
||||||
|
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
|
||||||
|
{
|
||||||
|
BestFitCache.Clear();
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeImportedAt(theta) };
|
||||||
|
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(parts);
|
||||||
|
foreach (var p in parts)
|
||||||
|
{
|
||||||
|
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
|
||||||
|
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class BoxComparisonTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThan_TallerBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(tall > short_);
|
||||||
|
Assert.False(short_ > tall);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var longer = new Box(0, 0, 20, 10);
|
||||||
|
var shorter = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(longer > shorter);
|
||||||
|
Assert.False(shorter > longer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessThan_ShorterBox_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(short_ < tall);
|
||||||
|
Assert.False(tall < short_);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.True(a >= b);
|
||||||
|
Assert.True(b >= a);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.True(a <= b);
|
||||||
|
Assert.True(b <= a);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompareTo_TallerBox_ReturnsPositive()
|
||||||
|
{
|
||||||
|
var tall = new Box(0, 0, 10, 20);
|
||||||
|
var short_ = new Box(0, 0, 10, 10);
|
||||||
|
|
||||||
|
Assert.True(tall.CompareTo(short_) > 0);
|
||||||
|
Assert.True(short_.CompareTo(tall) < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompareTo_EqualBoxes_ReturnsZero()
|
||||||
|
{
|
||||||
|
var a = new Box(0, 0, 10, 20);
|
||||||
|
var b = new Box(0, 0, 10, 20);
|
||||||
|
|
||||||
|
Assert.Equal(0, a.CompareTo(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sort_OrdersByWidthThenLength()
|
||||||
|
{
|
||||||
|
var boxes = new List<Box>
|
||||||
|
{
|
||||||
|
new Box(0, 0, 20, 10),
|
||||||
|
new Box(0, 0, 5, 30),
|
||||||
|
new Box(0, 0, 10, 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
boxes.Sort();
|
||||||
|
|
||||||
|
Assert.Equal(10, boxes[0].Width);
|
||||||
|
Assert.Equal(10, boxes[0].Length);
|
||||||
|
Assert.Equal(10, boxes[1].Width);
|
||||||
|
Assert.Equal(20, boxes[1].Length);
|
||||||
|
Assert.Equal(30, boxes[2].Width);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Tests.Geometry;
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
public class EllipseConverterTests
|
public class EllipseConverterTests
|
||||||
{
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
private const double Tol = 1e-10;
|
private const double Tol = 1e-10;
|
||||||
|
|
||||||
|
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
|
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
|
||||||
{
|
{
|
||||||
@@ -244,6 +249,101 @@ public class EllipseConverterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DxfImport_ArcBoundingBoxes_Diagnostic()
|
||||||
|
{
|
||||||
|
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
|
||||||
|
if (!System.IO.File.Exists(path)) return;
|
||||||
|
|
||||||
|
var result = Dxf.Import(path);
|
||||||
|
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
|
||||||
|
var bbox = all.GetBoundingBox();
|
||||||
|
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
|
||||||
|
|
||||||
|
for (var i = 0; i < result.Entities.Count; i++)
|
||||||
|
{
|
||||||
|
var e = result.Entities[i];
|
||||||
|
var b = e.BoundingBox;
|
||||||
|
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
|
||||||
|
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
|
||||||
|
{
|
||||||
|
var normal = new ACadSharp.Entities.Ellipse
|
||||||
|
{
|
||||||
|
Center = new CSMath.XYZ(-0.275, -0.245, 0),
|
||||||
|
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
|
||||||
|
RadiusRatio = 0.28,
|
||||||
|
StartParameter = 0.017,
|
||||||
|
EndParameter = 1.571,
|
||||||
|
Normal = new CSMath.XYZ(0, 0, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
var flipped = new ACadSharp.Entities.Ellipse
|
||||||
|
{
|
||||||
|
Center = new CSMath.XYZ(0.275, -0.245, 0),
|
||||||
|
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
|
||||||
|
RadiusRatio = 0.28,
|
||||||
|
StartParameter = 0.017,
|
||||||
|
EndParameter = 1.571,
|
||||||
|
Normal = new CSMath.XYZ(0, 0, -1)
|
||||||
|
};
|
||||||
|
|
||||||
|
var normalArcs = normal.ToOpenNest();
|
||||||
|
var flippedArcs = flipped.ToOpenNest();
|
||||||
|
|
||||||
|
Assert.True(normalArcs.Count > 0);
|
||||||
|
Assert.True(flippedArcs.Count > 0);
|
||||||
|
Assert.True(normalArcs.All(e => e is Arc));
|
||||||
|
Assert.True(flippedArcs.All(e => e is Arc));
|
||||||
|
|
||||||
|
var normalFirst = (Arc)normalArcs.First();
|
||||||
|
var flippedFirst = (Arc)flippedArcs.First();
|
||||||
|
var normalStart = GetArcStart(normalFirst);
|
||||||
|
var flippedStart = GetArcStart(flippedFirst);
|
||||||
|
|
||||||
|
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
|
||||||
|
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
|
||||||
|
|
||||||
|
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
|
||||||
|
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
|
||||||
|
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
|
||||||
|
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
|
||||||
|
{
|
||||||
|
var minX = double.MaxValue;
|
||||||
|
var maxX = double.MinValue;
|
||||||
|
foreach (var arc in arcs)
|
||||||
|
{
|
||||||
|
var s = GetArcStart(arc);
|
||||||
|
var e = GetArcEnd(arc);
|
||||||
|
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
|
||||||
|
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
|
||||||
|
}
|
||||||
|
return (minX, maxX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector GetArcStart(Arc arc)
|
||||||
|
{
|
||||||
|
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
|
||||||
|
return new Vector(
|
||||||
|
arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector GetArcEnd(Arc arc)
|
||||||
|
{
|
||||||
|
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
|
||||||
|
return new Vector(
|
||||||
|
arc.Center.X + arc.Radius * System.Math.Cos(angle),
|
||||||
|
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
|
||||||
|
}
|
||||||
|
|
||||||
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
|
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
|
||||||
double semiMajor, double semiMinor, double rotation, int samples)
|
double semiMajor, double semiMinor, double rotation, int samples)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class WeldEndpointsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_SnapsNearbyLineEndpoints()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.0000005, 0, 20, 0);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.000001);
|
||||||
|
|
||||||
|
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 10, 0);
|
||||||
|
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
|
||||||
|
var entities = new List<Entity> { line, arc };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.01);
|
||||||
|
|
||||||
|
var arcStart = arc.StartPoint();
|
||||||
|
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.1, 0, 20, 0);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
ShapeBuilder.WeldEndpoints(entities, 0.000001);
|
||||||
|
|
||||||
|
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
|
||||||
|
|
||||||
|
Assert.Single(shapes);
|
||||||
|
Assert.Equal(2, shapes[0].Entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
|
||||||
|
{
|
||||||
|
var line1 = new Line(0, 0, 10, 0);
|
||||||
|
var line2 = new Line(10, 0, 10, 10);
|
||||||
|
var entities = new List<Entity> { line1, line2 };
|
||||||
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
|
||||||
|
Assert.Single(shapes);
|
||||||
|
Assert.Equal(2, shapes[0].Entities.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class RemoveDuplicateArcsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var line = new Line(0, 0, 10, 0) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc, line };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
Assert.Contains(circle, entities);
|
||||||
|
Assert.Contains(line, entities);
|
||||||
|
Assert.DoesNotContain(arc, entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
|
||||||
|
{
|
||||||
|
var layer1 = new Layer("cut");
|
||||||
|
var layer2 = new Layer("etch");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer1 };
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
Assert.Contains(arc, entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_NoCircles_NoChange()
|
||||||
|
{
|
||||||
|
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
|
||||||
|
var line = new Line(0, 0, 10, 0);
|
||||||
|
var entities = new List<Entity> { arc, line };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Equal(2, entities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
|
||||||
|
{
|
||||||
|
var layer = new Layer("0");
|
||||||
|
var circle = new Circle(10, 10, 5) { Layer = layer };
|
||||||
|
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
|
||||||
|
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
|
||||||
|
var entities = new List<Entity> { circle, arc1, arc2 };
|
||||||
|
|
||||||
|
CadImporter.RemoveDuplicateArcs(entities);
|
||||||
|
|
||||||
|
Assert.Single(entities);
|
||||||
|
Assert.Contains(circle, entities);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using OpenNest.Math;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Math;
|
||||||
|
|
||||||
|
public class FractionTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3/8", 0.375)]
|
||||||
|
[InlineData("1 3/4", 1.75)]
|
||||||
|
[InlineData("1-3/4", 1.75)]
|
||||||
|
[InlineData("1/2", 0.5)]
|
||||||
|
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
|
||||||
|
{
|
||||||
|
var result = Fraction.Parse(input);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3/8", true)]
|
||||||
|
[InlineData("abc", false)]
|
||||||
|
[InlineData("1 3/4", true)]
|
||||||
|
public void IsValid_ReturnsExpected(string input, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Fraction.IsValid(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_InvalidInput_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var result = Fraction.TryParse("abc", out var value);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.Equal(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
|
||||||
|
{
|
||||||
|
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
|
||||||
|
|
||||||
|
Assert.Contains("1.75", result);
|
||||||
|
Assert.DoesNotContain("3/4", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
|||||||
new Size(48, 24), new Size(120, 10)
|
new Size(48, 24), new Size(120, 10)
|
||||||
};
|
};
|
||||||
|
|
||||||
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
|
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
|
||||||
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
|
.Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
|
||||||
|
.ToArray();
|
||||||
|
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
|
||||||
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
|
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
|
||||||
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
|
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
|
||||||
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
|
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using OpenNest.IO.Bom;
|
using OpenNest.Math;
|
||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Controls
|
||||||
|
{
|
||||||
|
public class CadText
|
||||||
|
{
|
||||||
|
public Vector Position { get; set; }
|
||||||
|
public string Value { get; set; }
|
||||||
|
public double Height { get; set; }
|
||||||
|
public double Rotation { get; set; }
|
||||||
|
public string LayerName { get; set; }
|
||||||
|
public StringAlignment HAlign { get; set; }
|
||||||
|
public StringAlignment VAlign { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,12 +29,14 @@ namespace OpenNest.Controls
|
|||||||
public List<Entity> SimplifierToleranceRight { get; set; }
|
public List<Entity> SimplifierToleranceRight { get; set; }
|
||||||
public List<Entity> OriginalEntities { get; set; }
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public bool ShowEntityLabels { get; set; }
|
public bool ShowEntityLabels { get; set; }
|
||||||
|
public List<CadText> Texts { get; set; } = new List<CadText>();
|
||||||
|
|
||||||
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||||
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
||||||
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
||||||
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
||||||
|
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
|
||||||
|
|
||||||
public event EventHandler<Line> LinePicked;
|
public event EventHandler<Line> LinePicked;
|
||||||
public event EventHandler PickCancelled;
|
public event EventHandler PickCancelled;
|
||||||
@@ -116,6 +118,8 @@ namespace OpenNest.Controls
|
|||||||
DrawEntity(e.Graphics, entity, pen);
|
DrawEntity(e.Graphics, entity, pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DrawTexts(e.Graphics);
|
||||||
|
|
||||||
if (ShowEntityLabels)
|
if (ShowEntityLabels)
|
||||||
DrawEntityLabels(e.Graphics);
|
DrawEntityLabels(e.Graphics);
|
||||||
|
|
||||||
@@ -408,6 +412,7 @@ namespace OpenNest.Controls
|
|||||||
labelFont.Dispose();
|
labelFont.Dispose();
|
||||||
labelBrush.Dispose();
|
labelBrush.Dispose();
|
||||||
labelBackBrush.Dispose();
|
labelBackBrush.Dispose();
|
||||||
|
textBrush.Dispose();
|
||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
@@ -474,6 +479,34 @@ namespace OpenNest.Controls
|
|||||||
diameter);
|
diameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawTexts(Graphics g)
|
||||||
|
{
|
||||||
|
if (Texts == null || Texts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var sf = new StringFormat();
|
||||||
|
|
||||||
|
foreach (var text in Texts)
|
||||||
|
{
|
||||||
|
var pos = PointWorldToGraph(text.Position);
|
||||||
|
var fontSize = LengthWorldToGui(text.Height);
|
||||||
|
if (fontSize < 2f) continue;
|
||||||
|
|
||||||
|
var state = g.Save();
|
||||||
|
g.TranslateTransform(pos.X, pos.Y);
|
||||||
|
|
||||||
|
if (text.Rotation != 0)
|
||||||
|
g.RotateTransform((float)OpenNest.Math.Angle.ToDegrees(text.Rotation));
|
||||||
|
|
||||||
|
sf.Alignment = text.HAlign;
|
||||||
|
sf.LineAlignment = text.VAlign;
|
||||||
|
|
||||||
|
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
|
||||||
|
g.DrawString(text.Value, font, textBrush, 0, 0, sf);
|
||||||
|
g.Restore(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawPoint(Graphics g, Vector pt, Pen pen)
|
private void DrawPoint(Graphics g, Vector pt, Pen pen)
|
||||||
{
|
{
|
||||||
var pt1 = PointWorldToGraph(pt);
|
var pt1 = PointWorldToGraph(pt);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace OpenNest.Controls
|
|||||||
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
||||||
public Box Bounds { get; set; }
|
public Box Bounds { get; set; }
|
||||||
public int EntityCount { get; set; }
|
public int EntityCount { get; set; }
|
||||||
|
public List<CadText> Texts { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FileListControl : Control
|
public class FileListControl : Control
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ namespace OpenNest.Forms
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var lookupName = item.FileName;
|
var lookupName = item.FileName;
|
||||||
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
|
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
|
||||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||||
|
|
||||||
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
|
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ namespace OpenNest.Forms
|
|||||||
Customer = string.Empty,
|
Customer = string.Empty,
|
||||||
Bends = result.Bends,
|
Bends = result.Bends,
|
||||||
Bounds = result.Bounds,
|
Bounds = result.Bounds,
|
||||||
EntityCount = result.Entities.Count
|
EntityCount = result.Entities.Count,
|
||||||
|
Texts = ExtractTexts(result.Document),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
@@ -152,6 +153,7 @@ namespace OpenNest.Forms
|
|||||||
entityView1.Entities.Clear();
|
entityView1.Entities.Clear();
|
||||||
entityView1.Entities.AddRange(item.Entities);
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||||
|
entityView1.Texts = item.Texts ?? new List<CadText>();
|
||||||
|
|
||||||
item.Entities.ForEach(e => e.IsVisible = true);
|
item.Entities.ForEach(e => e.IsVisible = true);
|
||||||
if (item.Entities.Any(e => e.Layer != null))
|
if (item.Entities.Any(e => e.Layer != null))
|
||||||
@@ -473,7 +475,8 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
var dxfFiles = files.Where(f =>
|
var dxfFiles = files.Where(f =>
|
||||||
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray();
|
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
if (dxfFiles.Length > 0)
|
if (dxfFiles.Length > 0)
|
||||||
AddFiles(dxfFiles);
|
AddFiles(dxfFiles);
|
||||||
}
|
}
|
||||||
@@ -803,6 +806,102 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
private static List<CadText> ExtractTexts(ACadSharp.CadDocument doc)
|
||||||
|
{
|
||||||
|
var texts = new List<CadText>();
|
||||||
|
if (doc == null) return texts;
|
||||||
|
|
||||||
|
foreach (var entity in doc.Entities)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case ACadSharp.Entities.MText mtext:
|
||||||
|
var (mh, mv) = MapAttachmentPoint(mtext.AttachmentPoint);
|
||||||
|
texts.Add(new CadText
|
||||||
|
{
|
||||||
|
Position = new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y),
|
||||||
|
Value = ReplaceControlCodes(StripMTextFormatting(mtext.Value)),
|
||||||
|
Height = mtext.Height,
|
||||||
|
Rotation = mtext.Rotation,
|
||||||
|
LayerName = mtext.Layer?.Name,
|
||||||
|
HAlign = mh,
|
||||||
|
VAlign = mv,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ACadSharp.Entities.TextEntity text:
|
||||||
|
var useAlignment = text.HorizontalAlignment != 0
|
||||||
|
|| text.VerticalAlignment != 0;
|
||||||
|
var pt = useAlignment ? text.AlignmentPoint : text.InsertPoint;
|
||||||
|
var ha = text.HorizontalAlignment switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.TextHorizontalAlignment.Center => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
texts.Add(new CadText
|
||||||
|
{
|
||||||
|
Position = new Vector(pt.X, pt.Y),
|
||||||
|
Value = ReplaceControlCodes(text.Value),
|
||||||
|
Height = text.Height,
|
||||||
|
Rotation = text.Rotation,
|
||||||
|
LayerName = text.Layer?.Name,
|
||||||
|
HAlign = ha,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (System.Drawing.StringAlignment h, System.Drawing.StringAlignment v) MapAttachmentPoint(
|
||||||
|
ACadSharp.Entities.AttachmentPointType apt)
|
||||||
|
{
|
||||||
|
var h = apt switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.AttachmentPointType.TopCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomCenter => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.AttachmentPointType.TopRight
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleRight
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
var v = apt switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.AttachmentPointType.MiddleLeft
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.MiddleRight => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.AttachmentPointType.BottomLeft
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomCenter
|
||||||
|
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
|
};
|
||||||
|
return (h, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripMTextFormatting(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
var result = System.Text.RegularExpressions.Regex.Replace(text, @"\\[A-Za-z][^;]*;", "");
|
||||||
|
result = result.Replace("{", "").Replace("}", "");
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceControlCodes(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
return text
|
||||||
|
.Replace("%%p", "±")
|
||||||
|
.Replace("%%P", "±")
|
||||||
|
.Replace("%%d", "°")
|
||||||
|
.Replace("%%D", "°")
|
||||||
|
.Replace("%%c", "⌀")
|
||||||
|
.Replace("%%C", "⌀")
|
||||||
|
.Replace("%%%", "%");
|
||||||
|
}
|
||||||
|
|
||||||
private void filterPanel_Paint(object sender, PaintEventArgs e)
|
private void filterPanel_Paint(object sender, PaintEventArgs e)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
var dlg = new OpenFileDialog();
|
var dlg = new OpenFileDialog();
|
||||||
dlg.Multiselect = true;
|
dlg.Multiselect = true;
|
||||||
dlg.Filter = "DXF Files (*.dxf) | *.dxf";
|
dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg";
|
||||||
|
|
||||||
if (dlg.ShowDialog() != DialogResult.OK)
|
if (dlg.ShowDialog() != DialogResult.OK)
|
||||||
return;
|
return;
|
||||||
@@ -346,7 +346,6 @@ namespace OpenNest.Forms
|
|||||||
drawings.ForEach(d => Nest.Drawings.Add(d));
|
drawings.ForEach(d => Nest.Drawings.Add(d));
|
||||||
|
|
||||||
UpdateDrawingList();
|
UpdateDrawingList();
|
||||||
tabControl1.SelectedIndex = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Export()
|
public bool Export()
|
||||||
|
|||||||
@@ -138,9 +138,20 @@ namespace OpenNest
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
cutPath.StartFigure();
|
{
|
||||||
leadPath.StartFigure();
|
var rapid = (RapidMove)code;
|
||||||
AddLine(cutPath, (RapidMove)code, mode, ref curpos);
|
var endpt = rapid.EndPoint;
|
||||||
|
if (mode == Mode.Incremental)
|
||||||
|
endpt += curpos;
|
||||||
|
var dx = endpt.X - curpos.X;
|
||||||
|
var dy = endpt.Y - curpos.Y;
|
||||||
|
if (dx * dx + dy * dy > 0.001 * 0.001)
|
||||||
|
{
|
||||||
|
cutPath.StartFigure();
|
||||||
|
leadPath.StartFigure();
|
||||||
|
}
|
||||||
|
curpos = endpt;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
@@ -300,8 +311,17 @@ namespace OpenNest
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.RapidMove:
|
case CodeType.RapidMove:
|
||||||
Flush();
|
{
|
||||||
AddLine(path, (RapidMove)code, mode, ref curpos);
|
var rapid = (RapidMove)code;
|
||||||
|
var endpt = rapid.EndPoint;
|
||||||
|
if (mode == Mode.Incremental)
|
||||||
|
endpt += curpos;
|
||||||
|
var dx = endpt.X - curpos.X;
|
||||||
|
var dy = endpt.Y - curpos.Y;
|
||||||
|
if (dx * dx + dy * dy > 0.001 * 0.001)
|
||||||
|
Flush();
|
||||||
|
curpos = endpt;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
|
|||||||
Reference in New Issue
Block a user