Compare commits
92 Commits
e50a7c82cf
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 786b6e2e88 | |||
| ba89967448 | |||
| b566d984b0 | |||
| c1e6092e83 | |||
| df86d4367b | |||
| 40026ab4dc | |||
| b18a82df7a | |||
| f090a2e299 | |||
| 55192a4888 | |||
| 7c28a35ad8 | |||
| b2a723ca60 | |||
| 3dca25c601 | |||
| ebc1a5f980 | |||
| b729f92cd6 | |||
| 5d6e018b81 | |||
| 5163b02f89 | |||
| a59911b38a | |||
| 810e37cacf | |||
| 8dfa45c446 | |||
| b223f69572 | |||
| 98c574c2ad | |||
| 30f1008fa9 | |||
| 41c20eaf75 | |||
| 3a97253473 | |||
| 3eab3c5946 | |||
| 0e05ad04ea | |||
| 5ac985dc0f | |||
| 865754611c | |||
| 9db326ee5d | |||
| 25faba430c | |||
| 089df67627 | |||
| 11884e712d | |||
| 6bed736cf0 | |||
| c20a079874 | |||
| 804a7fd9c1 | |||
| 3c4d00baa4 | |||
| 959ab15491 | |||
| cca70db547 | |||
| 62d9dce0b1 | |||
| 1f88453d4c | |||
| 0697bebbc2 | |||
| beadb14acc | |||
| 09f1140f54 | |||
| 7c918a2378 | |||
| feb08a5f60 | |||
| f1fd211ba5 | |||
| fd3c2462df | |||
| a4773748a1 | |||
| af57153269 | |||
| 35e89600d0 | |||
| 89a4e6b981 | |||
| ebad3577dd | |||
| a8dc275da4 | |||
| d84becdaee | |||
| 9cba3a6cd7 | |||
| e93523d7a2 | |||
| 3bdbf21881 | |||
| a8e42fb4b5 | |||
| ea3c6afbdd | |||
| ba88ac253a | |||
| 250fdefaea | |||
| e92208b8c0 | |||
| 297ebee45b | |||
| 1eba3e7cde | |||
| d65f3460a9 | |||
| ede06b1bf6 | |||
| 51eea6d1e6 | |||
| 3d23ad8073 | |||
| 107fd86066 | |||
| d12f0cee3e | |||
| d93b69c524 | |||
| a65598615e | |||
| ed082a6799 | |||
| c9b17619ef | |||
| f78cc78a65 | |||
| 37130e8a28 | |||
| 6f19fe1822 | |||
| 81c167320d | |||
| 981188f65e | |||
| ffd060bf61 | |||
| a360452da3 | |||
| b3e9e5e28b | |||
| 7380a43349 | |||
| 59e00cd707 | |||
| 44cb6e4a2b | |||
| 5949c3ca1f | |||
| ef15421915 | |||
| 943c262ad2 | |||
| 301831e096 | |||
| fce287e649 | |||
| 7e86313d7c | |||
| c5943e22eb |
@@ -25,14 +25,13 @@ public static class NestRunner
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
var importer = new DxfImporter();
|
||||
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
||||
var geometry = Dxf.GetGeometry(part.DxfPath);
|
||||
if (geometry.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
|
||||
@@ -241,17 +241,11 @@ static class NestConsole
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
|
||||
if (!importer.GetGeometry(path, out var geometry))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
|
||||
return null;
|
||||
}
|
||||
var geometry = Dxf.GetGeometry(path);
|
||||
|
||||
if (geometry.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
|
||||
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,7 +309,12 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
return circle.Rotation;
|
||||
|
||||
return shape.ToPolygon().RotationDirection();
|
||||
var polygon = shape.ToPolygon();
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return RotationType.CCW;
|
||||
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class MicrotabLeadOut : LeadOut
|
||||
{
|
||||
public double GapSize { get; set; } = 0.03;
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
return new List<ICode>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ namespace OpenNest.Collections
|
||||
public bool Remove(T item)
|
||||
{
|
||||
var success = items.Remove(item);
|
||||
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
||||
if (success)
|
||||
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
||||
return success;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ namespace OpenNest.Converters
|
||||
if (startpt != lastpt)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
|
||||
@@ -106,7 +106,7 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
||||
else
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
||||
|
||||
|
||||
@@ -50,13 +50,13 @@ namespace OpenNest
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
@@ -176,13 +176,13 @@ namespace OpenNest
|
||||
|
||||
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.X - clearance, bb.X + bb.Width + clearance)
|
||||
: (bb.Y - clearance, bb.Y + bb.Length + clearance);
|
||||
? (bb.X - clearance, bb.X + bb.Length + clearance)
|
||||
: (bb.Y - clearance, bb.Y + bb.Width + clearance);
|
||||
|
||||
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.Y - clearance, bb.Y + bb.Length + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Width + clearance);
|
||||
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
||||
|
||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -12,8 +13,32 @@ namespace OpenNest
|
||||
public class Drawing
|
||||
{
|
||||
private static int nextId;
|
||||
private static int nextColorIndex;
|
||||
private Program program;
|
||||
|
||||
public static readonly Color[] PartColors = new Color[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92), // Indian Red
|
||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||
Color.FromArgb(75, 180, 175), // Teal
|
||||
Color.FromArgb(210, 190, 75), // Goldenrod
|
||||
Color.FromArgb(190, 85, 175), // Orchid
|
||||
Color.FromArgb(185, 115, 85), // Sienna
|
||||
Color.FromArgb(120, 100, 190), // Slate Blue
|
||||
Color.FromArgb(200, 100, 140), // Rose
|
||||
Color.FromArgb(80, 175, 155), // Sea Green
|
||||
Color.FromArgb(195, 160, 85), // Dark Khaki
|
||||
Color.FromArgb(175, 95, 160), // Plum
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
};
|
||||
|
||||
public static Color GetNextColor()
|
||||
{
|
||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
}
|
||||
|
||||
public Drawing()
|
||||
: this(string.Empty, new Program())
|
||||
{
|
||||
@@ -66,6 +91,18 @@ namespace OpenNest
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of source entities with stable GUIDs.
|
||||
/// Null when the drawing was created from G-code or an older nest file.
|
||||
/// </summary>
|
||||
public List<Entity> SourceEntities { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
|
||||
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -420,8 +420,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
double minX = boxes[0].X;
|
||||
double minY = boxes[0].Y;
|
||||
double maxX = boxes[0].X + boxes[0].Width;
|
||||
double maxY = boxes[0].Y + boxes[0].Length;
|
||||
double maxX = boxes[0].Right;
|
||||
double maxY = boxes[0].Top;
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
|
||||
@@ -14,15 +14,15 @@ namespace OpenNest.Geometry
|
||||
public Box(double x, double y, double w, double h)
|
||||
{
|
||||
Location = new Vector(x, y);
|
||||
Width = w;
|
||||
Length = h;
|
||||
Length = w;
|
||||
Width = h;
|
||||
}
|
||||
|
||||
public Vector Location;
|
||||
|
||||
public Vector Center
|
||||
{
|
||||
get { return new Vector(X + Width * 0.5, Y + Length * 0.5); }
|
||||
get { return new Vector(X + Length * 0.5, Y + Width * 0.5); }
|
||||
}
|
||||
|
||||
public Size Size;
|
||||
@@ -76,12 +76,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
public Box Translate(double x, double y)
|
||||
{
|
||||
return new Box(X + x, Y + y, Width, Length);
|
||||
return new Box(X + x, Y + y, Length, Width);
|
||||
}
|
||||
|
||||
public Box Translate(Vector offset)
|
||||
{
|
||||
return new Box(X + offset.X, Y + offset.Y, Width, Length);
|
||||
return new Box(X + offset.X, Y + offset.Y, Length, Width);
|
||||
}
|
||||
|
||||
public double Left
|
||||
@@ -91,12 +91,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
public double Right
|
||||
{
|
||||
get { return X + Width; }
|
||||
get { return X + Length; }
|
||||
}
|
||||
|
||||
public double Top
|
||||
{
|
||||
get { return Y + Length; }
|
||||
get { return Y + Width; }
|
||||
}
|
||||
|
||||
public double Bottom
|
||||
@@ -207,7 +207,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
public Box Offset(double d)
|
||||
{
|
||||
return new Box(X - d, Y - d, Width + d * 2, Length + d * 2);
|
||||
return new Box(X - d, Y - d, Length + d * 2, Width + d * 2);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = small.Top;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = large.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -23,7 +23,7 @@
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = small.Left - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = small.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -49,7 +49,7 @@
|
||||
var x = small.Right;
|
||||
var y = large.Bottom;
|
||||
var w = large.Right - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
|
||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
var stepAngle = Angle.TwoPI / segments;
|
||||
var stepAngle = Rotation == RotationType.CW
|
||||
? -Angle.TwoPI / segments
|
||||
: Angle.TwoPI / segments;
|
||||
|
||||
var r = circumscribe && segments > 0
|
||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
Layer = OpenNest.Geometry.Layer.Default;
|
||||
boundingBox = new Box();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this entity, stable across edit sessions.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
|
||||
/// </summary>
|
||||
|
||||
@@ -370,23 +370,23 @@ namespace OpenNest.Geometry
|
||||
if (StartPoint.X < EndPoint.X)
|
||||
{
|
||||
boundingBox.X = StartPoint.X;
|
||||
boundingBox.Width = EndPoint.X - StartPoint.X;
|
||||
boundingBox.Length = EndPoint.X - StartPoint.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.X = EndPoint.X;
|
||||
boundingBox.Width = StartPoint.X - EndPoint.X;
|
||||
boundingBox.Length = StartPoint.X - EndPoint.X;
|
||||
}
|
||||
|
||||
if (StartPoint.Y < EndPoint.Y)
|
||||
{
|
||||
boundingBox.Y = StartPoint.Y;
|
||||
boundingBox.Length = EndPoint.Y - StartPoint.Y;
|
||||
boundingBox.Width = EndPoint.Y - StartPoint.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.Y = EndPoint.Y;
|
||||
boundingBox.Length = StartPoint.Y - EndPoint.Y;
|
||||
boundingBox.Width = StartPoint.Y - EndPoint.Y;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -311,8 +311,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
|
||||
@@ -605,7 +605,7 @@ namespace OpenNest.Geometry
|
||||
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 });
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -640,7 +640,7 @@ namespace OpenNest.Geometry
|
||||
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 });
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer, Rotation = RotationType.CCW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves ray-circle intersection, returning the two parametric t values.
|
||||
/// Returns false if no real intersection exists.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
private static bool SolveRayCircle(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY,
|
||||
out double t1, out double t2)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the distance from a point along a direction to an arc.
|
||||
/// Solves ray-circle intersection, then constrains hits to the arc's
|
||||
/// angular span. Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
public static double RayArcDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double startAngle, double endAngle, bool reversed,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
|
||||
var best = double.MaxValue;
|
||||
|
||||
if (t1 > -Tolerance.Epsilon)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t1 > Tolerance.Epsilon ? t1 : 0;
|
||||
}
|
||||
|
||||
if (t2 > -Tolerance.Epsilon && t2 < best)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t2 > Tolerance.Epsilon ? t2 : 0;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the distance from a point along a direction to a full circle.
|
||||
/// Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
public static double RayCircleDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
|
||||
if (t1 > Tolerance.Epsilon) return t1;
|
||||
if (t1 >= -Tolerance.Epsilon) return 0;
|
||||
if (t2 > Tolerance.Epsilon) return t2;
|
||||
if (t2 >= -Tolerance.Epsilon) return 0;
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
// Sort edges for pruning if not already sorted (usually they aren't here)
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
|
||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1 + movingOffset);
|
||||
movingVertices.Add(movingLines[i].pt2 + movingOffset);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Extract unique vertices from moving edges.
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingEdges.Length; i++)
|
||||
{
|
||||
movingVertices.Add(movingEdges[i].start + movingOffset);
|
||||
movingVertices.Add(movingEdges[i].end + movingOffset);
|
||||
}
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryEdges.Length; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
|
||||
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
|
||||
}
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -467,12 +480,7 @@ namespace OpenNest.Geometry
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -487,12 +495,7 @@ namespace OpenNest.Geometry
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -507,6 +510,284 @@ namespace OpenNest.Geometry
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Delegates to the Vector-based overload.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
|
||||
{
|
||||
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||
/// without tessellation.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X;
|
||||
var vy = movingVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Arc-to-line closest-point check.
|
||||
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||
// closest point on a small corner arc to a straight edge may lie between
|
||||
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
|
||||
// Phase 4: Curve-to-curve direct distance.
|
||||
// The vertex-to-entity approach misses the closest contact between two
|
||||
// curved entities (circles/arcs) because only a few cardinal vertices are
|
||||
// sampled. The true closest contact along the push direction is found by
|
||||
// treating it as a ray from one center to an expanded circle at the other
|
||||
// center (radius = r1 + r2).
|
||||
for (var i = 0; i < movingEntities.Count; i++)
|
||||
{
|
||||
var me = movingEntities[i];
|
||||
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var se = stationaryEntities[j];
|
||||
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
|
||||
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
// For arcs, verify the contact point falls within both arcs' angular ranges.
|
||||
if (me is Arc || se is Arc)
|
||||
{
|
||||
var mx = mcx + d * dirX;
|
||||
var my = mcy + d * dirY;
|
||||
var toCx = scx - mx;
|
||||
var toCy = scy - my;
|
||||
|
||||
if (me is Arc mArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (se is Arc sArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities, List<Entity> lineEntities,
|
||||
double dirX, double dirY, double minDist)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (arcEntities[i] is Arc arc)
|
||||
{
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is Line line)
|
||||
{
|
||||
var linePt = line.ClosestPointTo(arc.Center);
|
||||
var arcPt = arc.ClosestPointTo(linePt);
|
||||
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
|
||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||
dirX, dirY);
|
||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx, double vy, Entity entity, double dirX, double dirY)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return RayEdgeDistance(vx, vy,
|
||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return RayArcDistance(vx, vy,
|
||||
arc.Center.X, arc.Center.Y, arc.Radius,
|
||||
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return RayCircleDistance(vx, vy,
|
||||
circle.Center.X, circle.Center.Y, circle.Radius,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.pt1);
|
||||
vertices.Add(line.pt2);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremeVertices(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
|
||||
if (arc.IsReversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
|
||||
{
|
||||
return CollectVertices(ToEdgeArray(lines), offset);
|
||||
}
|
||||
|
||||
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
vertices.Add(edges[i].start + offset);
|
||||
vertices.Add(edges[i].end + offset);
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
|
||||
{
|
||||
var edges = new (Vector start, Vector end)[lines.Count];
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
edges[i] = (lines[i].pt1, lines[i].pt2);
|
||||
return edges;
|
||||
}
|
||||
|
||||
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
|
||||
{
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
|
||||
else
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
|
||||
{
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
|
||||
return true;
|
||||
}
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -51,6 +52,10 @@ namespace OpenNest
|
||||
|
||||
public PlateSettings PlateDefaults { get; set; }
|
||||
|
||||
public List<PlateOption> PlateOptions { get; set; } = new();
|
||||
|
||||
public double SalvageRate { get; set; } = 0.5;
|
||||
|
||||
public Plate CreatePlate()
|
||||
{
|
||||
var plate = PlateDefaults.CreateNew();
|
||||
|
||||
@@ -190,7 +190,14 @@ namespace OpenNest
|
||||
{
|
||||
var rotation = Rotation;
|
||||
Program = BaseDrawing.Program.Clone() as Program;
|
||||
Program.Rotate(Program.Rotation - rotation);
|
||||
|
||||
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||
Program.Rotate(rotation);
|
||||
|
||||
HasManualLeadIns = false;
|
||||
LeadInsLocked = false;
|
||||
CuttingParameters = null;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -277,7 +284,7 @@ namespace OpenNest
|
||||
var part = new Part(BaseDrawing, Program,
|
||||
location + offset,
|
||||
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
||||
BoundingBox.Width, BoundingBox.Length));
|
||||
BoundingBox.Length, BoundingBox.Width));
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,115 @@ namespace OpenNest
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
|
||||
/// <summary>
|
||||
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
|
||||
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
|
||||
/// </summary>
|
||||
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
|
||||
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
||||
if (offsetShape == null)
|
||||
return new List<Entity>();
|
||||
|
||||
// Offset the shape's entities to the part's location.
|
||||
// OffsetOutward creates a new Shape, so mutating is safe.
|
||||
foreach (var entity in offsetShape.Entities)
|
||||
entity.Offset(part.Location);
|
||||
|
||||
return offsetShape.Entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
|
||||
/// without tessellation. Perimeter is offset outward, cutouts inward.
|
||||
/// </summary>
|
||||
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var entities = new List<Entity>();
|
||||
|
||||
var perimeter = profile.Perimeter.OffsetOutward(spacing);
|
||||
if (perimeter != null)
|
||||
{
|
||||
foreach (var entity in perimeter.Entities)
|
||||
entity.Offset(part.Location);
|
||||
entities.AddRange(perimeter.Entities);
|
||||
}
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var inset = cutout.OffsetInward(spacing);
|
||||
if (inset == null) continue;
|
||||
foreach (var entity in inset.Entities)
|
||||
entity.Offset(part.Location);
|
||||
entities.AddRange(inset.Entities);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns perimeter entities at the part's world location, without tessellation
|
||||
/// or spacing offset.
|
||||
/// </summary>
|
||||
public static List<Entity> GetPerimeterEntities(Part part)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
|
||||
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all entities (perimeter + cutouts) at the part's world location,
|
||||
/// without tessellation or spacing offset.
|
||||
/// </summary>
|
||||
public static List<Entity> GetPartEntities(Part part)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
|
||||
{
|
||||
var result = new List<Entity>(source.Count);
|
||||
|
||||
for (var i = 0; i < source.Count; i++)
|
||||
{
|
||||
var entity = source[i];
|
||||
Entity copy;
|
||||
|
||||
if (entity is Line line)
|
||||
copy = new Line(line.StartPoint + location, line.EndPoint + location);
|
||||
else if (entity is Arc arc)
|
||||
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
else if (entity is Circle circle)
|
||||
copy = new Circle(circle.Center + location, circle.Radius);
|
||||
else
|
||||
continue;
|
||||
|
||||
result.Add(copy);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
|
||||
bool perimeterOnly = false)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
@@ -50,9 +158,12 @@ namespace OpenNest
|
||||
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
if (!perimeterOnly)
|
||||
{
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ namespace OpenNest
|
||||
{
|
||||
var plateBox = new Box();
|
||||
|
||||
// Convention: Size.Length = X axis (horizontal), Size.Width = Y axis (vertical)
|
||||
// Width = Y axis (vertical), Length = X axis (horizontal)
|
||||
switch (Quadrant)
|
||||
{
|
||||
case 1:
|
||||
@@ -451,8 +451,8 @@ namespace OpenNest
|
||||
return new Box();
|
||||
}
|
||||
|
||||
plateBox.Width = Size.Length;
|
||||
plateBox.Length = Size.Width;
|
||||
plateBox.Width = Size.Width;
|
||||
plateBox.Length = Size.Length;
|
||||
|
||||
if (!includeParts)
|
||||
return plateBox;
|
||||
@@ -468,11 +468,11 @@ namespace OpenNest
|
||||
? partsBox.Bottom
|
||||
: plateBox.Bottom;
|
||||
|
||||
boundingBox.Width = partsBox.Right > plateBox.Right
|
||||
boundingBox.Length = partsBox.Right > plateBox.Right
|
||||
? partsBox.Right - boundingBox.X
|
||||
: plateBox.Right - boundingBox.X;
|
||||
|
||||
boundingBox.Length = partsBox.Top > plateBox.Top
|
||||
boundingBox.Width = partsBox.Top > plateBox.Top
|
||||
? partsBox.Top - boundingBox.Y
|
||||
: plateBox.Top - boundingBox.Y;
|
||||
|
||||
@@ -489,8 +489,8 @@ namespace OpenNest
|
||||
|
||||
box.X += EdgeSpacing.Left;
|
||||
box.Y += EdgeSpacing.Bottom;
|
||||
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using OpenNest.Collections;
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateChangedEventArgs : EventArgs
|
||||
{
|
||||
public Plate Plate { get; }
|
||||
public int Index { get; }
|
||||
|
||||
public PlateChangedEventArgs(Plate plate, int index)
|
||||
{
|
||||
Plate = plate;
|
||||
Index = index;
|
||||
}
|
||||
}
|
||||
|
||||
public class PlateManager : IDisposable
|
||||
{
|
||||
private readonly Nest nest;
|
||||
private bool disposed;
|
||||
private bool suppressNavigation;
|
||||
private bool batching;
|
||||
private Plate subscribedLast;
|
||||
private Plate subscribedSecondToLast;
|
||||
|
||||
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
|
||||
public event EventHandler PlateListChanged;
|
||||
|
||||
public PlateManager(Nest nest)
|
||||
{
|
||||
this.nest = nest;
|
||||
nest.Plates.ItemAdded += OnPlateAdded;
|
||||
nest.Plates.ItemRemoved += OnPlateRemoved;
|
||||
}
|
||||
|
||||
public int CurrentIndex { get; private set; }
|
||||
|
||||
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
|
||||
|
||||
public int Count => nest.Plates.Count;
|
||||
|
||||
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
|
||||
|
||||
public bool IsLast => CurrentIndex + 1 >= Count;
|
||||
|
||||
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
|
||||
|
||||
public void LoadFirst()
|
||||
{
|
||||
if (Count == 0)
|
||||
return;
|
||||
|
||||
CurrentIndex = 0;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void LoadLast()
|
||||
{
|
||||
if (Count == 0)
|
||||
return;
|
||||
|
||||
CurrentIndex = Count - 1;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public bool LoadNext()
|
||||
{
|
||||
if (CurrentIndex + 1 >= Count)
|
||||
return false;
|
||||
|
||||
CurrentIndex++;
|
||||
FireCurrentPlateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool LoadPrevious()
|
||||
{
|
||||
if (Count == 0 || CurrentIndex - 1 < 0)
|
||||
return false;
|
||||
|
||||
CurrentIndex--;
|
||||
FireCurrentPlateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void LoadAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= Count)
|
||||
return;
|
||||
|
||||
CurrentIndex = index;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void EnsureSentinel()
|
||||
{
|
||||
suppressNavigation = true;
|
||||
try
|
||||
{
|
||||
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
|
||||
nest.CreatePlate();
|
||||
|
||||
while (Count > 1
|
||||
&& nest.Plates[^1].Parts.Count == 0
|
||||
&& nest.Plates[^2].Parts.Count == 0)
|
||||
{
|
||||
nest.Plates.RemoveAt(Count - 1);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressNavigation = false;
|
||||
}
|
||||
|
||||
SubscribeToTailPlates();
|
||||
}
|
||||
|
||||
public void BeginBatch()
|
||||
{
|
||||
batching = true;
|
||||
}
|
||||
|
||||
public void EndBatch()
|
||||
{
|
||||
batching = false;
|
||||
EnsureSentinel();
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public Plate GetOrCreateEmpty()
|
||||
{
|
||||
for (var i = Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (nest.Plates[i].Parts.Count == 0)
|
||||
return nest.Plates[i];
|
||||
}
|
||||
|
||||
return nest.CreatePlate();
|
||||
}
|
||||
|
||||
public void RemoveCurrent()
|
||||
{
|
||||
if (Count < 2)
|
||||
return;
|
||||
|
||||
nest.Plates.RemoveAt(CurrentIndex);
|
||||
}
|
||||
|
||||
private void SubscribeToTailPlates()
|
||||
{
|
||||
UnsubscribeFromTailPlates();
|
||||
|
||||
if (Count > 0)
|
||||
{
|
||||
subscribedLast = nest.Plates[^1];
|
||||
subscribedLast.PartAdded += OnTailPartAdded;
|
||||
subscribedLast.PartRemoved += OnTailPartRemoved;
|
||||
}
|
||||
|
||||
if (Count > 1)
|
||||
{
|
||||
subscribedSecondToLast = nest.Plates[^2];
|
||||
subscribedSecondToLast.PartAdded += OnTailPartAdded;
|
||||
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromTailPlates()
|
||||
{
|
||||
if (subscribedLast != null)
|
||||
{
|
||||
subscribedLast.PartAdded -= OnTailPartAdded;
|
||||
subscribedLast.PartRemoved -= OnTailPartRemoved;
|
||||
subscribedLast = null;
|
||||
}
|
||||
|
||||
if (subscribedSecondToLast != null)
|
||||
{
|
||||
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
|
||||
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
|
||||
subscribedSecondToLast = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
|
||||
{
|
||||
if (!batching)
|
||||
EnsureSentinel();
|
||||
}
|
||||
|
||||
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
|
||||
{
|
||||
if (!batching)
|
||||
EnsureSentinel();
|
||||
}
|
||||
|
||||
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
|
||||
{
|
||||
if (!suppressNavigation && !batching)
|
||||
EnsureSentinel();
|
||||
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
if (!suppressNavigation)
|
||||
{
|
||||
CurrentIndex = Count - 1;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
|
||||
{
|
||||
if (CurrentIndex >= Count && Count > 0)
|
||||
CurrentIndex = Count - 1;
|
||||
|
||||
if (!suppressNavigation && !batching)
|
||||
EnsureSentinel();
|
||||
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
if (!suppressNavigation)
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
private void FireCurrentPlateChanged()
|
||||
{
|
||||
CurrentPlateChanged?.Invoke(this, new PlateChangedEventArgs(CurrentPlate, CurrentIndex));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
return;
|
||||
|
||||
disposed = true;
|
||||
UnsubscribeFromTailPlates();
|
||||
nest.Plates.ItemAdded -= OnPlateAdded;
|
||||
nest.Plates.ItemRemoved -= OnPlateRemoved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateOptimizerResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public PlateOption ChosenSize { get; set; }
|
||||
public double NetCost { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateOption
|
||||
{
|
||||
public double Width { get; set; }
|
||||
public double Length { get; set; }
|
||||
public double Cost { get; set; }
|
||||
|
||||
public double Area => Width * Length;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public double Diameter { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Diameter = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -11,6 +11,15 @@ namespace OpenNest.Shapes
|
||||
public double HolePatternDiameter { get; set; }
|
||||
public int HoleCount { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
NominalPipeSize = 2;
|
||||
OD = 7.5;
|
||||
HoleDiameter = 0.875;
|
||||
HolePatternDiameter = 5.5;
|
||||
HoleCount = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double Base { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Base = 8;
|
||||
Height = 10;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var midX = Base / 2.0;
|
||||
|
||||
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
|
||||
public double LegWidth { get; set; }
|
||||
public double LegHeight { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
Height = 10;
|
||||
LegWidth = 3;
|
||||
LegHeight = 3;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public double Width { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var center = Width / 2.0;
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
Width = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double OuterDiameter { get; set; }
|
||||
public double InnerDiameter { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
OuterDiameter = 10;
|
||||
InnerDiameter = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -10,6 +10,13 @@ namespace OpenNest.Shapes
|
||||
public double Width { get; set; }
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
Width = 6;
|
||||
Radius = 1;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var r = Radius;
|
||||
|
||||
@@ -26,6 +26,8 @@ namespace OpenNest.Shapes
|
||||
|
||||
public abstract Drawing GetDrawing();
|
||||
|
||||
public virtual void SetPreviewDefaults() { }
|
||||
|
||||
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
|
||||
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
|
||||
public double StemWidth { get; set; }
|
||||
public double BarHeight { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 10;
|
||||
Height = 8;
|
||||
StemWidth = 3;
|
||||
BarHeight = 3;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
|
||||
|
||||
@@ -9,6 +9,13 @@ namespace OpenNest.Shapes
|
||||
public double BottomWidth { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
TopWidth = 6;
|
||||
BottomWidth = 10;
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var offset = (BottomWidth - TopWidth) / 2.0;
|
||||
|
||||
@@ -13,8 +13,8 @@ public static class AutoSplitCalculator
|
||||
|
||||
var lines = new List<SplitLine>();
|
||||
|
||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableWidth) - 1 : 0;
|
||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableHeight) - 1 : 0;
|
||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableWidth) - 1 : 0;
|
||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableHeight) - 1 : 0;
|
||||
|
||||
if (verticalSplits < 0) verticalSplits = 0;
|
||||
if (horizontalSplits < 0) horizontalSplits = 0;
|
||||
@@ -34,14 +34,14 @@ public static class AutoSplitCalculator
|
||||
|
||||
if (verticalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Width / verticalPieces;
|
||||
var spacing = partBounds.Length / verticalPieces;
|
||||
for (var i = 1; i < verticalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
||||
}
|
||||
|
||||
if (horizontalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Length / horizontalPieces;
|
||||
var spacing = partBounds.Width / horizontalPieces;
|
||||
for (var i = 1; i < horizontalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace OpenNest.Engine.BestFit
|
||||
if (!result.Keep)
|
||||
continue;
|
||||
|
||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
|
||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
|
||||
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = "Exceeds plate dimensions";
|
||||
|
||||
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var allCandidates = candidateBags.SelectMany(c => c).ToList();
|
||||
|
||||
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
|
||||
|
||||
var results = _evaluator.EvaluateAll(allCandidates);
|
||||
|
||||
_filter.Apply(results);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
|
||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||
|
||||
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
|
||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary edges
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving edges (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
|
||||
return results;
|
||||
}
|
||||
|
||||
public double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
|
||||
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
|
||||
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
|
||||
|
||||
var movingCurves = ExtractCurveParams(movingEntities);
|
||||
var stationaryCurves = ExtractCurveParams(stationaryEntities);
|
||||
|
||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
|
||||
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary entities
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving entities (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Curve-to-curve direct distance.
|
||||
// Vertex sampling misses the true contact between two curved entities
|
||||
// when the approach angle doesn't align with a sampled vertex.
|
||||
for (var m = 0; m < movingCurves.Length; m++)
|
||||
{
|
||||
var mc = movingCurves[m];
|
||||
var mcx = mc.Cx + offset.Dx;
|
||||
var mcy = mc.Cy + offset.Dy;
|
||||
|
||||
for (var s = 0; s < stationaryCurves.Length; s++)
|
||||
{
|
||||
var sc = stationaryCurves[s];
|
||||
var d = SpatialQuery.RayCircleDistance(
|
||||
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
|
||||
|
||||
if (d >= minDist || d == double.MaxValue)
|
||||
continue;
|
||||
|
||||
if (mc.Entity is Arc || sc.Entity is Arc)
|
||||
{
|
||||
var mx = mcx + d * dirX;
|
||||
var my = mcy + d * dirY;
|
||||
var toCx = sc.Cx - mx;
|
||||
var toCy = sc.Cy - my;
|
||||
|
||||
if (mc.Entity is Arc mArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sc.Entity is Arc sArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly struct CurveParams
|
||||
{
|
||||
public readonly Entity Entity;
|
||||
public readonly double Cx, Cy, Radius;
|
||||
|
||||
public CurveParams(Entity entity, double cx, double cy, double radius)
|
||||
{
|
||||
Entity = entity;
|
||||
Cx = cx;
|
||||
Cy = cy;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
|
||||
{
|
||||
var curves = new List<CurveParams>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
|
||||
else if (entities[i] is Arc arc)
|
||||
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
|
||||
}
|
||||
return curves.ToArray();
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx, double vy, Entity entity,
|
||||
double entityOffsetX, double entityOffsetY,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return SpatialQuery.RayEdgeDistance(
|
||||
vx, vy,
|
||||
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
|
||||
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return SpatialQuery.RayArcDistance(
|
||||
vx, vy,
|
||||
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
|
||||
arc.Radius,
|
||||
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return SpatialQuery.RayCircleDistance(
|
||||
vx, vy,
|
||||
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
|
||||
circle.Radius,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.StartPoint);
|
||||
vertices.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremes(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Four cardinal points
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
var reversed = arc.IsReversed;
|
||||
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
// Right (0°)
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
|
||||
// Top (90°)
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
|
||||
// Left (180°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
|
||||
// Bottom (270°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters vertices by their projection onto the push direction.
|
||||
/// keepHigh=true returns the leading half (front face, closest to target).
|
||||
/// keepHigh=false returns the facing half (side facing the approaching part).
|
||||
/// </summary>
|
||||
private static Vector[] FilterVerticesByProjection(
|
||||
Vector[] vertices, double dirX, double dirY, bool keepHigh)
|
||||
{
|
||||
|
||||
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
|
||||
flatOffsets, count, directions);
|
||||
}
|
||||
|
||||
public double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
// GPU path doesn't support native entities yet — fall back to CPU.
|
||||
var cpu = new CpuDistanceComputer();
|
||||
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||
/// Left=0, Down=1, Right=2, Up=3.
|
||||
|
||||
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
|
||||
List<Line> stationaryLines,
|
||||
List<Line> movingTemplateLines,
|
||||
SlideOffset[] offsets);
|
||||
|
||||
double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
|
||||
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
|
||||
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
|
||||
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
|
||||
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
|
||||
return candidates;
|
||||
|
||||
var distances = _distanceComputer.ComputeDistances(
|
||||
part1Lines, part2TemplateLines, offsets);
|
||||
part1Entities, part2Entities, offsets);
|
||||
|
||||
var testNumber = 0;
|
||||
|
||||
@@ -89,15 +89,20 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
if (isHorizontalPush)
|
||||
{
|
||||
perpMin = -(bbox2.Length + spacing);
|
||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
// Perpendicular sweep along Y → Width; push extent along X → Length
|
||||
// Trim to offsets where the parts overlap by at least 50%.
|
||||
var halfOverlap = bbox2.Width * 0.5;
|
||||
perpMin = -(halfOverlap - spacing);
|
||||
perpMax = bbox1.Width + halfOverlap + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpMin = -(bbox2.Width + spacing);
|
||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
// Perpendicular sweep along X → Length; push extent along Y → Width
|
||||
var halfOverlap = bbox2.Length * 0.5;
|
||||
perpMin = -(halfOverlap - spacing);
|
||||
perpMax = bbox1.Length + halfOverlap + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
}
|
||||
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
|
||||
@@ -139,28 +139,81 @@ namespace OpenNest
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||
|
||||
var best = bestFits.FirstOrDefault(r => r.Keep);
|
||||
if (best == null)
|
||||
return null;
|
||||
List<Part> bestPlacement = null;
|
||||
|
||||
var parts = best.BuildParts(drawing);
|
||||
foreach (var fit in bestFits)
|
||||
{
|
||||
if (!fit.Keep)
|
||||
continue;
|
||||
|
||||
// BuildParts positions at origin — offset to work area.
|
||||
// Skip pairs that can't possibly fit the work area in either orientation.
|
||||
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||
continue;
|
||||
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var landscape = fit.BuildParts(drawing);
|
||||
var portrait = RotatePair90(landscape);
|
||||
|
||||
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
||||
var pFits = TryOffsetToWorkArea(portrait, workArea);
|
||||
|
||||
// Pick the better orientation for this pair.
|
||||
List<Part> candidate = null;
|
||||
if (lFits && pFits)
|
||||
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
|
||||
else if (lFits)
|
||||
candidate = landscape;
|
||||
else if (pFits)
|
||||
candidate = portrait;
|
||||
|
||||
if (candidate == null)
|
||||
continue;
|
||||
|
||||
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
|
||||
bestPlacement = candidate;
|
||||
}
|
||||
|
||||
return bestPlacement;
|
||||
}
|
||||
|
||||
private static List<Part> RotatePair90(List<Part> parts)
|
||||
{
|
||||
var rotated = new List<Part>(parts.Count);
|
||||
foreach (var p in parts)
|
||||
rotated.Add((Part)p.Clone());
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||
var center = bbox.Center;
|
||||
|
||||
foreach (var p in rotated)
|
||||
p.Rotate(-Angle.HalfPI, center);
|
||||
|
||||
var newBbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||
var offset = new Vector(-newBbox.Left, -newBbox.Bottom);
|
||||
foreach (var p in rotated)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static bool TryOffsetToWorkArea(List<Part> parts, Box workArea)
|
||||
{
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var offset = workArea.Location - bbox.Location;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
|
||||
// Verify pair fits in work area.
|
||||
bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return parts;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -203,7 +256,7 @@ namespace OpenNest
|
||||
if (newWidth >= workArea.Width && newLength >= workArea.Length)
|
||||
return workArea;
|
||||
|
||||
return new Box(workArea.X, workArea.Y, newWidth, newLength);
|
||||
return new Box(workArea.X, workArea.Y, newLength, newWidth);
|
||||
}
|
||||
|
||||
private List<Part> RunFillPipeline(NestItem item, Box workArea,
|
||||
@@ -295,6 +348,7 @@ namespace OpenNest
|
||||
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
context.ActivePhase = strategy.Phase;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = strategy.Fill(context);
|
||||
@@ -308,14 +362,18 @@ namespace OpenNest
|
||||
// during progress reporting.
|
||||
PhaseResults.Add(phaseResult);
|
||||
|
||||
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
|
||||
// FillContext.ReportProgress updates CurrentBest during the
|
||||
// strategy's angle sweep. This catches strategies that return a
|
||||
// result without reporting it (e.g. RectBestFit).
|
||||
var improved = context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea);
|
||||
if (improved)
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
}
|
||||
|
||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
{
|
||||
ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal record CombinationResult(bool Found, int Count1, int Count2);
|
||||
|
||||
internal static class BestCombination
|
||||
{
|
||||
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
|
||||
public static CombinationResult FindFrom2(double length1, double length2, double overallLength)
|
||||
{
|
||||
overallLength += Tolerance.Epsilon;
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
var count1 = 0;
|
||||
var count2 = 0;
|
||||
|
||||
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
||||
var bestRemnant = overallLength + 1;
|
||||
@@ -30,7 +32,7 @@ namespace OpenNest
|
||||
break;
|
||||
}
|
||||
|
||||
return count1 > 0 || count2 > 0;
|
||||
return new CombinationResult(count1 > 0 || count2 > 0, count1, count2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ namespace OpenNest.Engine.Fill
|
||||
/// </summary>
|
||||
public static class Compactor
|
||||
{
|
||||
private const double ChordTolerance = 0.001;
|
||||
|
||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||
{
|
||||
var obstacleParts = plate.Parts
|
||||
@@ -44,7 +42,7 @@ namespace OpenNest.Engine.Fill
|
||||
var opposite = -direction;
|
||||
|
||||
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||
var obstacleLines = new List<Line>[obstacleParts.Count];
|
||||
var obstacleEntities = new List<Entity>[obstacleParts.Count];
|
||||
|
||||
for (var i = 0; i < obstacleParts.Count; i++)
|
||||
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||
@@ -61,7 +59,19 @@ namespace OpenNest.Engine.Fill
|
||||
distance = edgeDist;
|
||||
|
||||
var movingBox = moving.BoundingBox;
|
||||
List<Line> movingLines = null;
|
||||
List<Entity> movingEntities = null;
|
||||
|
||||
// Check if any obstacle is inside the moving part — only then
|
||||
// do we need cutout entities on the moving part.
|
||||
var needCutouts = false;
|
||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||
{
|
||||
if (movingBox.Contains(obstacleBoxes[i]))
|
||||
{
|
||||
needCutouts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||
{
|
||||
@@ -76,15 +86,19 @@ namespace OpenNest.Engine.Fill
|
||||
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
|
||||
continue;
|
||||
|
||||
movingLines ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
|
||||
movingEntities ??= halfSpacing > 0
|
||||
? (needCutouts
|
||||
? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
|
||||
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
|
||||
: (needCutouts
|
||||
? PartGeometry.GetPartEntities(moving)
|
||||
: PartGeometry.GetPerimeterEntities(moving));
|
||||
|
||||
obstacleLines[i] ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
|
||||
obstacleEntities[i] ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||
|
||||
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
|
||||
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
}
|
||||
@@ -157,7 +171,7 @@ namespace OpenNest.Engine.Fill
|
||||
continue;
|
||||
|
||||
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||
var d = gap - partSpacing - 2 * ChordTolerance;
|
||||
var d = gap - partSpacing - 0.002;
|
||||
if (d < 0) d = 0;
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
|
||||
@@ -24,10 +24,8 @@ namespace OpenNest.Engine.Fill
|
||||
}
|
||||
|
||||
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null,
|
||||
List<Engine.BestFit.BestFitResult> bestFits = null)
|
||||
Action<List<Part>, string> reportProgress = null)
|
||||
{
|
||||
var pair = BuildPair(drawing, rotationAngle);
|
||||
if (pair == null)
|
||||
@@ -37,14 +35,7 @@ namespace OpenNest.Engine.Fill
|
||||
if (column.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = column,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: initial column {column.Count} parts",
|
||||
});
|
||||
reportProgress?.Invoke(column, $"Extents: initial column {column.Count} parts");
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
@@ -56,25 +47,11 @@ namespace OpenNest.Engine.Fill
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: column {adjusted.Count} parts",
|
||||
});
|
||||
reportProgress?.Invoke(adjusted, $"Extents: column {adjusted.Count} parts");
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = result,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: {result.Count} parts total",
|
||||
});
|
||||
reportProgress?.Invoke(result, $"Extents: {result.Count} parts total");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -96,7 +73,7 @@ namespace OpenNest.Engine.Fill
|
||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
||||
|
||||
// Position part2 to the right of part1 at bounding box width distance.
|
||||
var startOffset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing;
|
||||
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
|
||||
part2.Offset(startOffset, 0);
|
||||
part2.UpdateBounds();
|
||||
|
||||
@@ -135,7 +112,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
// Compute vertical copy distance using bounding boxes as starting point,
|
||||
// then slide down to find true geometry distance.
|
||||
var pairHeight = pair.Bbox.Length;
|
||||
var pairHeight = pair.Bbox.Width;
|
||||
var testOffset = new Vector(0, pairHeight);
|
||||
|
||||
// Create test parts for slide distance measurement.
|
||||
@@ -218,7 +195,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
private List<Part> AdjustColumn(PartPair pair, List<Part> column, CancellationToken token)
|
||||
{
|
||||
var originalPairWidth = pair.Bbox.Width;
|
||||
var originalPairWidth = pair.Bbox.Length;
|
||||
|
||||
for (var iteration = 0; iteration < MaxIterations; iteration++)
|
||||
{
|
||||
@@ -294,7 +271,7 @@ namespace OpenNest.Engine.Fill
|
||||
// Check if the pair got wider.
|
||||
var newBbox = PairBbox(p1, p2);
|
||||
|
||||
if (newBbox.Width > originalPairWidth + Tolerance.Epsilon)
|
||||
if (newBbox.Length > originalPairWidth + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return AnchorToWorkArea(p1, p2);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace OpenNest.Engine.Fill
|
||||
public FillLinear(Box workArea, double partSpacing)
|
||||
{
|
||||
PartSpacing = partSpacing;
|
||||
WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Length);
|
||||
WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
||||
}
|
||||
|
||||
public Box WorkArea { get; }
|
||||
@@ -41,7 +41,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
private static double GetDimension(Box box, NestDirection direction)
|
||||
{
|
||||
return direction == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
return direction == NestDirection.Horizontal ? box.Length : box.Width;
|
||||
}
|
||||
|
||||
private static double GetStart(Box box, NestDirection direction)
|
||||
@@ -119,10 +119,11 @@ namespace OpenNest.Engine.Fill
|
||||
var maxCopyDistance = FindMaxPairDistance(
|
||||
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
|
||||
|
||||
if (maxCopyDistance < Tolerance.Epsilon)
|
||||
return bboxDim + PartSpacing;
|
||||
|
||||
return maxCopyDistance;
|
||||
// The copy distance must be at least bboxDim + PartSpacing to prevent
|
||||
// bounding box overlap. Cross-pair slides can underestimate when the
|
||||
// circumscribed polygon boundary overshoots the true arc, creating
|
||||
// spurious contacts between diagonal parts in adjacent copies.
|
||||
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -45,9 +45,8 @@ namespace OpenNest.Engine.Fill
|
||||
}
|
||||
|
||||
public PairFillResult Fill(NestItem item, Box workArea,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null)
|
||||
Action<List<Part>, string> reportProgress = null)
|
||||
{
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
item.Drawing, plateSize.Length, plateSize.Width, partSpacing);
|
||||
@@ -58,7 +57,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var targetCount = item.Quantity > 0 ? item.Quantity : 0;
|
||||
var parts = EvaluateCandidates(candidates, item.Drawing, workArea, targetCount,
|
||||
plateNumber, token, progress);
|
||||
token, reportProgress);
|
||||
|
||||
return new PairFillResult { Parts = parts, BestFits = bestFits };
|
||||
}
|
||||
@@ -66,7 +65,7 @@ namespace OpenNest.Engine.Fill
|
||||
private List<Part> EvaluateCandidates(
|
||||
List<BestFitResult> candidates, Drawing drawing,
|
||||
Box workArea, int targetCount,
|
||||
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
|
||||
CancellationToken token, Action<List<Part>, string> reportProgress)
|
||||
{
|
||||
List<Part> best = null;
|
||||
var sinceImproved = 0;
|
||||
@@ -112,14 +111,8 @@ namespace OpenNest.Engine.Fill
|
||||
sinceImproved++;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Pairs,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
reportProgress?.Invoke(best,
|
||||
$"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts");
|
||||
}
|
||||
|
||||
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
||||
@@ -175,8 +168,8 @@ namespace OpenNest.Engine.Fill
|
||||
var newTop = remaining.Max(p => p.BoundingBox.Top);
|
||||
|
||||
return new Box(workArea.X, workArea.Y,
|
||||
workArea.Width,
|
||||
System.Math.Min(newTop - workArea.Y, workArea.Length));
|
||||
workArea.Length,
|
||||
System.Math.Min(newTop - workArea.Y, workArea.Width));
|
||||
}
|
||||
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
|
||||
@@ -271,8 +264,8 @@ namespace OpenNest.Engine.Fill
|
||||
var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
|
||||
var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
|
||||
|
||||
var topArea = workArea.Width * topHeight;
|
||||
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length);
|
||||
var topArea = workArea.Length * topHeight;
|
||||
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width);
|
||||
var remnantArea = topArea + rightArea;
|
||||
|
||||
return (int)(remnantArea * maxUtilization / partArea) + 1;
|
||||
@@ -292,7 +285,7 @@ namespace OpenNest.Engine.Fill
|
||||
var topLength = workArea.Top - topY;
|
||||
if (topLength >= minDim)
|
||||
{
|
||||
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
|
||||
var topBox = new Box(workArea.X, topY, workArea.Length, topLength);
|
||||
var parts = FillRemnantBox(drawing, topBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
@@ -303,7 +296,7 @@ namespace OpenNest.Engine.Fill
|
||||
var rightWidth = workArea.Right - rightX;
|
||||
if (rightWidth >= minDim)
|
||||
{
|
||||
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
|
||||
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width);
|
||||
var parts = FillRemnantBox(drawing, rightBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace OpenNest.Engine.Fill
|
||||
public PartBoundary(Part part, double spacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.Where(e => e.Layer == SpecialLayers.Cut)
|
||||
.ToList();
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
|
||||
@@ -13,15 +13,15 @@ namespace OpenNest.Engine.Fill
|
||||
var cellBox = cell.GetBoundingBox();
|
||||
var halfSpacing = partSpacing / 2;
|
||||
|
||||
var cellWidth = cellBox.Width + partSpacing;
|
||||
var cellHeight = cellBox.Length + partSpacing;
|
||||
var cellW = cellBox.Width + partSpacing;
|
||||
var cellL = cellBox.Length + partSpacing;
|
||||
|
||||
if (cellWidth <= 0 || cellHeight <= 0)
|
||||
if (cellW <= 0 || cellL <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Size.Width = X-axis, Size.Length = Y-axis
|
||||
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
|
||||
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
|
||||
// Width = Y axis, Length = X axis
|
||||
var cols = (int)System.Math.Floor(plateSize.Length / cellL);
|
||||
var rows = (int)System.Math.Floor(plateSize.Width / cellW);
|
||||
|
||||
if (cols <= 0 || rows <= 0)
|
||||
return new List<Part>();
|
||||
@@ -37,7 +37,7 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
for (var col = 0; col < cols; col++)
|
||||
{
|
||||
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
|
||||
var tileOffset = baseOffset + new Vector(col * cellL, row * cellW);
|
||||
|
||||
foreach (var part in cell)
|
||||
{
|
||||
|
||||
@@ -106,7 +106,7 @@ namespace OpenNest.Engine.Fill
|
||||
// rectangular obstacle boundary. Without this, gaps between
|
||||
// individual bounding boxes cause the next drawing to fill
|
||||
// into inter-row spaces, producing an interleaved layout.
|
||||
if (placed.Count > 1)
|
||||
if (placed.Count > 2)
|
||||
RemoveTopmostPart(placed);
|
||||
|
||||
allParts.AddRange(placed);
|
||||
|
||||
@@ -304,10 +304,10 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
// Edge extensions (priority 1).
|
||||
if (remnant.Right > envelope.Right + eps)
|
||||
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Length, 1, minDim);
|
||||
TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Width, 1, minDim);
|
||||
|
||||
if (remnant.Left < envelope.Left - eps)
|
||||
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Length, 1, minDim);
|
||||
TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Width, 1, minDim);
|
||||
|
||||
if (remnant.Top > envelope.Top + eps)
|
||||
TryAdd(results, innerLeft, envelope.Top, innerRight - innerLeft, remnant.Top - envelope.Top, 1, minDim);
|
||||
|
||||
@@ -95,14 +95,8 @@ public class StripeFiller
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = _context.PlateNumber,
|
||||
Parts = bestParts,
|
||||
WorkArea = workArea,
|
||||
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
|
||||
});
|
||||
_context.ReportProgress(bestParts,
|
||||
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts");
|
||||
}
|
||||
|
||||
return bestParts ?? new List<Part>();
|
||||
@@ -201,8 +195,8 @@ public class StripeFiller
|
||||
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
|
||||
{
|
||||
return primaryAxis == NestDirection.Horizontal
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
|
||||
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
|
||||
? new Box(workArea.X, workArea.Y, workArea.Length, perpDim)
|
||||
: new Box(workArea.X, workArea.Y, perpDim, workArea.Width);
|
||||
}
|
||||
|
||||
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
|
||||
@@ -224,7 +218,7 @@ public class StripeFiller
|
||||
var remnantLength = workArea.Top - remnantY;
|
||||
if (remnantLength < minDim)
|
||||
return null;
|
||||
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
|
||||
remnantBox = new Box(workArea.X, remnantY, workArea.Length, remnantLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -232,7 +226,7 @@ public class StripeFiller
|
||||
var remnantWidth = workArea.Right - remnantX;
|
||||
if (remnantWidth < minDim)
|
||||
return null;
|
||||
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
|
||||
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Width);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
|
||||
@@ -324,7 +318,7 @@ public class StripeFiller
|
||||
{
|
||||
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
|
||||
var span0 = GetDimension(box, axis);
|
||||
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
|
||||
var perpSpan0 = axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
|
||||
if (span0 <= perpSpan0)
|
||||
return 0;
|
||||
@@ -388,7 +382,7 @@ public class StripeFiller
|
||||
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
|
||||
var pairSpan = GetDimension(rotated.BoundingBox, axis);
|
||||
var perpDim = axis == NestDirection.Horizontal
|
||||
? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
|
||||
? rotated.BoundingBox.Width : rotated.BoundingBox.Length;
|
||||
|
||||
if (pairSpan + spacing <= 0)
|
||||
break;
|
||||
@@ -472,13 +466,13 @@ public class StripeFiller
|
||||
{
|
||||
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
|
||||
return axis == NestDirection.Horizontal
|
||||
? rotated.BoundingBox.Width
|
||||
: rotated.BoundingBox.Length;
|
||||
? rotated.BoundingBox.Length
|
||||
: rotated.BoundingBox.Width;
|
||||
}
|
||||
|
||||
private static double GetDimension(Box box, NestDirection axis)
|
||||
{
|
||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
return axis == NestDirection.Horizontal ? box.Length : box.Width;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts) =>
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace OpenNest
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var cos = System.Math.Abs(System.Math.Cos(angle));
|
||||
var sin = System.Math.Abs(System.Math.Sin(angle));
|
||||
return bb.Length * cos + bb.Width * sin;
|
||||
return bb.Width * cos + bb.Length * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace OpenNest.Engine.ML
|
||||
{
|
||||
Area = drawing.Area,
|
||||
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
|
||||
AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0),
|
||||
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
|
||||
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
||||
VertexCount = polygon.Vertices.Count,
|
||||
Bitmask = GenerateBitmask(polygon, 32)
|
||||
@@ -72,8 +72,8 @@ namespace OpenNest.Engine.ML
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
// Map grid coordinate (0..size) to bounding box coordinate
|
||||
var px = bb.Left + (x + 0.5) * (bb.Width / size);
|
||||
var py = bb.Bottom + (y + 0.5) * (bb.Length / size);
|
||||
var px = bb.Left + (x + 0.5) * (bb.Length / size);
|
||||
var py = bb.Bottom + (y + 0.5) * (bb.Width / size);
|
||||
|
||||
if (polygon.ContainsPoint(new Vector(px, py)))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum PartClass
|
||||
{
|
||||
Large,
|
||||
Medium,
|
||||
Small,
|
||||
}
|
||||
|
||||
public class MultiPlateNester
|
||||
{
|
||||
private readonly Plate _template;
|
||||
private readonly List<PlateOption> _plateOptions;
|
||||
private readonly List<PlateOption> _sortedOptions;
|
||||
private readonly double _salvageRate;
|
||||
private readonly double _minRemnantSize;
|
||||
private readonly List<PlateResult> _platePool;
|
||||
private readonly IProgress<NestProgress> _progress;
|
||||
private readonly CancellationToken _token;
|
||||
private readonly MultiPlateNestOptions _options;
|
||||
|
||||
private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0;
|
||||
|
||||
private MultiPlateNester(
|
||||
MultiPlateNestOptions options,
|
||||
List<Plate> existingPlates,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
_options = options;
|
||||
_template = options.Template;
|
||||
_plateOptions = options.PlateOptions;
|
||||
_sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList();
|
||||
_salvageRate = options.SalvageRate;
|
||||
_minRemnantSize = options.MinRemnantSize;
|
||||
_platePool = InitializePlatePool(existingPlates);
|
||||
_progress = progress;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
// --- Static Utility Methods ---
|
||||
|
||||
public static bool FitsBounds(Box container, Box part)
|
||||
{
|
||||
var fitsNormal = container.Width >= part.Width - Tolerance.Epsilon
|
||||
&& container.Length >= part.Length - Tolerance.Epsilon;
|
||||
var fitsRotated = container.Width >= part.Length - Tolerance.Epsilon
|
||||
&& container.Length >= part.Width - Tolerance.Epsilon;
|
||||
return fitsNormal || fitsRotated;
|
||||
}
|
||||
|
||||
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
|
||||
{
|
||||
var withBounds = items.Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox())).ToList();
|
||||
|
||||
switch (sortOrder)
|
||||
{
|
||||
case PartSortOrder.BoundingBoxArea:
|
||||
return withBounds
|
||||
.OrderByDescending(x => x.Bounds.Width * x.Bounds.Length)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
case PartSortOrder.Size:
|
||||
return withBounds
|
||||
.OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length))
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
default:
|
||||
return items.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public static PartClass Classify(Box partBounds, Box workArea)
|
||||
{
|
||||
var halfWidth = workArea.Width / 2.0;
|
||||
var halfLength = workArea.Length / 2.0;
|
||||
|
||||
if (partBounds.Width > halfWidth || partBounds.Length > halfLength)
|
||||
return PartClass.Large;
|
||||
|
||||
var workAreaArea = workArea.Width * workArea.Length;
|
||||
var partArea = partBounds.Width * partBounds.Length;
|
||||
|
||||
if (partArea > workAreaArea / 9.0)
|
||||
return PartClass.Medium;
|
||||
|
||||
return PartClass.Small;
|
||||
}
|
||||
|
||||
public static bool IsScrapRemnant(Box remnant, double minRemnantSize)
|
||||
{
|
||||
return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize;
|
||||
}
|
||||
|
||||
public static List<Box> FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly)
|
||||
{
|
||||
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList();
|
||||
}
|
||||
|
||||
public struct UpgradeDecision
|
||||
{
|
||||
public bool ShouldUpgrade;
|
||||
public double UpgradeCost;
|
||||
public double NewPlateCost;
|
||||
}
|
||||
|
||||
public static Plate CreatePlate(Plate template, List<PlateOption> options, Box minBounds)
|
||||
{
|
||||
var plate = new Plate(template.Size)
|
||||
{
|
||||
PartSpacing = template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
};
|
||||
plate.EdgeSpacing = new Spacing
|
||||
{
|
||||
Left = template.EdgeSpacing.Left,
|
||||
Right = template.EdgeSpacing.Right,
|
||||
Top = template.EdgeSpacing.Top,
|
||||
Bottom = template.EdgeSpacing.Bottom,
|
||||
};
|
||||
|
||||
if (options == null || options.Count == 0 || minBounds == null)
|
||||
return plate;
|
||||
|
||||
var sorted = options.OrderBy(o => o.Cost).ToList();
|
||||
|
||||
foreach (var option in sorted)
|
||||
{
|
||||
if (FitsBounds(OptionWorkArea(option, template), minBounds))
|
||||
{
|
||||
plate.Size = new Size(option.Width, option.Length);
|
||||
return plate;
|
||||
}
|
||||
}
|
||||
|
||||
return plate;
|
||||
}
|
||||
|
||||
public static UpgradeDecision EvaluateUpgradeVsNew(
|
||||
PlateOption currentSize,
|
||||
PlateOption upgradeSize,
|
||||
PlateOption newPlateSize,
|
||||
double salvageRate,
|
||||
double estimatedNewPlateUtilization)
|
||||
{
|
||||
var upgradeCost = upgradeSize.Cost - currentSize.Cost;
|
||||
|
||||
var newPlateCost = newPlateSize.Cost;
|
||||
var remnantFraction = 1.0 - estimatedNewPlateUtilization;
|
||||
var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate;
|
||||
var netNewCost = newPlateCost - salvageCredit;
|
||||
|
||||
return new UpgradeDecision
|
||||
{
|
||||
ShouldUpgrade = upgradeCost <= netNewCost,
|
||||
UpgradeCost = upgradeCost,
|
||||
NewPlateCost = netNewCost,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Main Entry Point ---
|
||||
|
||||
public static MultiPlateResult Nest(
|
||||
List<NestItem> items,
|
||||
MultiPlateNestOptions options,
|
||||
List<Plate> existingPlates = null,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var nester = new MultiPlateNester(options, existingPlates, progress, token);
|
||||
return nester.Run(items, options.SortOrder, options.AllowPlateCreation);
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
private static Box OptionWorkArea(PlateOption option, Plate template)
|
||||
{
|
||||
var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
|
||||
var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
|
||||
return new Box(0, 0, w, h);
|
||||
}
|
||||
|
||||
private static double ScoreZone(Box zone, Box partBounds)
|
||||
{
|
||||
if (!FitsBounds(zone, partBounds))
|
||||
return -1;
|
||||
|
||||
var cols = (int)(zone.Width / partBounds.Width);
|
||||
var rows = (int)(zone.Length / partBounds.Length);
|
||||
var colsR = (int)(zone.Width / partBounds.Length);
|
||||
var rowsR = (int)(zone.Length / partBounds.Width);
|
||||
var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR);
|
||||
|
||||
var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area();
|
||||
|
||||
var zoneAspect = zone.Width / zone.Length;
|
||||
var partAspect = partBounds.Width / partBounds.Length;
|
||||
var aspectMatch = System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect);
|
||||
|
||||
return utilization * 0.7 + aspectMatch * 0.3;
|
||||
}
|
||||
|
||||
private static void DecrementQuantity(NestItem item, int placed)
|
||||
{
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
|
||||
{
|
||||
var engine = NestEngineRegistry.Create(pr.Plate);
|
||||
var clonedItem = CloneItem(item);
|
||||
var parts = engine.Fill(clonedItem, zone, _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
pr.AddParts(parts);
|
||||
DecrementQuantity(item, parts.Count);
|
||||
}
|
||||
|
||||
return parts.Count;
|
||||
}
|
||||
|
||||
private PlateResult CreateNewPlateResult(Plate plate)
|
||||
{
|
||||
var pr = new PlateResult { Plate = plate, IsNew = true };
|
||||
|
||||
if (HasPlateOptions)
|
||||
{
|
||||
pr.ChosenSize = _plateOptions.FirstOrDefault(o =>
|
||||
o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length));
|
||||
}
|
||||
|
||||
return pr;
|
||||
}
|
||||
|
||||
private static NestItem CloneItem(NestItem item)
|
||||
{
|
||||
return new NestItem
|
||||
{
|
||||
Drawing = item.Drawing,
|
||||
Priority = item.Priority,
|
||||
Quantity = item.Quantity,
|
||||
StepAngle = item.StepAngle,
|
||||
RotationStart = item.RotationStart,
|
||||
RotationEnd = item.RotationEnd,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PlateResult> InitializePlatePool(List<Plate> existingPlates)
|
||||
{
|
||||
var pool = new List<PlateResult>();
|
||||
|
||||
if (existingPlates != null)
|
||||
{
|
||||
foreach (var plate in existingPlates)
|
||||
pool.Add(new PlateResult { Plate = plate, IsNew = false });
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private bool TryWithUpgradedSize(PlateResult pr, PlateOption upgradeOption, Func<List<Box>, bool> tryFill)
|
||||
{
|
||||
var oldSize = pr.Plate.Size;
|
||||
var oldChosenSize = pr.ChosenSize;
|
||||
|
||||
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
|
||||
pr.ChosenSize = upgradeOption;
|
||||
|
||||
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
|
||||
|
||||
if (remnants.Count > 0 && tryFill(remnants))
|
||||
return true;
|
||||
|
||||
pr.Plate.Size = oldSize;
|
||||
pr.ChosenSize = oldChosenSize;
|
||||
return false;
|
||||
}
|
||||
|
||||
private PlateOption FindSmallestFittingOption(Box partBounds)
|
||||
{
|
||||
return _sortedOptions?.FirstOrDefault(o => FitsBounds(OptionWorkArea(o, _template), partBounds));
|
||||
}
|
||||
|
||||
// --- Orchestration ---
|
||||
|
||||
private MultiPlateResult Run(List<NestItem> items, PartSortOrder sortOrder, bool allowPlateCreation)
|
||||
{
|
||||
var result = new MultiPlateResult();
|
||||
|
||||
if (items == null || items.Count == 0)
|
||||
return result;
|
||||
|
||||
var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder);
|
||||
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
if (_token.IsCancellationRequested || item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
|
||||
TryPlaceOnExistingPlates(item, bb);
|
||||
|
||||
var templateClass = Classify(bb, _template.WorkArea());
|
||||
|
||||
if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small)
|
||||
{
|
||||
PlaceOnNewPlates(item, bb);
|
||||
|
||||
if (item.Quantity > 0 && HasPlateOptions)
|
||||
TryUpgradeOrNewPlate(item, bb);
|
||||
}
|
||||
}
|
||||
|
||||
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
|
||||
{
|
||||
PackIntoExistingRemnants(leftovers);
|
||||
CreateSharedPlates(leftovers);
|
||||
}
|
||||
|
||||
if (HasPlateOptions && !_token.IsCancellationRequested)
|
||||
TryConsolidateTailPlates();
|
||||
|
||||
foreach (var item in sorted.Where(i => i.Quantity > 0))
|
||||
result.UnplacedItems.Add(item);
|
||||
|
||||
result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew));
|
||||
return result;
|
||||
}
|
||||
|
||||
private void PackIntoExistingRemnants(List<NestItem> leftovers)
|
||||
{
|
||||
foreach (var pr in _platePool)
|
||||
{
|
||||
if (_token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var anyPlaced = true;
|
||||
while (anyPlaced && !_token.IsCancellationRequested)
|
||||
{
|
||||
anyPlaced = false;
|
||||
|
||||
var remaining = leftovers.Where(i => i.Quantity > 0).ToList();
|
||||
if (remaining.Count == 0)
|
||||
break;
|
||||
|
||||
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
|
||||
if (remnants.Count == 0)
|
||||
break;
|
||||
|
||||
var engine = NestEngineRegistry.Create(pr.Plate);
|
||||
|
||||
foreach (var remnant in remnants)
|
||||
{
|
||||
remaining = leftovers.Where(i => i.Quantity > 0).ToList();
|
||||
if (remaining.Count == 0)
|
||||
break;
|
||||
|
||||
var cloned = remaining.Select(CloneItem).ToList();
|
||||
var parts = engine.PackArea(remnant, cloned, _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
pr.AddParts(parts);
|
||||
anyPlaced = true;
|
||||
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
|
||||
DecrementQuantity(item, placed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSharedPlates(List<NestItem> leftovers)
|
||||
{
|
||||
leftovers.RemoveAll(i => i.Quantity <= 0);
|
||||
|
||||
while (leftovers.Count > 0 && !_token.IsCancellationRequested)
|
||||
{
|
||||
var plate = CreatePlate(_template, _plateOptions, null);
|
||||
var pr = CreateNewPlateResult(plate);
|
||||
var placedAny = false;
|
||||
|
||||
foreach (var item in leftovers)
|
||||
{
|
||||
if (item.Quantity <= 0 || _token.IsCancellationRequested)
|
||||
continue;
|
||||
|
||||
var remnants = !placedAny
|
||||
? new List<Box> { plate.WorkArea() }
|
||||
: RemnantFinder.FromPlate(plate).FindRemnants();
|
||||
|
||||
if (remnants.Count == 0)
|
||||
break;
|
||||
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
|
||||
foreach (var remnant in remnants)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
break;
|
||||
|
||||
var clonedItem = CloneItem(item);
|
||||
var parts = engine.Fill(clonedItem, remnant, _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
pr.AddParts(parts);
|
||||
DecrementQuantity(item, parts.Count);
|
||||
placedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedAny)
|
||||
break;
|
||||
|
||||
_platePool.Add(pr);
|
||||
leftovers.RemoveAll(i => i.Quantity <= 0);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
|
||||
{
|
||||
var anyPlaced = false;
|
||||
var remnantCache = new Dictionary<PlateResult, List<Box>>();
|
||||
PlateResult lastModified = null;
|
||||
|
||||
while (item.Quantity > 0 && !_token.IsCancellationRequested)
|
||||
{
|
||||
PlateResult bestPlate = null;
|
||||
Box bestZone = null;
|
||||
var bestScore = double.MinValue;
|
||||
|
||||
foreach (var pr in _platePool)
|
||||
{
|
||||
if (_token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (pr == lastModified || !remnantCache.ContainsKey(pr))
|
||||
{
|
||||
var workArea = pr.Plate.WorkArea();
|
||||
var classification = Classify(partBounds, workArea);
|
||||
|
||||
remnantCache[pr] = classification == PartClass.Small
|
||||
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
|
||||
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
|
||||
}
|
||||
|
||||
foreach (var zone in remnantCache[pr])
|
||||
{
|
||||
var score = ScoreZone(zone, partBounds);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestPlate = pr;
|
||||
bestZone = zone;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPlate == null || bestZone == null)
|
||||
break;
|
||||
|
||||
if (FillAndPlace(bestPlate, bestZone, item) == 0)
|
||||
break;
|
||||
|
||||
lastModified = bestPlate;
|
||||
anyPlaced = true;
|
||||
}
|
||||
|
||||
return anyPlaced;
|
||||
}
|
||||
|
||||
private bool PlaceOnNewPlates(NestItem item, Box partBounds)
|
||||
{
|
||||
var anyPlaced = false;
|
||||
|
||||
while (item.Quantity > 0 && !_token.IsCancellationRequested)
|
||||
{
|
||||
var plate = CreatePlate(_template, _plateOptions, partBounds);
|
||||
var workArea = plate.WorkArea();
|
||||
|
||||
if (!FitsBounds(workArea, partBounds))
|
||||
break;
|
||||
|
||||
var pr = CreateNewPlateResult(plate);
|
||||
|
||||
if (FillAndPlace(pr, workArea, item) == 0)
|
||||
break;
|
||||
|
||||
_platePool.Add(pr);
|
||||
anyPlaced = true;
|
||||
}
|
||||
|
||||
return anyPlaced;
|
||||
}
|
||||
|
||||
private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds)
|
||||
{
|
||||
if (!HasPlateOptions)
|
||||
return false;
|
||||
|
||||
foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null))
|
||||
{
|
||||
var currentOption = pr.ChosenSize;
|
||||
var currentIdx = _sortedOptions.FindIndex(o =>
|
||||
o.Width.IsEqualTo(currentOption.Width) && o.Length.IsEqualTo(currentOption.Length));
|
||||
|
||||
if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1)
|
||||
continue;
|
||||
|
||||
for (var i = currentIdx + 1; i < _sortedOptions.Count; i++)
|
||||
{
|
||||
var upgradeOption = _sortedOptions[i];
|
||||
|
||||
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|
||||
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var smallestNew = FindSmallestFittingOption(partBounds);
|
||||
|
||||
if (smallestNew == null)
|
||||
continue;
|
||||
|
||||
var utilEst = pr.Plate.Utilization();
|
||||
var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew,
|
||||
_salvageRate, utilEst);
|
||||
|
||||
if (decision.ShouldUpgrade)
|
||||
{
|
||||
var placed = TryWithUpgradedSize(pr, upgradeOption, remnants =>
|
||||
{
|
||||
foreach (var remnant in remnants)
|
||||
{
|
||||
if (FillAndPlace(pr, remnant, item) > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (placed)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void TryConsolidateTailPlates()
|
||||
{
|
||||
var consolidated = true;
|
||||
while (consolidated)
|
||||
{
|
||||
consolidated = false;
|
||||
|
||||
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
|
||||
if (activePlates.Count < 2)
|
||||
return;
|
||||
|
||||
var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList();
|
||||
|
||||
foreach (var donor in donors)
|
||||
{
|
||||
if (donor.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var donorParts = donor.Parts.ToList();
|
||||
var absorbed = false;
|
||||
|
||||
foreach (var target in activePlates)
|
||||
{
|
||||
if (target == donor || target.ChosenSize == null || target.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var currentOption = target.ChosenSize;
|
||||
|
||||
foreach (var upgradeOption in _sortedOptions.Where(o =>
|
||||
o.Width >= currentOption.Width - Tolerance.Epsilon
|
||||
&& o.Length >= currentOption.Length - Tolerance.Epsilon
|
||||
&& (o.Width > currentOption.Width + Tolerance.Epsilon
|
||||
|| o.Length > currentOption.Length + Tolerance.Epsilon)))
|
||||
{
|
||||
absorbed = TryWithUpgradedSize(target, upgradeOption, remnants =>
|
||||
{
|
||||
var engine = NestEngineRegistry.Create(target.Plate);
|
||||
var tempItems = donorParts
|
||||
.GroupBy(p => p.BaseDrawing)
|
||||
.Select(g => new NestItem
|
||||
{
|
||||
Drawing = g.Key,
|
||||
Quantity = g.Count(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var totalPlaced = new List<Part>();
|
||||
foreach (var remnant in remnants)
|
||||
{
|
||||
var placed = engine.PackArea(remnant, tempItems, _progress, _token);
|
||||
totalPlaced.AddRange(placed);
|
||||
|
||||
foreach (var ti in tempItems)
|
||||
{
|
||||
var count = placed.Count(p => p.BaseDrawing == ti.Drawing);
|
||||
ti.Quantity = System.Math.Max(0, ti.Quantity - count);
|
||||
}
|
||||
|
||||
if (tempItems.All(ti => ti.Quantity <= 0))
|
||||
break;
|
||||
}
|
||||
|
||||
if (totalPlaced.Count >= donorParts.Count)
|
||||
{
|
||||
target.AddParts(totalPlaced);
|
||||
|
||||
foreach (var p in donorParts)
|
||||
donor.Plate.Parts.Remove(p);
|
||||
donor.Parts.Clear();
|
||||
_platePool.Remove(donor);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (absorbed)
|
||||
break;
|
||||
}
|
||||
|
||||
if (absorbed)
|
||||
break;
|
||||
}
|
||||
|
||||
if (absorbed)
|
||||
{
|
||||
consolidated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class MultiPlateNestOptions
|
||||
{
|
||||
public Plate Template { get; set; }
|
||||
public List<PlateOption> PlateOptions { get; set; }
|
||||
public double SalvageRate { get; set; } = 0.5;
|
||||
public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea;
|
||||
public double MinRemnantSize { get; set; } = 12.0;
|
||||
public bool AllowPlateCreation { get; set; } = true;
|
||||
}
|
||||
|
||||
public class MultiPlateResult
|
||||
{
|
||||
public List<PlateResult> Plates { get; set; } = new();
|
||||
public List<NestItem> UnplacedItems { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PlateResult
|
||||
{
|
||||
public Plate Plate { get; set; }
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public PlateOption ChosenSize { get; set; }
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
public void AddParts(IList<Part> parts)
|
||||
{
|
||||
Plate.Parts.AddRange(parts);
|
||||
Parts.AddRange(parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,45 +333,56 @@ namespace OpenNest
|
||||
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||
var bestFit = bestFits.FirstOrDefault(r => r.Keep);
|
||||
if (bestFit == null) continue;
|
||||
|
||||
var parts = bestFit.BuildParts(item.Drawing);
|
||||
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var pairW = pairBbox.Width;
|
||||
var pairL = pairBbox.Length;
|
||||
var minDim = System.Math.Min(pairW, pairL);
|
||||
List<Part> bestPlacement = null;
|
||||
Box bestTarget = null;
|
||||
|
||||
var remnants = finder.FindRemnants(minDim);
|
||||
Box target = null;
|
||||
|
||||
foreach (var r in remnants)
|
||||
foreach (var fit in bestFits)
|
||||
{
|
||||
if (pairW <= r.Width + Tolerance.Epsilon &&
|
||||
pairL <= r.Length + Tolerance.Epsilon)
|
||||
if (!fit.Keep)
|
||||
continue;
|
||||
|
||||
var parts = fit.BuildParts(item.Drawing);
|
||||
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var pairW = pairBbox.Width;
|
||||
var pairL = pairBbox.Length;
|
||||
var minDim = System.Math.Min(pairW, pairL);
|
||||
|
||||
var remnants = finder.FindRemnants(minDim);
|
||||
|
||||
foreach (var r in remnants)
|
||||
{
|
||||
target = r;
|
||||
break;
|
||||
if (pairW <= r.Width + Tolerance.Epsilon &&
|
||||
pairL <= r.Length + Tolerance.Epsilon)
|
||||
{
|
||||
var offset = r.Location - pairBbox.Location;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
|
||||
if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r))
|
||||
{
|
||||
bestPlacement = parts;
|
||||
bestTarget = r;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target == null) continue;
|
||||
if (bestPlacement == null) continue;
|
||||
|
||||
var offset = target.Location - pairBbox.Location;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
|
||||
result.AddRange(parts);
|
||||
result.AddRange(bestPlacement);
|
||||
item.Quantity = 0;
|
||||
|
||||
var envelope = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
|
||||
finder.AddObstacle(envelope.Offset(Plate.PartSpacing));
|
||||
|
||||
Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " +
|
||||
$"at ({target.X:F1},{target.Y:F1}), size {pairW:F1}x{pairL:F1}");
|
||||
$"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " +
|
||||
$"size {envelope.Width:F1}x{envelope.Length:F1}");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum PartSortOrder
|
||||
{
|
||||
BoundingBoxArea,
|
||||
Size,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class PlateOptimizer
|
||||
{
|
||||
public static PlateOptimizerResult Optimize(
|
||||
List<NestItem> items,
|
||||
List<PlateOption> plateOptions,
|
||||
double salvageRate,
|
||||
Plate templatePlate,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
|
||||
return null;
|
||||
|
||||
// Find the minimum dimension needed to fit the largest part,
|
||||
// skipping items that are too large for every plate option.
|
||||
var minPartWidth = 0.0;
|
||||
var minPartLength = 0.0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0) continue;
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var shortSide = System.Math.Min(bb.Width, bb.Length);
|
||||
var longSide = System.Math.Max(bb.Width, bb.Length);
|
||||
|
||||
if (!plateOptions.Any(o => FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)))
|
||||
{
|
||||
Debug.WriteLine($"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " +
|
||||
$"({shortSide:F1}x{longSide:F1}) — does not fit any plate option");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shortSide > minPartWidth) minPartWidth = shortSide;
|
||||
if (longSide > minPartLength) minPartLength = longSide;
|
||||
}
|
||||
|
||||
// Sort candidates by cost ascending — try cheapest first.
|
||||
var candidates = plateOptions
|
||||
.Where(o => FitsPart(o, minPartWidth, minPartLength, templatePlate.EdgeSpacing))
|
||||
.OrderBy(o => o.Cost)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
|
||||
// Pre-compute best fits for all candidate plate sizes at once.
|
||||
// This runs the expensive GPU evaluation once on the largest plate
|
||||
// and filters the results for each smaller size.
|
||||
var plateSizes = candidates
|
||||
.Select(o => (Width: o.Length, Height: o.Width))
|
||||
.ToList();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0) continue;
|
||||
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
|
||||
}
|
||||
|
||||
PlateOptimizerResult best = null;
|
||||
|
||||
foreach (var option in candidates)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var result = TryPlateSize(option, items, salvageRate, templatePlate, progress, token);
|
||||
if (result == null)
|
||||
continue;
|
||||
|
||||
if (IsBetter(result, best))
|
||||
best = result;
|
||||
|
||||
// Early exit: when all items fit, larger plates can only have
|
||||
// worse utilization and higher cost. With salvage < 100%, the
|
||||
// remnant credit never offsets the extra plate cost, so skip.
|
||||
if (salvageRate < 1.0)
|
||||
{
|
||||
var allPlaced = items.All(i => i.Quantity <= 0 ||
|
||||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
|
||||
if (allPlaced)
|
||||
{
|
||||
Debug.WriteLine($"[PlateOptimizer] Early exit: {option.Width}x{option.Length} placed all items");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool FitsPart(PlateOption option, double minWidth, double minLength, Spacing edgeSpacing)
|
||||
{
|
||||
var workW = option.Width - edgeSpacing.Left - edgeSpacing.Right;
|
||||
var workL = option.Length - edgeSpacing.Top - edgeSpacing.Bottom;
|
||||
|
||||
// Part fits in either orientation.
|
||||
var fitsNormal = workW >= minWidth - Tolerance.Epsilon && workL >= minLength - Tolerance.Epsilon;
|
||||
var fitsRotated = workW >= minLength - Tolerance.Epsilon && workL >= minWidth - Tolerance.Epsilon;
|
||||
return fitsNormal || fitsRotated;
|
||||
}
|
||||
|
||||
private static PlateOptimizerResult TryPlateSize(
|
||||
PlateOption option,
|
||||
List<NestItem> items,
|
||||
double salvageRate,
|
||||
Plate templatePlate,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token)
|
||||
{
|
||||
// Create a temporary plate with candidate size + settings from template.
|
||||
var tempPlate = new Plate(option.Width, option.Length)
|
||||
{
|
||||
PartSpacing = templatePlate.PartSpacing,
|
||||
EdgeSpacing = new Spacing
|
||||
{
|
||||
Left = templatePlate.EdgeSpacing.Left,
|
||||
Right = templatePlate.EdgeSpacing.Right,
|
||||
Top = templatePlate.EdgeSpacing.Top,
|
||||
Bottom = templatePlate.EdgeSpacing.Bottom,
|
||||
},
|
||||
};
|
||||
|
||||
// Clone items so the dry run doesn't mutate originals.
|
||||
var clonedItems = items.Select(i => new NestItem
|
||||
{
|
||||
Drawing = i.Drawing, // share Drawing reference for BestFitCache compatibility
|
||||
Priority = i.Priority,
|
||||
Quantity = i.Quantity,
|
||||
StepAngle = i.StepAngle,
|
||||
RotationStart = i.RotationStart,
|
||||
RotationEnd = i.RotationEnd,
|
||||
}).ToList();
|
||||
|
||||
var engine = NestEngineRegistry.Create(tempPlate);
|
||||
var parts = engine.Nest(clonedItems, progress, token);
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return null;
|
||||
|
||||
var workArea = tempPlate.WorkArea();
|
||||
var plateArea = workArea.Width * workArea.Length;
|
||||
var partsArea = 0.0;
|
||||
foreach (var part in parts)
|
||||
partsArea += part.BoundingBox.Area();
|
||||
|
||||
var remnantArea = plateArea - partsArea;
|
||||
var costPerSqUnit = option.Cost / option.Area;
|
||||
var netCost = option.Cost - (remnantArea * costPerSqUnit * salvageRate);
|
||||
|
||||
Debug.WriteLine($"[PlateOptimizer] {option.Width}x{option.Length} ${option.Cost}: " +
|
||||
$"{parts.Count} parts, util={partsArea / plateArea:P1}, net=${netCost:F2}");
|
||||
|
||||
return new PlateOptimizerResult
|
||||
{
|
||||
Parts = parts,
|
||||
ChosenSize = option,
|
||||
NetCost = netCost,
|
||||
Utilization = plateArea > 0 ? partsArea / plateArea : 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsBetter(PlateOptimizerResult candidate, PlateOptimizerResult current)
|
||||
{
|
||||
if (current == null) return true;
|
||||
|
||||
// 1. More parts placed is always better.
|
||||
if (candidate.Parts.Count != current.Parts.Count)
|
||||
return candidate.Parts.Count > current.Parts.Count;
|
||||
|
||||
// 2. Lower net cost.
|
||||
if (!candidate.NetCost.IsEqualTo(current.NetCost))
|
||||
return candidate.NetCost < current.NetCost;
|
||||
|
||||
// 3. Higher utilization (tighter density) as tiebreak.
|
||||
return candidate.Utilization > current.Utilization;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
public class PlateResult
|
||||
public class PlateProcessingResult
|
||||
{
|
||||
public List<ProcessedPart> Parts { get; init; }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace OpenNest.Engine
|
||||
public ContourCuttingStrategy CuttingStrategy { get; set; }
|
||||
public IRapidPlanner RapidPlanner { get; set; }
|
||||
|
||||
public PlateResult Process(Plate plate)
|
||||
public PlateProcessingResult Process(Plate plate)
|
||||
{
|
||||
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
||||
var results = new List<ProcessedPart>(sequenced.Count);
|
||||
@@ -66,7 +66,7 @@ namespace OpenNest.Engine
|
||||
currentPoint = ToPlateSpace(lastCutLocal, part);
|
||||
}
|
||||
|
||||
return new PlateResult { Parts = results };
|
||||
return new PlateProcessingResult { Parts = results };
|
||||
}
|
||||
|
||||
private static Vector ToPartLocal(Vector platePoint, Part part)
|
||||
|
||||
@@ -29,11 +29,15 @@ namespace OpenNest.RectanglePacking
|
||||
Bin.Items.AddRange(bin1.Items);
|
||||
else
|
||||
Bin.Items.AddRange(bin2.Items);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override void Fill(Item item, int maxCount)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
Fill(item);
|
||||
|
||||
if (Bin.Items.Count > maxCount)
|
||||
Bin.Items.RemoveRange(maxCount, Bin.Items.Count - maxCount);
|
||||
}
|
||||
|
||||
private Bin BestFitHorizontal(Item item) => BestFitAxis(item, horizontal: true);
|
||||
@@ -44,14 +48,18 @@ namespace OpenNest.RectanglePacking
|
||||
{
|
||||
var bin = Bin.Clone() as Bin;
|
||||
|
||||
var primarySize = horizontal ? item.Width : item.Length;
|
||||
var secondarySize = horizontal ? item.Length : item.Width;
|
||||
var binPrimary = horizontal ? bin.Width : Bin.Length;
|
||||
var binSecondary = horizontal ? bin.Length : Bin.Width;
|
||||
var primarySize = horizontal ? item.Length : item.Width;
|
||||
var secondarySize = horizontal ? item.Width : item.Length;
|
||||
var binPrimary = horizontal ? bin.Length : Bin.Width;
|
||||
var binSecondary = horizontal ? bin.Width : Bin.Length;
|
||||
|
||||
if (!BestCombination.FindFrom2(primarySize, secondarySize, binPrimary, out var normalPrimary, out var rotatePrimary))
|
||||
var combo = BestCombination.FindFrom2(primarySize, secondarySize, binPrimary);
|
||||
if (!combo.Found)
|
||||
return bin;
|
||||
|
||||
var normalPrimary = combo.Count1;
|
||||
var rotatePrimary = combo.Count2;
|
||||
|
||||
var normalSecondary = (int)System.Math.Floor((binSecondary + Tolerance.Epsilon) / secondarySize);
|
||||
var rotateSecondary = (int)System.Math.Floor((binSecondary + Tolerance.Epsilon) / primarySize);
|
||||
|
||||
@@ -67,9 +75,9 @@ namespace OpenNest.RectanglePacking
|
||||
bin.Items.AddRange(FillGrid(item, normalRows, normalCols, int.MaxValue));
|
||||
|
||||
if (horizontal)
|
||||
item.Location.X += item.Width * normalPrimary;
|
||||
item.Location.X += item.Length * normalPrimary;
|
||||
else
|
||||
item.Location.Y += item.Length * normalPrimary;
|
||||
item.Location.Y += item.Width * normalPrimary;
|
||||
|
||||
item.Rotate();
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ namespace OpenNest.RectanglePacking
|
||||
{
|
||||
for (var j = 0; j < innerCount; j++)
|
||||
{
|
||||
var x = (columnMajor ? i : j) * item.Width + item.X;
|
||||
var y = (columnMajor ? j : i) * item.Length + item.Y;
|
||||
var x = (columnMajor ? i : j) * item.Length + item.X;
|
||||
var y = (columnMajor ? j : i) * item.Width + item.Y;
|
||||
|
||||
var clone = item.Clone() as Item;
|
||||
clone.Location = new Vector(x, y);
|
||||
|
||||
@@ -14,16 +14,16 @@ namespace OpenNest.RectanglePacking
|
||||
|
||||
public override void Fill(Item item)
|
||||
{
|
||||
var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
|
||||
var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
|
||||
var ycount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
|
||||
var xcount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
|
||||
|
||||
for (int i = 0; i < xcount; i++)
|
||||
{
|
||||
var x = item.Width * i + Bin.X;
|
||||
var x = item.Length * i + Bin.X;
|
||||
|
||||
for (int j = 0; j < ycount; j++)
|
||||
{
|
||||
var y = item.Length * j + Bin.Y;
|
||||
var y = item.Width * j + Bin.Y;
|
||||
|
||||
var addedItem = item.Clone() as Item;
|
||||
addedItem.Location = new Vector(x, y);
|
||||
@@ -35,8 +35,8 @@ namespace OpenNest.RectanglePacking
|
||||
|
||||
public override void Fill(Item item, int maxCount)
|
||||
{
|
||||
var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
|
||||
var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
|
||||
var ycount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width);
|
||||
var xcount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length);
|
||||
var count = ycount * xcount;
|
||||
|
||||
if (count <= maxCount)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.RectanglePacking
|
||||
{
|
||||
internal class FillSpiral : FillEngine
|
||||
{
|
||||
public Box CenterRemnant { get; private set; }
|
||||
|
||||
public FillSpiral(Bin bin)
|
||||
: base(bin)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Fill(Item item)
|
||||
{
|
||||
Fill(item, int.MaxValue);
|
||||
}
|
||||
|
||||
public override void Fill(Item item, int maxCount)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
// Width = Y axis, Length = X axis
|
||||
var comboY = BestCombination.FindFrom2(item.Width, item.Length, Bin.Width);
|
||||
var comboX = BestCombination.FindFrom2(item.Length, item.Width, Bin.Length);
|
||||
|
||||
if (!comboY.Found || !comboX.Found)
|
||||
return;
|
||||
|
||||
var q14size = new Size(
|
||||
item.Width * comboY.Count1,
|
||||
item.Length * comboX.Count1);
|
||||
var q23size = new Size(
|
||||
item.Length * comboY.Count2,
|
||||
item.Width * comboX.Count2);
|
||||
|
||||
if ((q14size.Width > q23size.Width && q14size.Length > q23size.Length) ||
|
||||
(q23size.Width > q14size.Width && q23size.Length > q14size.Length))
|
||||
return; // cant do an efficient spiral fill
|
||||
|
||||
// Q1: normal orientation at bin origin
|
||||
item.Location = Bin.Location;
|
||||
var q1 = FillGrid(item, comboY.Count1, comboX.Count1, maxCount);
|
||||
Bin.Items.AddRange(q1);
|
||||
|
||||
// Q2: rotated, above Q1
|
||||
item.Rotate();
|
||||
item.Location = new Vector(Bin.X, Bin.Y + q14size.Width);
|
||||
var q2 = FillGrid(item, comboY.Count2, comboX.Count2, maxCount - Bin.Items.Count);
|
||||
Bin.Items.AddRange(q2);
|
||||
|
||||
// Q3: rotated, right of Q1
|
||||
item.Location = new Vector(Bin.X + q14size.Length, Bin.Y);
|
||||
var q3 = FillGrid(item, comboY.Count2, comboX.Count2, maxCount - Bin.Items.Count);
|
||||
Bin.Items.AddRange(q3);
|
||||
|
||||
// Q4: normal orientation, diagonal from Q1
|
||||
item.Rotate();
|
||||
item.Location = new Vector(
|
||||
Bin.X + q23size.Length,
|
||||
Bin.Y + q23size.Width);
|
||||
var q4 = FillGrid(item, comboY.Count1, comboX.Count1, maxCount);
|
||||
Bin.Items.AddRange(q4);
|
||||
|
||||
// Compute center remnant — the rectangular gap between the 4 quadrants
|
||||
// Only valid when all 4 quadrants have items; otherwise the "center"
|
||||
// overlaps an occupied quadrant and recursion never terminates.
|
||||
var centerW = System.Math.Abs(q14size.Length - q23size.Length);
|
||||
var centerH = System.Math.Abs(q14size.Width - q23size.Width);
|
||||
|
||||
if (comboY.Count1 > 0 && comboY.Count2 > 0 && comboX.Count1 > 0 && comboX.Count2 > 0
|
||||
&& centerW > Tolerance.Epsilon && centerH > Tolerance.Epsilon)
|
||||
{
|
||||
CenterRemnant = new Box(
|
||||
Bin.X + System.Math.Min(q14size.Length, q23size.Length),
|
||||
Bin.Y + System.Math.Min(q14size.Width, q23size.Width),
|
||||
centerW,
|
||||
centerH);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ namespace OpenNest.RectanglePacking
|
||||
|
||||
double minX = items[0].X;
|
||||
double minY = items[0].Y;
|
||||
double maxX = items[0].X + items[0].Width;
|
||||
double maxY = items[0].Y + items[0].Length;
|
||||
double maxX = items[0].Right;
|
||||
double maxY = items[0].Top;
|
||||
|
||||
foreach (var box in items)
|
||||
{
|
||||
|
||||
@@ -16,11 +16,11 @@ namespace OpenNest.RectanglePacking
|
||||
|
||||
public override void Pack(List<Item> items)
|
||||
{
|
||||
items = items.OrderBy(i => -i.Length).ToList();
|
||||
items = items.OrderBy(i => -i.Width).ToList();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Length > Bin.Length)
|
||||
if (item.Width > Bin.Width)
|
||||
continue;
|
||||
|
||||
var level = FindLevel(item);
|
||||
@@ -36,10 +36,10 @@ namespace OpenNest.RectanglePacking
|
||||
{
|
||||
foreach (var level in levels)
|
||||
{
|
||||
if (level.Height < item.Length)
|
||||
if (level.Height < item.Width)
|
||||
continue;
|
||||
|
||||
if (level.RemainingWidth < item.Width)
|
||||
if (level.RemainingLength < item.Length)
|
||||
continue;
|
||||
|
||||
return level;
|
||||
@@ -58,12 +58,12 @@ namespace OpenNest.RectanglePacking
|
||||
|
||||
var remaining = Bin.Top - y;
|
||||
|
||||
if (remaining < item.Length)
|
||||
if (remaining < item.Width)
|
||||
return null;
|
||||
|
||||
var level = new Level(Bin);
|
||||
level.Y = y;
|
||||
level.Height = item.Length;
|
||||
level.Height = item.Width;
|
||||
|
||||
levels.Add(level);
|
||||
|
||||
@@ -93,9 +93,9 @@ namespace OpenNest.RectanglePacking
|
||||
set { NextItemLocation.Y = value; }
|
||||
}
|
||||
|
||||
public double Width
|
||||
public double LevelLength
|
||||
{
|
||||
get { return Parent.Width; }
|
||||
get { return Parent.Length; }
|
||||
}
|
||||
|
||||
public double Height { get; set; }
|
||||
@@ -105,9 +105,9 @@ namespace OpenNest.RectanglePacking
|
||||
get { return Y + Height; }
|
||||
}
|
||||
|
||||
public double RemainingWidth
|
||||
public double RemainingLength
|
||||
{
|
||||
get { return X + Width - NextItemLocation.X; }
|
||||
get { return X + LevelLength - NextItemLocation.X; }
|
||||
}
|
||||
|
||||
public void AddItem(Item item)
|
||||
@@ -115,7 +115,7 @@ namespace OpenNest.RectanglePacking
|
||||
item.Location = NextItemLocation;
|
||||
Parent.Items.Add(item);
|
||||
|
||||
NextItemLocation = new Vector(NextItemLocation.X + item.Width, NextItemLocation.Y);
|
||||
NextItemLocation = new Vector(NextItemLocation.X + item.Length, NextItemLocation.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.RectanglePacking
|
||||
{
|
||||
internal static class RectFill
|
||||
{
|
||||
public static void FillBest(Bin bin, Item item, int maxCount = int.MaxValue)
|
||||
{
|
||||
var spiralBin = bin.Clone() as Bin;
|
||||
var spiral = new FillSpiral(spiralBin);
|
||||
spiral.Fill(item, maxCount);
|
||||
|
||||
// Recursively fill the center remnant of the spiral
|
||||
if (spiralBin.Items.Count > 0 && spiral.CenterRemnant != null)
|
||||
{
|
||||
var center = spiral.CenterRemnant;
|
||||
var fitsNormal = item.Length <= center.Length && item.Width <= center.Width;
|
||||
var fitsRotated = item.Width <= center.Length && item.Length <= center.Width;
|
||||
|
||||
if (fitsNormal || fitsRotated)
|
||||
{
|
||||
var remaining = maxCount - spiralBin.Items.Count;
|
||||
FillBest(center.Location, center.Size, spiralBin, item, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
var bestFitBin = bin.Clone() as Bin;
|
||||
new FillBestFit(bestFitBin).Fill(item, maxCount);
|
||||
|
||||
var winner = spiralBin.Items.Count >= bestFitBin.Items.Count ? spiralBin : bestFitBin;
|
||||
bin.Items.AddRange(winner.Items);
|
||||
}
|
||||
|
||||
public static void FillBest(Vector location, Size size, Bin target, Item item, int maxCount)
|
||||
{
|
||||
if (size.Width <= 0 || size.Length <= 0 || maxCount <= 0)
|
||||
return;
|
||||
|
||||
var bin = new Bin { Location = location, Size = size };
|
||||
FillBest(bin, item, maxCount);
|
||||
target.Items.AddRange(bin.Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ public class ColumnFillStrategy : IFillStrategy
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
if (context.PartType == PartType.Rectangle)
|
||||
return null;
|
||||
|
||||
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
|
||||
return filler.Fill();
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
return FillHelpers.BestOverAngles(context, angles,
|
||||
angle => filler.Fill(context.Item.Drawing, angle,
|
||||
context.PlateNumber, context.Token, context.Progress),
|
||||
NestPhase.Extents, "Extents");
|
||||
context.Token, context.ReportProgress),
|
||||
"Extents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,39 @@ namespace OpenNest.Engine.Strategies
|
||||
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||
public FillScore CurrentBestScore { get; set; }
|
||||
public NestPhase WinnerPhase { get; set; }
|
||||
public NestPhase ActivePhase { get; set; }
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
public List<AngleResult> AngleResults { get; } = new();
|
||||
|
||||
public Dictionary<string, object> SharedState { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Standard progress reporting for strategies and fillers. Reports intermediate
|
||||
/// results using the current ActivePhase, PlateNumber, and WorkArea.
|
||||
/// When the reported parts beat the current pipeline best, promotes the
|
||||
/// result to IsOverallBest so the UI updates immediately.
|
||||
/// </summary>
|
||||
public void ReportProgress(List<Part> parts, string description)
|
||||
{
|
||||
var isNewBest = parts != null && parts.Count > 0
|
||||
&& Policy.Comparer.IsBetter(parts, CurrentBest, WorkArea);
|
||||
|
||||
if (isNewBest)
|
||||
{
|
||||
CurrentBest = parts;
|
||||
CurrentBestScore = FillScore.Compute(parts, WorkArea);
|
||||
WinnerPhase = ActivePhase;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(Progress, new ProgressReport
|
||||
{
|
||||
Phase = ActivePhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = isNewBest ? parts : CurrentBest,
|
||||
WorkArea = WorkArea,
|
||||
Description = description,
|
||||
IsOverallBest = isNewBest,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,13 +113,12 @@ namespace OpenNest.Engine.Strategies
|
||||
/// <summary>
|
||||
/// Sweeps a list of angles, calling fillAtAngle for each, and returns
|
||||
/// the best result according to the context's comparer. Handles
|
||||
/// cancellation and progress reporting.
|
||||
/// cancellation and progress reporting via context.ReportProgress.
|
||||
/// </summary>
|
||||
public static List<Part> BestOverAngles(
|
||||
FillContext context,
|
||||
IReadOnlyList<double> angles,
|
||||
Func<double, List<Part>> fillAtAngle,
|
||||
NestPhase phase,
|
||||
string phaseLabel)
|
||||
{
|
||||
var workArea = context.WorkArea;
|
||||
@@ -140,14 +139,8 @@ namespace OpenNest.Engine.Strategies
|
||||
best = result;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = context.PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"{phaseLabel}: {i + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
context.ReportProgress(best,
|
||||
$"{phaseLabel}: {i + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts");
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
return result;
|
||||
},
|
||||
NestPhase.Linear, "Linear");
|
||||
"Linear");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace OpenNest.Engine.Strategies
|
||||
var dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
var filler = new PairFiller(context.Plate, comparer, dedup);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
context.Token, context.ReportProgress);
|
||||
|
||||
context.SharedState["BestFits"] = result.BestFits;
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@ namespace OpenNest.Engine.Strategies
|
||||
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
|
||||
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
|
||||
|
||||
var engine = new FillBestFit(bin);
|
||||
engine.Fill(binItem);
|
||||
RectFill.FillBest(bin, binItem);
|
||||
|
||||
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ public class RowFillStrategy : IFillStrategy
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
if (context.PartType == PartType.Rectangle)
|
||||
return null;
|
||||
|
||||
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
|
||||
return filler.Fill();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace OpenNest
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var cos = System.Math.Abs(System.Math.Cos(angle));
|
||||
var sin = System.Math.Abs(System.Math.Sin(angle));
|
||||
return bb.Width * cos + bb.Length * sin;
|
||||
return bb.Length * cos + bb.Width * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using CSMath;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
using AcadArc = ACadSharp.Entities.Arc;
|
||||
using AcadCircle = ACadSharp.Entities.Circle;
|
||||
using AcadLine = ACadSharp.Entities.Line;
|
||||
using Layer = ACadSharp.Tables.Layer;
|
||||
|
||||
public static class Dxf
|
||||
{
|
||||
#region Import
|
||||
|
||||
/// <summary>
|
||||
/// Imports a DXF file, returning both converted entities and the raw CadDocument
|
||||
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
|
||||
/// additional analysis (e.g., MText extraction for bend notes).
|
||||
/// </summary>
|
||||
public static DxfImportResult Import(string path)
|
||||
{
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
|
||||
return new DxfImportResult
|
||||
{
|
||||
Entities = ConvertEntities(doc),
|
||||
Document = doc
|
||||
};
|
||||
}
|
||||
|
||||
public static List<Entity> GetGeometry(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
return ConvertEntities(doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Entity> GetGeometry(Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new DxfReader(stream);
|
||||
var doc = reader.Read();
|
||||
return ConvertEntities(doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Export
|
||||
|
||||
public static void ExportProgram(Program program, string path)
|
||||
{
|
||||
using var stream = File.Create(path);
|
||||
ExportProgram(program, stream);
|
||||
}
|
||||
|
||||
public static void ExportProgram(Program program, Stream stream)
|
||||
{
|
||||
var ctx = new ExportContext();
|
||||
ctx.AddProgram(program);
|
||||
|
||||
using var writer = new DxfWriter(stream, ctx.Document, false);
|
||||
writer.Write();
|
||||
}
|
||||
|
||||
public static void ExportPlate(Plate plate, string path)
|
||||
{
|
||||
using var stream = File.Create(path);
|
||||
ExportPlate(plate, stream);
|
||||
}
|
||||
|
||||
public static void ExportPlate(Plate plate, Stream stream)
|
||||
{
|
||||
var ctx = new ExportContext();
|
||||
ctx.AddPlateOutline(plate);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var endpt = part.Location.ToAcadXYZ();
|
||||
ctx.AddLine(ctx.CurPos, endpt, ctx.RapidLayer);
|
||||
ctx.CurPos = part.Location.ToAcadXYZ();
|
||||
ctx.AddProgram(part.Program);
|
||||
}
|
||||
|
||||
using var writer = new DxfWriter(stream, ctx.Document, false);
|
||||
writer.Write();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private
|
||||
|
||||
private static List<Entity> ConvertEntities(CadDocument doc)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var lines = new List<Line>();
|
||||
var arcs = new List<Arc>();
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
if (IsNonCutLayer(entity.Layer?.Name))
|
||||
continue;
|
||||
|
||||
switch (entity)
|
||||
{
|
||||
case ACadSharp.Entities.Line line:
|
||||
lines.Add(line.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Arc arc:
|
||||
arcs.Add(arc.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Circle circle:
|
||||
entities.Add(circle.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Spline spline:
|
||||
foreach (var e in spline.ToOpenNest())
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.LwPolyline lwPolyline:
|
||||
lines.AddRange(lwPolyline.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Polyline polyline:
|
||||
lines.AddRange(polyline.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Ellipse ellipse:
|
||||
foreach (var e in ellipse.ToOpenNest())
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GeometryOptimizer.Optimize(lines);
|
||||
GeometryOptimizer.Optimize(arcs);
|
||||
|
||||
entities.AddRange(lines);
|
||||
entities.AddRange(arcs);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static bool IsNonCutLayer(string layerName)
|
||||
{
|
||||
return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private class ExportContext
|
||||
{
|
||||
public CadDocument Document { get; }
|
||||
public XYZ CurPos { get; set; }
|
||||
public Layer CutLayer { get; }
|
||||
public Layer RapidLayer { get; }
|
||||
public Layer PlateLayer { get; }
|
||||
|
||||
private Mode mode;
|
||||
|
||||
public ExportContext()
|
||||
{
|
||||
Document = new CadDocument();
|
||||
|
||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
||||
PlateLayer = new Layer("Plate") { Color = new Color(4) };
|
||||
|
||||
Document.Layers.Add(CutLayer);
|
||||
Document.Layers.Add(RapidLayer);
|
||||
Document.Layers.Add(PlateLayer);
|
||||
}
|
||||
|
||||
public void AddLine(XYZ start, XYZ end, Layer layer)
|
||||
{
|
||||
var ln = new AcadLine
|
||||
{
|
||||
StartPoint = start,
|
||||
EndPoint = end,
|
||||
Layer = layer
|
||||
};
|
||||
Document.Entities.Add(ln);
|
||||
}
|
||||
|
||||
public void AddPlateOutline(Plate plate)
|
||||
{
|
||||
XYZ pt1, pt2, pt3, pt4;
|
||||
|
||||
switch (plate.Quadrant)
|
||||
{
|
||||
case 1:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, plate.Size.Width, 0);
|
||||
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
|
||||
pt4 = new XYZ(plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, plate.Size.Width, 0);
|
||||
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
|
||||
pt4 = new XYZ(-plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 3:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, -plate.Size.Width, 0);
|
||||
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
|
||||
pt4 = new XYZ(-plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 4:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, -plate.Size.Width, 0);
|
||||
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
|
||||
pt4 = new XYZ(plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
AddLine(pt1, pt2, PlateLayer);
|
||||
AddLine(pt2, pt3, PlateLayer);
|
||||
AddLine(pt3, pt4, PlateLayer);
|
||||
AddLine(pt4, pt1, PlateLayer);
|
||||
|
||||
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
|
||||
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
|
||||
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
|
||||
var m4 = new XYZ(m3.X, m1.Y, 0);
|
||||
|
||||
AddLine(m1, m2, PlateLayer);
|
||||
AddLine(m2, m3, PlateLayer);
|
||||
AddLine(m3, m4, PlateLayer);
|
||||
AddLine(m4, m1, PlateLayer);
|
||||
}
|
||||
|
||||
public void AddProgram(Program program)
|
||||
{
|
||||
mode = program.Mode;
|
||||
|
||||
for (var i = 0; i < program.Length; ++i)
|
||||
{
|
||||
var code = program[i];
|
||||
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.ArcMove:
|
||||
AddArcMove((ArcMove)code);
|
||||
break;
|
||||
|
||||
case CodeType.LinearMove:
|
||||
AddLinearMove((LinearMove)code);
|
||||
break;
|
||||
|
||||
case CodeType.RapidMove:
|
||||
AddRapidMove((RapidMove)code);
|
||||
break;
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
var tmpmode = mode;
|
||||
AddProgram(((SubProgramCall)code).Program);
|
||||
mode = tmpmode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLinearMove(LinearMove line)
|
||||
{
|
||||
var pt = line.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
|
||||
|
||||
AddLine(CurPos, pt, CutLayer);
|
||||
CurPos = pt;
|
||||
}
|
||||
|
||||
private void AddRapidMove(RapidMove rapid)
|
||||
{
|
||||
var pt = rapid.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
pt = new XYZ(pt.X + CurPos.X, pt.Y + CurPos.Y, 0);
|
||||
|
||||
AddLine(CurPos, pt, RapidLayer);
|
||||
CurPos = pt;
|
||||
}
|
||||
|
||||
private void AddArcMove(ArcMove arc)
|
||||
{
|
||||
var center = arc.CenterPoint.ToAcadXYZ();
|
||||
var endpt = arc.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
{
|
||||
endpt = new XYZ(endpt.X + CurPos.X, endpt.Y + CurPos.Y, 0);
|
||||
center = new XYZ(center.X + CurPos.X, center.Y + CurPos.Y, 0);
|
||||
}
|
||||
|
||||
var startAngle = System.Math.Atan2(
|
||||
CurPos.Y - center.Y,
|
||||
CurPos.X - center.X);
|
||||
|
||||
var endAngle = System.Math.Atan2(
|
||||
endpt.Y - center.Y,
|
||||
endpt.X - center.X);
|
||||
|
||||
if (arc.Rotation == RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
var dx = endpt.X - center.X;
|
||||
var dy = endpt.Y - center.Y;
|
||||
var radius = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
{
|
||||
var circle = new AcadCircle
|
||||
{
|
||||
Center = center,
|
||||
Radius = radius,
|
||||
Layer = CutLayer
|
||||
};
|
||||
Document.Entities.Add(circle);
|
||||
}
|
||||
else
|
||||
{
|
||||
var acadArc = new AcadArc
|
||||
{
|
||||
Center = center,
|
||||
Radius = radius,
|
||||
StartAngle = startAngle,
|
||||
EndAngle = endAngle,
|
||||
Layer = CutLayer
|
||||
};
|
||||
Document.Entities.Add(acadArc);
|
||||
}
|
||||
|
||||
CurPos = endpt;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using CSMath;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Math;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
using AcadArc = ACadSharp.Entities.Arc;
|
||||
using AcadCircle = ACadSharp.Entities.Circle;
|
||||
using AcadLine = ACadSharp.Entities.Line;
|
||||
using Layer = ACadSharp.Tables.Layer;
|
||||
|
||||
public class DxfExporter
|
||||
{
|
||||
private CadDocument doc;
|
||||
private XYZ curpos;
|
||||
private Mode mode;
|
||||
private readonly Layer cutLayer;
|
||||
private readonly Layer rapidLayer;
|
||||
private readonly Layer plateLayer;
|
||||
|
||||
public DxfExporter()
|
||||
{
|
||||
doc = new CadDocument();
|
||||
|
||||
cutLayer = new Layer("Cut");
|
||||
cutLayer.Color = new Color(1);
|
||||
|
||||
rapidLayer = new Layer("Rapid");
|
||||
rapidLayer.Color = new Color(5);
|
||||
|
||||
plateLayer = new Layer("Plate");
|
||||
plateLayer.Color = new Color(4);
|
||||
}
|
||||
|
||||
public void ExportProgram(Program program, Stream stream)
|
||||
{
|
||||
doc = new CadDocument();
|
||||
EnsureLayers();
|
||||
AddProgram(program);
|
||||
using (var writer = new DxfWriter(stream, doc, false))
|
||||
{
|
||||
writer.Write();
|
||||
}
|
||||
}
|
||||
|
||||
public bool ExportProgram(Program program, string path)
|
||||
{
|
||||
Stream stream = null;
|
||||
var success = false;
|
||||
|
||||
try
|
||||
{
|
||||
stream = File.Create(path);
|
||||
ExportProgram(program, stream);
|
||||
success = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Debug.Fail("DxfExporter.ExportProgram failed to write program to file: " + path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stream != null)
|
||||
stream.Close();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
public void ExportPlate(Plate plate, Stream stream)
|
||||
{
|
||||
doc = new CadDocument();
|
||||
EnsureLayers();
|
||||
AddPlateOutline(plate);
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
var endpt = part.Location.ToAcadXYZ();
|
||||
AddLine(curpos, endpt, rapidLayer);
|
||||
curpos = part.Location.ToAcadXYZ();
|
||||
AddProgram(part.Program);
|
||||
}
|
||||
|
||||
using (var writer = new DxfWriter(stream, doc, false))
|
||||
{
|
||||
writer.Write();
|
||||
}
|
||||
}
|
||||
|
||||
public bool ExportPlate(Plate plate, string path)
|
||||
{
|
||||
Stream stream = null;
|
||||
var success = false;
|
||||
|
||||
try
|
||||
{
|
||||
stream = File.Create(path);
|
||||
ExportPlate(plate, stream);
|
||||
success = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Debug.Fail("DxfExporter.ExportPlate failed to write plate to file: " + path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stream != null)
|
||||
stream.Close();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void EnsureLayers()
|
||||
{
|
||||
doc.Layers.Add(cutLayer);
|
||||
doc.Layers.Add(rapidLayer);
|
||||
doc.Layers.Add(plateLayer);
|
||||
}
|
||||
|
||||
private void AddLine(XYZ start, XYZ end, Layer layer)
|
||||
{
|
||||
var ln = new AcadLine();
|
||||
ln.StartPoint = start;
|
||||
ln.EndPoint = end;
|
||||
ln.Layer = layer;
|
||||
doc.Entities.Add(ln);
|
||||
}
|
||||
|
||||
private void AddPlateOutline(Plate plate)
|
||||
{
|
||||
XYZ pt1;
|
||||
XYZ pt2;
|
||||
XYZ pt3;
|
||||
XYZ pt4;
|
||||
|
||||
switch (plate.Quadrant)
|
||||
{
|
||||
case 1:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, plate.Size.Width, 0);
|
||||
pt3 = new XYZ(plate.Size.Length, plate.Size.Width, 0);
|
||||
pt4 = new XYZ(plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, plate.Size.Width, 0);
|
||||
pt3 = new XYZ(-plate.Size.Length, plate.Size.Width, 0);
|
||||
pt4 = new XYZ(-plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 3:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, -plate.Size.Width, 0);
|
||||
pt3 = new XYZ(-plate.Size.Length, -plate.Size.Width, 0);
|
||||
pt4 = new XYZ(-plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
case 4:
|
||||
pt1 = new XYZ(0, 0, 0);
|
||||
pt2 = new XYZ(0, -plate.Size.Width, 0);
|
||||
pt3 = new XYZ(plate.Size.Length, -plate.Size.Width, 0);
|
||||
pt4 = new XYZ(plate.Size.Length, 0, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
AddLine(pt1, pt2, plateLayer);
|
||||
AddLine(pt2, pt3, plateLayer);
|
||||
AddLine(pt3, pt4, plateLayer);
|
||||
AddLine(pt4, pt1, plateLayer);
|
||||
|
||||
var m1 = new XYZ(pt1.X + plate.EdgeSpacing.Left, pt1.Y + plate.EdgeSpacing.Bottom, 0);
|
||||
var m2 = new XYZ(m1.X, pt2.Y - plate.EdgeSpacing.Top, 0);
|
||||
var m3 = new XYZ(pt3.X - plate.EdgeSpacing.Right, m2.Y, 0);
|
||||
var m4 = new XYZ(m3.X, m1.Y, 0);
|
||||
|
||||
AddLine(m1, m2, plateLayer);
|
||||
AddLine(m2, m3, plateLayer);
|
||||
AddLine(m3, m4, plateLayer);
|
||||
AddLine(m4, m1, plateLayer);
|
||||
}
|
||||
|
||||
private void AddProgram(Program program)
|
||||
{
|
||||
mode = program.Mode;
|
||||
|
||||
for (var i = 0; i < program.Length; ++i)
|
||||
{
|
||||
var code = program[i];
|
||||
|
||||
switch (code.Type)
|
||||
{
|
||||
case CodeType.ArcMove:
|
||||
var arc = (ArcMove)code;
|
||||
AddArcMove(arc);
|
||||
break;
|
||||
|
||||
case CodeType.LinearMove:
|
||||
var line = (LinearMove)code;
|
||||
AddLinearMove(line);
|
||||
break;
|
||||
|
||||
case CodeType.RapidMove:
|
||||
var rapid = (RapidMove)code;
|
||||
AddRapidMove(rapid);
|
||||
break;
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
var tmpmode = mode;
|
||||
var subpgm = (CNC.SubProgramCall)code;
|
||||
AddProgram(subpgm.Program);
|
||||
mode = tmpmode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLinearMove(LinearMove line)
|
||||
{
|
||||
var pt = line.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
|
||||
|
||||
AddLine(curpos, pt, cutLayer);
|
||||
curpos = pt;
|
||||
}
|
||||
|
||||
private void AddRapidMove(RapidMove rapid)
|
||||
{
|
||||
var pt = rapid.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
pt = new XYZ(pt.X + curpos.X, pt.Y + curpos.Y, 0);
|
||||
|
||||
AddLine(curpos, pt, rapidLayer);
|
||||
curpos = pt;
|
||||
}
|
||||
|
||||
private void AddArcMove(ArcMove arc)
|
||||
{
|
||||
var center = arc.CenterPoint.ToAcadXYZ();
|
||||
var endpt = arc.EndPoint.ToAcadXYZ();
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
{
|
||||
endpt = new XYZ(endpt.X + curpos.X, endpt.Y + curpos.Y, 0);
|
||||
center = new XYZ(center.X + curpos.X, center.Y + curpos.Y, 0);
|
||||
}
|
||||
|
||||
var startAngle = System.Math.Atan2(
|
||||
curpos.Y - center.Y,
|
||||
curpos.X - center.X);
|
||||
|
||||
var endAngle = System.Math.Atan2(
|
||||
endpt.Y - center.Y,
|
||||
endpt.X - center.X);
|
||||
|
||||
if (arc.Rotation == OpenNest.RotationType.CW)
|
||||
Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
var dx = endpt.X - center.X;
|
||||
var dy = endpt.Y - center.Y;
|
||||
|
||||
var radius = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
{
|
||||
var circle = new AcadCircle();
|
||||
circle.Center = center;
|
||||
circle.Radius = radius;
|
||||
circle.Layer = cutLayer;
|
||||
doc.Entities.Add(circle);
|
||||
}
|
||||
else
|
||||
{
|
||||
var arc2 = new AcadArc();
|
||||
arc2.Center = center;
|
||||
arc2.Radius = radius;
|
||||
arc2.StartAngle = startAngle;
|
||||
arc2.EndAngle = endAngle;
|
||||
arc2.Layer = cutLayer;
|
||||
doc.Entities.Add(arc2);
|
||||
}
|
||||
|
||||
curpos = endpt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.IO;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public class DxfImporter
|
||||
{
|
||||
public int SplinePrecision { get; set; }
|
||||
|
||||
public DxfImporter()
|
||||
{
|
||||
}
|
||||
|
||||
private List<Entity> GetGeometry(CadDocument doc)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var lines = new List<Line>();
|
||||
var arcs = new List<Arc>();
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
// Skip bend/etch entities — bends are converted to Bend objects
|
||||
// separately via bend detection, and etch marks are generated from
|
||||
// bends during DXF export. Neither should be treated as cut geometry.
|
||||
if (IsNonCutLayer(entity.Layer?.Name))
|
||||
continue;
|
||||
|
||||
switch (entity)
|
||||
{
|
||||
case ACadSharp.Entities.Line line:
|
||||
lines.Add(line.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Arc arc:
|
||||
arcs.Add(arc.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Circle circle:
|
||||
entities.Add(circle.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Spline spline:
|
||||
foreach (var e in spline.ToOpenNest(SplinePrecision))
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.LwPolyline lwPolyline:
|
||||
lines.AddRange(lwPolyline.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Polyline polyline:
|
||||
lines.AddRange(polyline.ToOpenNest());
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Ellipse ellipse:
|
||||
foreach (var e in ellipse.ToOpenNest())
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GeometryOptimizer.Optimize(lines);
|
||||
GeometryOptimizer.Optimize(arcs);
|
||||
|
||||
entities.AddRange(lines);
|
||||
entities.AddRange(arcs);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a DXF file, returning both converted entities and the raw CadDocument
|
||||
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
|
||||
/// additional analysis (e.g., MText extraction for bend notes).
|
||||
/// </summary>
|
||||
public DxfImportResult Import(string path)
|
||||
{
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
var entities = GetGeometry(doc);
|
||||
|
||||
return new DxfImportResult
|
||||
{
|
||||
Entities = entities,
|
||||
Document = doc
|
||||
};
|
||||
}
|
||||
|
||||
public bool GetGeometry(Stream stream, out List<Entity> geometry)
|
||||
{
|
||||
var success = false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var reader = new DxfReader(stream))
|
||||
{
|
||||
var doc = reader.Read();
|
||||
geometry = GetGeometry(doc);
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
geometry = new List<Entity>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (stream != null)
|
||||
stream.Close();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
public bool GetGeometry(string path, out List<Entity> geometry)
|
||||
{
|
||||
var success = false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var reader = new DxfReader(path))
|
||||
{
|
||||
var doc = reader.Read();
|
||||
geometry = GetGeometry(doc);
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
geometry = new List<Entity>();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static bool IsNonCutLayer(string layerName)
|
||||
{
|
||||
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public static class EntitySerializer
|
||||
{
|
||||
public static EntitySetDto ToDto(List<Entity> entities, HashSet<Guid> suppressed)
|
||||
{
|
||||
return new EntitySetDto
|
||||
{
|
||||
Entities = entities.Select(ToEntityDto).ToList(),
|
||||
Suppressed = suppressed.Select(id => id.ToString()).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static (List<Entity> entities, HashSet<Guid> suppressed) FromDto(EntitySetDto dto)
|
||||
{
|
||||
var entities = dto.Entities.Select(FromEntityDto).ToList();
|
||||
var suppressed = new HashSet<Guid>(dto.Suppressed.Select(Guid.Parse));
|
||||
return (entities, suppressed);
|
||||
}
|
||||
|
||||
private static EntityDto ToEntityDto(Entity entity)
|
||||
{
|
||||
switch (entity.Type)
|
||||
{
|
||||
case EntityType.Line:
|
||||
var line = (Line)entity;
|
||||
return new EntityDto
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Type = "line",
|
||||
Layer = entity.Layer?.Name ?? "",
|
||||
LineType = entity.LineTypeName ?? "",
|
||||
X1 = line.StartPoint.X,
|
||||
Y1 = line.StartPoint.Y,
|
||||
X2 = line.EndPoint.X,
|
||||
Y2 = line.EndPoint.Y
|
||||
};
|
||||
|
||||
case EntityType.Arc:
|
||||
var arc = (Arc)entity;
|
||||
return new EntityDto
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Type = "arc",
|
||||
Layer = entity.Layer?.Name ?? "",
|
||||
LineType = entity.LineTypeName ?? "",
|
||||
CX = arc.Center.X,
|
||||
CY = arc.Center.Y,
|
||||
R = arc.Radius,
|
||||
StartAngle = arc.StartAngle,
|
||||
EndAngle = arc.EndAngle,
|
||||
Reversed = arc.IsReversed
|
||||
};
|
||||
|
||||
case EntityType.Circle:
|
||||
var circle = (Circle)entity;
|
||||
return new EntityDto
|
||||
{
|
||||
Id = entity.Id.ToString(),
|
||||
Type = "circle",
|
||||
Layer = entity.Layer?.Name ?? "",
|
||||
LineType = entity.LineTypeName ?? "",
|
||||
CX = circle.Center.X,
|
||||
CY = circle.Center.Y,
|
||||
R = circle.Radius,
|
||||
Rotation = circle.Rotation == RotationType.CW ? "CW" : "CCW"
|
||||
};
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Entity type {entity.Type} is not supported for serialization.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Entity FromEntityDto(EntityDto dto)
|
||||
{
|
||||
Entity entity;
|
||||
|
||||
switch (dto.Type)
|
||||
{
|
||||
case "line":
|
||||
entity = new Line(
|
||||
new Vector(dto.X1, dto.Y1),
|
||||
new Vector(dto.X2, dto.Y2));
|
||||
break;
|
||||
|
||||
case "arc":
|
||||
entity = new Arc(
|
||||
new Vector(dto.CX, dto.CY),
|
||||
dto.R,
|
||||
dto.StartAngle,
|
||||
dto.EndAngle,
|
||||
dto.Reversed);
|
||||
break;
|
||||
|
||||
case "circle":
|
||||
var circle = new Circle(new Vector(dto.CX, dto.CY), dto.R);
|
||||
circle.Rotation = dto.Rotation == "CW" ? RotationType.CW : RotationType.CCW;
|
||||
entity = circle;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Entity type '{dto.Type}' is not supported for deserialization.");
|
||||
}
|
||||
|
||||
entity.Id = Guid.Parse(dto.Id);
|
||||
entity.Layer = ResolveLayer(dto.Layer);
|
||||
entity.LineTypeName = dto.LineType;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static Layer ResolveLayer(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name) || name == "0")
|
||||
return Layer.Default;
|
||||
|
||||
if (string.Equals(name, SpecialLayers.Cut.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Cut;
|
||||
if (string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Rapid;
|
||||
if (string.Equals(name, SpecialLayers.Display.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Display;
|
||||
if (string.Equals(name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Leadin;
|
||||
if (string.Equals(name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Leadout;
|
||||
if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase))
|
||||
return SpecialLayers.Scribe;
|
||||
|
||||
return new Layer(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ namespace OpenNest.IO
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
|
||||
public static List<Geometry.Entity> ToOpenNest(this Spline spline)
|
||||
{
|
||||
var layer = spline.Layer.ToOpenNest();
|
||||
var color = spline.ResolveColor();
|
||||
@@ -67,7 +67,7 @@ namespace OpenNest.IO
|
||||
List<XYZ> curvePoints;
|
||||
try
|
||||
{
|
||||
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
|
||||
curvePoints = spline.PolygonalVertexes(200);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,8 @@ namespace OpenNest.IO
|
||||
public PlateDefaultsDto PlateDefaults { get; init; } = new();
|
||||
public List<DrawingDto> Drawings { get; init; } = new();
|
||||
public List<PlateDto> Plates { get; init; } = new();
|
||||
public List<PlateOptionDto> PlateOptions { get; init; } = new();
|
||||
public double SalvageRate { get; init; } = 0.5;
|
||||
}
|
||||
|
||||
public record PlateDefaultsDto
|
||||
@@ -153,6 +155,42 @@ namespace OpenNest.IO
|
||||
public string NoteText { get; init; } = "";
|
||||
}
|
||||
|
||||
public record PlateOptionDto
|
||||
{
|
||||
public double Width { get; init; }
|
||||
public double Length { get; init; }
|
||||
public double Cost { get; init; }
|
||||
}
|
||||
|
||||
public record EntitySetDto
|
||||
{
|
||||
public List<EntityDto> Entities { get; init; } = new();
|
||||
public List<string> Suppressed { get; init; } = new();
|
||||
}
|
||||
|
||||
public record EntityDto
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Type { get; init; } = "";
|
||||
public string Layer { get; init; } = "";
|
||||
public string LineType { get; init; } = "";
|
||||
|
||||
// Line
|
||||
public double X1 { get; init; }
|
||||
public double Y1 { get; init; }
|
||||
public double X2 { get; init; }
|
||||
public double Y2 { get; init; }
|
||||
|
||||
// Arc / Circle
|
||||
public double CX { get; init; }
|
||||
public double CY { get; init; }
|
||||
public double R { get; init; }
|
||||
public double StartAngle { get; init; }
|
||||
public double EndAngle { get; init; }
|
||||
public bool Reversed { get; init; }
|
||||
public string Rotation { get; init; } = "";
|
||||
}
|
||||
|
||||
public record BestFitSetDto
|
||||
{
|
||||
public double PlateWidth { get; init; }
|
||||
|
||||
@@ -36,7 +36,8 @@ namespace OpenNest.IO
|
||||
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
|
||||
|
||||
var programs = ReadPrograms(dto.Drawings.Count);
|
||||
var drawingMap = BuildDrawings(dto, programs);
|
||||
var entitySets = ReadEntitySets(dto.Drawings.Count);
|
||||
var drawingMap = BuildDrawings(dto, programs, entitySets);
|
||||
ReadBestFits(drawingMap);
|
||||
var nest = BuildNest(dto, drawingMap);
|
||||
|
||||
@@ -74,7 +75,25 @@ namespace OpenNest.IO
|
||||
return programs;
|
||||
}
|
||||
|
||||
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
|
||||
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
|
||||
{
|
||||
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"entities/entities-{i}");
|
||||
if (entry == null) continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
var json = reader.ReadToEnd();
|
||||
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
|
||||
result[i] = EntitySerializer.FromDto(dto);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs,
|
||||
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets)
|
||||
{
|
||||
var map = new Dictionary<int, Drawing>();
|
||||
foreach (var d in dto.Drawings)
|
||||
@@ -112,6 +131,12 @@ namespace OpenNest.IO
|
||||
if (programs.TryGetValue(d.Id, out var pgm))
|
||||
drawing.Program = pgm;
|
||||
|
||||
if (entitySets.TryGetValue(d.Id, out var entitySet))
|
||||
{
|
||||
drawing.SourceEntities = entitySet.entities;
|
||||
drawing.SuppressedEntityIds = entitySet.suppressed;
|
||||
}
|
||||
|
||||
map[d.Id] = drawing;
|
||||
}
|
||||
return map;
|
||||
@@ -192,6 +217,18 @@ namespace OpenNest.IO
|
||||
nest.PlateDefaults.PartSpacing = pd.PartSpacing;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top);
|
||||
|
||||
// Plate optimizer settings
|
||||
nest.SalvageRate = dto.SalvageRate;
|
||||
if (dto.PlateOptions != null)
|
||||
{
|
||||
nest.PlateOptions = dto.PlateOptions.Select(o => new PlateOption
|
||||
{
|
||||
Width = o.Width,
|
||||
Length = o.Length,
|
||||
Cost = o.Cost,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// Drawings
|
||||
foreach (var d in drawingMap.OrderBy(k => k.Key))
|
||||
nest.Drawings.Add(d.Value);
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace OpenNest.IO
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
WritePrograms(zipArchive);
|
||||
WriteEntities(zipArchive);
|
||||
WriteBestFits(zipArchive);
|
||||
|
||||
return true;
|
||||
@@ -88,7 +89,14 @@ namespace OpenNest.IO
|
||||
},
|
||||
PlateDefaults = BuildPlateDefaultsDto(),
|
||||
Drawings = BuildDrawingDtos(),
|
||||
Plates = BuildPlateDtos()
|
||||
Plates = BuildPlateDtos(),
|
||||
PlateOptions = nest.PlateOptions?.Select(o => new PlateOptionDto
|
||||
{
|
||||
Width = o.Width,
|
||||
Length = o.Length,
|
||||
Cost = o.Cost,
|
||||
}).ToList() ?? new(),
|
||||
SalvageRate = nest.SalvageRate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,9 +176,15 @@ namespace OpenNest.IO
|
||||
private List<PlateDto> BuildPlateDtos()
|
||||
{
|
||||
var list = new List<PlateDto>();
|
||||
var id = 0;
|
||||
for (var i = 0; i < nest.Plates.Count; i++)
|
||||
{
|
||||
var plate = nest.Plates[i];
|
||||
|
||||
if (plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0)
|
||||
continue;
|
||||
|
||||
id++;
|
||||
var parts = new List<PartDto>();
|
||||
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
|
||||
{
|
||||
@@ -201,7 +215,7 @@ namespace OpenNest.IO
|
||||
|
||||
list.Add(new PlateDto
|
||||
{
|
||||
Id = i + 1,
|
||||
Id = id,
|
||||
Size = new SizeDto { Width = plate.Size.Width, Length = plate.Size.Length },
|
||||
Quadrant = plate.Quadrant,
|
||||
Quantity = plate.Quantity,
|
||||
@@ -299,6 +313,24 @@ namespace OpenNest.IO
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteEntities(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var drawing = kvp.Value;
|
||||
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
|
||||
continue;
|
||||
|
||||
var dto = EntitySerializer.ToDto(drawing.SourceEntities, drawing.SuppressedEntityIds);
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(json);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDrawing(Stream stream, Drawing drawing)
|
||||
{
|
||||
var program = drawing.Program;
|
||||
|
||||
@@ -96,13 +96,10 @@ namespace OpenNest.Mcp.Tools
|
||||
if (!File.Exists(path))
|
||||
return $"Error: file not found: {path}";
|
||||
|
||||
var importer = new DxfImporter();
|
||||
|
||||
if (!importer.GetGeometry(path, out var geometry))
|
||||
return "Error: failed to read DXF file";
|
||||
var geometry = Dxf.GetGeometry(path);
|
||||
|
||||
if (geometry.Count == 0)
|
||||
return "Error: no geometry found in DXF file";
|
||||
return "Error: failed to read DXF file or no geometry found";
|
||||
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
@@ -112,6 +109,7 @@ namespace OpenNest.Mcp.Tools
|
||||
|
||||
var drawingName = name ?? Path.GetFileNameWithoutExtension(path);
|
||||
var drawing = new Drawing(drawingName, pgm);
|
||||
drawing.Color = Drawing.GetNextColor();
|
||||
_session.Drawings.Add(drawing);
|
||||
|
||||
var bbox = pgm.BoundingBox();
|
||||
@@ -155,6 +153,7 @@ namespace OpenNest.Mcp.Tools
|
||||
if (pgm == null)
|
||||
return "Error: failed to parse G-code";
|
||||
var gcodeDrawing = new Drawing(name, pgm);
|
||||
gcodeDrawing.Color = Drawing.GetNextColor();
|
||||
_session.Drawings.Add(gcodeDrawing);
|
||||
var gcodeBbox = pgm.BoundingBox();
|
||||
return $"Created drawing '{name}': bbox={gcodeBbox.Width:F2} x {gcodeBbox.Length:F2}";
|
||||
@@ -164,6 +163,7 @@ namespace OpenNest.Mcp.Tools
|
||||
}
|
||||
|
||||
var drawing = shapeDef.GetDrawing();
|
||||
drawing.Color = Drawing.GetNextColor();
|
||||
_session.Drawings.Add(drawing);
|
||||
|
||||
var bbox = drawing.Program.BoundingBox();
|
||||
|
||||
@@ -13,6 +13,9 @@ public class NestResponsePersistenceTests
|
||||
{
|
||||
var nest = new Nest("test-nest");
|
||||
var plate = new Plate(new Size(60, 120));
|
||||
var drawing = new Drawing("test-part");
|
||||
nest.Drawings.Add(drawing);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var request = new NestRequest
|
||||
|
||||
@@ -70,8 +70,7 @@ public class NestRunnerTests
|
||||
var pgm = ConvertGeometry.ToProgram(shape);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
||||
|
||||
var exporter = new DxfExporter();
|
||||
exporter.ExportProgram(pgm, path);
|
||||
Dxf.ExportProgram(pgm, path);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ public class SolidWorksBendDetectorTests
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11 Test.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
|
||||
var result = importer.Import(path);
|
||||
var result = OpenNest.IO.Dxf.Import(path);
|
||||
|
||||
// EllipseConverter now produces arcs directly during import,
|
||||
// so the imported entities should contain Arc instances from the ellipses
|
||||
@@ -61,8 +60,7 @@ public class SolidWorksBendDetectorTests
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11.dxf");
|
||||
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||
|
||||
var importer = new OpenNest.IO.DxfImporter();
|
||||
var result = importer.Import(path);
|
||||
var result = OpenNest.IO.Dxf.Import(path);
|
||||
|
||||
// The DXF has 2 trimmed ellipses forming an oblong slot.
|
||||
// Trimmed ellipses must not generate a closing chord line.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests.BestFit;
|
||||
|
||||
public class BestFitOverlapTests
|
||||
{
|
||||
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT16.dxf";
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public BestFitOverlapTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
private static Drawing MakeRoundedRect(double w = 7.25, double h = 3.31, double r = 0.5)
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(w - r, 0)));
|
||||
pgm.Codes.Add(new ArcMove(new Vector(w, r), new Vector(w - r, r), RotationType.CW));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(w, h - r)));
|
||||
pgm.Codes.Add(new ArcMove(new Vector(w - r, h), new Vector(w - r, h - r), RotationType.CW));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("rounded-rect", pgm);
|
||||
}
|
||||
|
||||
private static Drawing ImportDxf()
|
||||
{
|
||||
if (!File.Exists(DxfPath))
|
||||
return null;
|
||||
|
||||
var geometry = Dxf.GetGeometry(DxfPath);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT16", pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeptPairs_NoOverlap()
|
||||
{
|
||||
var drawing = ImportDxf() ?? MakeRoundedRect();
|
||||
var bbox = drawing.Program.BoundingBox();
|
||||
_output.WriteLine($"Drawing: {drawing.Name}, bbox={bbox.Length:F2} x {bbox.Width:F2}");
|
||||
|
||||
var finder = new BestFitFinder(120, 60);
|
||||
var results = finder.FindBestFits(drawing);
|
||||
|
||||
var kept = results.Where(r => r.Keep).ToList();
|
||||
_output.WriteLine($"Total results: {results.Count}, Kept: {kept.Count}");
|
||||
|
||||
var overlapping = 0;
|
||||
|
||||
foreach (var result in kept)
|
||||
{
|
||||
var parts = result.BuildParts(drawing);
|
||||
if (parts[0].Intersects(parts[1], out var pts))
|
||||
{
|
||||
overlapping++;
|
||||
_output.WriteLine($" OVERLAP #{overlapping}: Test {result.Candidate.TestNumber} " +
|
||||
$"Part2Rot={OpenNest.Math.Angle.ToDegrees(result.Candidate.Part2Rotation):F1}° " +
|
||||
$"collision pts={pts.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(0, overlapping);
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ public class EngineOverlapTests
|
||||
if (!System.IO.File.Exists(DxfPath))
|
||||
return null;
|
||||
|
||||
var importer = new DxfImporter();
|
||||
importer.GetGeometry(DxfPath, out var geometry);
|
||||
var geometry = Dxf.GetGeometry(DxfPath);
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
return new Drawing("PT15", pgm);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests.Engine;
|
||||
|
||||
public class MultiPlateNesterTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public MultiPlateNesterTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
private static Drawing MakeDrawing(string name, double width, double length)
|
||||
{
|
||||
var program = new OpenNest.CNC.Program();
|
||||
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, 0)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, length)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, length)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing(name, program);
|
||||
drawing.UpdateArea();
|
||||
return drawing;
|
||||
}
|
||||
|
||||
private static NestItem MakeItem(string name, double width, double length, int qty = 1)
|
||||
{
|
||||
return new NestItem
|
||||
{
|
||||
Drawing = MakeDrawing(name, width, length),
|
||||
Quantity = qty,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortByBoundingBoxArea_OrdersLargestFirst()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("small", 10, 10),
|
||||
MakeItem("large", 40, 60),
|
||||
MakeItem("medium", 20, 30),
|
||||
};
|
||||
|
||||
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.BoundingBoxArea);
|
||||
|
||||
Assert.Equal("large", sorted[0].Drawing.Name);
|
||||
Assert.Equal("medium", sorted[1].Drawing.Name);
|
||||
Assert.Equal("small", sorted[2].Drawing.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortBySize_OrdersByLongestDimension()
|
||||
{
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("short-wide", 50, 20), // longest = 50
|
||||
MakeItem("tall-narrow", 10, 80), // longest = 80
|
||||
MakeItem("square", 30, 30), // longest = 30
|
||||
};
|
||||
|
||||
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.Size);
|
||||
|
||||
Assert.Equal("tall-narrow", sorted[0].Drawing.Name);
|
||||
Assert.Equal("short-wide", sorted[1].Drawing.Name);
|
||||
Assert.Equal("square", sorted[2].Drawing.Name);
|
||||
}
|
||||
|
||||
// --- Task 4: Part Classification ---
|
||||
|
||||
[Fact]
|
||||
public void Classify_LargePart_WhenWidthExceedsHalfWorkArea()
|
||||
{
|
||||
var workArea = new Box(0, 0, 96, 48);
|
||||
var bb = new Box(0, 0, 50, 20); // width 50 > half of 96 = 48
|
||||
var result = MultiPlateNester.Classify(bb, workArea);
|
||||
Assert.Equal(PartClass.Large, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_LargePart_WhenLengthExceedsHalfWorkArea()
|
||||
{
|
||||
var workArea = new Box(0, 0, 96, 48);
|
||||
var bb = new Box(0, 0, 20, 30); // length 30 > half of 48 = 24
|
||||
var result = MultiPlateNester.Classify(bb, workArea);
|
||||
Assert.Equal(PartClass.Large, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_MediumPart_NotLargeButAreaAboveThreshold()
|
||||
{
|
||||
var workArea = new Box(0, 0, 96, 48);
|
||||
// workArea = 4608, 1/9 = 512. bb = 40*15 = 600 > 512
|
||||
// 40 < 48 (half of 96), 15 < 24 (half of 48) — not Large
|
||||
var bb = new Box(0, 0, 40, 15);
|
||||
var result = MultiPlateNester.Classify(bb, workArea);
|
||||
Assert.Equal(PartClass.Medium, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_SmallPart()
|
||||
{
|
||||
var workArea = new Box(0, 0, 96, 48);
|
||||
// workArea = 4608, 1/9 = 512. bb = 10*10 = 100 < 512
|
||||
var bb = new Box(0, 0, 10, 10);
|
||||
var result = MultiPlateNester.Classify(bb, workArea);
|
||||
Assert.Equal(PartClass.Small, result);
|
||||
}
|
||||
|
||||
// --- Task 5: Scrap Zone Identification ---
|
||||
|
||||
[Fact]
|
||||
public void IsScrapRemnant_BothDimensionsBelowThreshold_ReturnsTrue()
|
||||
{
|
||||
var remnant = new Box(0, 0, 10, 8);
|
||||
Assert.True(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsScrapRemnant_OneDimensionAboveThreshold_ReturnsFalse()
|
||||
{
|
||||
// 11 x 120 — narrow but long, should be preserved
|
||||
var remnant = new Box(0, 0, 11, 120);
|
||||
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsScrapRemnant_BothDimensionsAboveThreshold_ReturnsFalse()
|
||||
{
|
||||
var remnant = new Box(0, 0, 20, 30);
|
||||
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindRemnants_ScrapOnly_ReturnsOnlyScrapRemnants()
|
||||
{
|
||||
// 96x48 plate with a 70x40 part placed at origin
|
||||
var plate = new Plate(96, 48) { PartSpacing = 0.25 };
|
||||
var drawing = MakeDrawing("big", 70, 40);
|
||||
var part = new Part(drawing);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var scrap = MultiPlateNester.FindRemnants(plate, 12.0, scrapOnly: true);
|
||||
|
||||
// All returned zones should have both dims < 12
|
||||
foreach (var zone in scrap)
|
||||
{
|
||||
Assert.True(zone.Width < 12.0 && zone.Length < 12.0,
|
||||
$"Zone {zone.Width:F1}x{zone.Length:F1} is not scrap — at least one dimension >= 12");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task 6: Plate Creation Helper ---
|
||||
|
||||
[Fact]
|
||||
public void CreatePlate_UsesTemplateWhenNoOptions()
|
||||
{
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 };
|
||||
|
||||
var plate = MultiPlateNester.CreatePlate(template, null, null);
|
||||
|
||||
Assert.Equal(96, plate.Size.Width);
|
||||
Assert.Equal(48, plate.Size.Length);
|
||||
Assert.Equal(0.25, plate.PartSpacing);
|
||||
Assert.Equal(1, plate.Quadrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatePlate_PicksSmallestFittingOption()
|
||||
{
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing { Left = 1, Right = 1, Top = 1, Bottom = 1 };
|
||||
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 48, Length = 96, Cost = 100 },
|
||||
new() { Width = 60, Length = 120, Cost = 200 },
|
||||
new() { Width = 72, Length = 144, Cost = 300 },
|
||||
};
|
||||
|
||||
// Part needs 50x50 work area — 48x96 (after edge spacing: 46x94) — 46 < 50, doesn't fit.
|
||||
// 60x120 (58x118) does fit.
|
||||
var minBounds = new Box(0, 0, 50, 50);
|
||||
|
||||
var plate = MultiPlateNester.CreatePlate(template, options, minBounds);
|
||||
|
||||
Assert.Equal(60, plate.Size.Width);
|
||||
Assert.Equal(120, plate.Size.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateUpgrade_PrefersCheaperOption()
|
||||
{
|
||||
var currentOption = new PlateOption { Width = 48, Length = 96, Cost = 100 };
|
||||
var upgradeOption = new PlateOption { Width = 60, Length = 120, Cost = 160 };
|
||||
var newPlateOption = new PlateOption { Width = 48, Length = 96, Cost = 100 };
|
||||
|
||||
// Upgrade cost = 160 - 100 = 60
|
||||
// New plate cost with 50% utilization, 50% salvage:
|
||||
// remnantFraction = 0.5, salvageCredit = 0.5 * 100 * 0.5 = 25
|
||||
// netNewCost = 100 - 25 = 75
|
||||
// Upgrade (60) < new plate (75), so upgrade wins
|
||||
var decision = MultiPlateNester.EvaluateUpgradeVsNew(
|
||||
currentOption, upgradeOption, newPlateOption, 0.5, 0.5);
|
||||
|
||||
Assert.True(decision.ShouldUpgrade);
|
||||
}
|
||||
|
||||
// --- Task 7: Main Orchestration ---
|
||||
|
||||
[Fact]
|
||||
public void Nest_LargePartsGetOwnPlates()
|
||||
{
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("big1", 80, 40, 1),
|
||||
MakeItem("big2", 70, 35, 1),
|
||||
};
|
||||
|
||||
var options = new MultiPlateNestOptions { Template = template };
|
||||
|
||||
var result = MultiPlateNester.Nest(items, options);
|
||||
|
||||
// Each large part should be on its own plate.
|
||||
Assert.True(result.Plates.Count >= 2,
|
||||
$"Expected at least 2 plates, got {result.Plates.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nest_SmallPartsConsolidateOntoSharedPlates()
|
||||
{
|
||||
// Small parts should be packed together on shared plates rather than
|
||||
// each drawing getting its own plate. The consolidation pass fills
|
||||
// small parts into remaining space on existing plates.
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("big", 80, 40, 1),
|
||||
MakeItem("tinyA", 5, 5, 3),
|
||||
MakeItem("tinyB", 4, 4, 3),
|
||||
};
|
||||
|
||||
var options = new MultiPlateNestOptions { Template = template };
|
||||
|
||||
var result = MultiPlateNester.Nest(items, options);
|
||||
|
||||
// Both small drawing types should share space — not each on their own plate.
|
||||
// With consolidation, they pack into remaining space alongside the big part.
|
||||
Assert.True(result.Plates.Count <= 2,
|
||||
$"Expected at most 2 plates (small parts consolidated), got {result.Plates.Count}");
|
||||
Assert.Equal(0, result.UnplacedItems.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nest_RespectsAllowPlateCreation()
|
||||
{
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing();
|
||||
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("big1", 80, 40, 1),
|
||||
MakeItem("big2", 70, 35, 1),
|
||||
};
|
||||
|
||||
var options = new MultiPlateNestOptions
|
||||
{
|
||||
Template = template,
|
||||
AllowPlateCreation = false,
|
||||
};
|
||||
|
||||
var result = MultiPlateNester.Nest(items, options);
|
||||
|
||||
// No existing plates and no plate creation — nothing can be placed.
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(2, result.UnplacedItems.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nest_UsesExistingPlates()
|
||||
{
|
||||
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
template.EdgeSpacing = new Spacing();
|
||||
|
||||
var existingPlate = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
|
||||
existingPlate.EdgeSpacing = new Spacing();
|
||||
|
||||
// Use a part small enough to be classified as Medium on a 96x48 plate.
|
||||
// Plate WorkArea: Width=96, Length=48. Half: 48, 24.
|
||||
// Part 24x22: Length=24 (not > 24), Width=22 (not > 48) — not Large.
|
||||
// Area = 528 > 4608/9 = 512 — Medium.
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
MakeItem("medium", 24, 22, 1),
|
||||
};
|
||||
|
||||
var options = new MultiPlateNestOptions { Template = template };
|
||||
|
||||
var result = MultiPlateNester.Nest(items, options,
|
||||
existingPlates: new List<Plate> { existingPlate });
|
||||
|
||||
// Part should be placed on the existing plate, not a new one.
|
||||
Assert.Single(result.Plates);
|
||||
Assert.False(result.Plates[0].IsNew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nest_RealNestFile_PartFirst()
|
||||
{
|
||||
var nestPath = @"C:\Users\aisaacs\Desktop\4526 A14 - 0.188 AISI 304.nest";
|
||||
if (!File.Exists(nestPath))
|
||||
{
|
||||
_output.WriteLine("SKIP: nest file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var nest = new NestReader(nestPath).Read();
|
||||
var template = nest.PlateDefaults.CreateNew();
|
||||
|
||||
_output.WriteLine($"Plate: {template.Size.Width}x{template.Size.Length}, " +
|
||||
$"spacing={template.PartSpacing}, edge=({template.EdgeSpacing.Left},{template.EdgeSpacing.Bottom},{template.EdgeSpacing.Right},{template.EdgeSpacing.Top})");
|
||||
|
||||
var wa = template.WorkArea();
|
||||
_output.WriteLine($"Work area: {wa.Width:F1}x{wa.Length:F1}");
|
||||
_output.WriteLine($"Classification thresholds: Large if dim > {wa.Width / 2:F1} or {wa.Length / 2:F1}, " +
|
||||
$"Medium if area > {wa.Width * wa.Length / 9:F0}");
|
||||
_output.WriteLine("---");
|
||||
|
||||
var items = new List<NestItem>();
|
||||
foreach (var d in nest.Drawings)
|
||||
{
|
||||
var qty = d.Quantity.Required > 0 ? d.Quantity.Required : d.Quantity.Remaining;
|
||||
if (qty <= 0) qty = 1;
|
||||
|
||||
var bb = d.Program.BoundingBox();
|
||||
var classification = MultiPlateNester.Classify(bb, wa);
|
||||
|
||||
_output.WriteLine($" {d.Name,-25} {bb.Width:F1}x{bb.Length:F1} (area={bb.Width * bb.Length:F0}) qty={qty} class={classification}");
|
||||
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = d,
|
||||
Quantity = qty,
|
||||
StepAngle = d.Constraints.StepAngle,
|
||||
RotationStart = d.Constraints.StartAngle,
|
||||
RotationEnd = d.Constraints.EndAngle,
|
||||
});
|
||||
}
|
||||
|
||||
_output.WriteLine("---");
|
||||
_output.WriteLine($"Total: {items.Count} drawings, {items.Sum(i => i.Quantity)} parts");
|
||||
|
||||
var plateOptions = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 48, Length = 96, Cost = 0 },
|
||||
new() { Width = 48, Length = 120, Cost = 0 },
|
||||
new() { Width = 48, Length = 144, Cost = 0 },
|
||||
new() { Width = 60, Length = 96, Cost = 0 },
|
||||
new() { Width = 60, Length = 120, Cost = 0 },
|
||||
new() { Width = 60, Length = 144, Cost = 0 },
|
||||
new() { Width = 72, Length = 96, Cost = 0 },
|
||||
new() { Width = 72, Length = 120, Cost = 0 },
|
||||
new() { Width = 72, Length = 144, Cost = 0 },
|
||||
};
|
||||
|
||||
_output.WriteLine($"Plate options: {string.Join(", ", plateOptions.Select(o => $"{o.Width}x{o.Length}"))}");
|
||||
_output.WriteLine("");
|
||||
|
||||
var options = new MultiPlateNestOptions
|
||||
{
|
||||
Template = template,
|
||||
PlateOptions = plateOptions,
|
||||
};
|
||||
|
||||
var result = MultiPlateNester.Nest(items, options);
|
||||
|
||||
_output.WriteLine($"=== RESULTS: {result.Plates.Count} plates ===");
|
||||
|
||||
for (var i = 0; i < result.Plates.Count; i++)
|
||||
{
|
||||
var pr = result.Plates[i];
|
||||
var groups = pr.Parts.GroupBy(p => p.BaseDrawing.Name)
|
||||
.Select(g => $"{g.Key} x{g.Count()}")
|
||||
.ToList();
|
||||
_output.WriteLine($" Plate {i + 1} ({pr.Plate.Size.Width}x{pr.Plate.Size.Length}): " +
|
||||
$"{pr.Parts.Count} parts, util={pr.Plate.Utilization():P1} [{string.Join(", ", groups)}]");
|
||||
}
|
||||
|
||||
if (result.UnplacedItems.Count > 0)
|
||||
{
|
||||
_output.WriteLine($" Unplaced: {string.Join(", ", result.UnplacedItems.Select(i => $"{i.Drawing.Name} x{i.Quantity}"))}");
|
||||
}
|
||||
|
||||
_output.WriteLine($"\nTotal parts placed: {result.Plates.Sum(p => p.Parts.Count)}");
|
||||
_output.WriteLine($"Total plates used: {result.Plates.Count}");
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class NestProgressTests
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
TestHelpers.MakePartAt(0, 10, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedWidth, precision: 4);
|
||||
@@ -61,7 +61,7 @@ public class NestProgressTests
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(0, 10, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedLength, precision: 4);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Engine;
|
||||
|
||||
public class PlateOptimizerTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PicksCheapestPlateThatFitsParts()
|
||||
{
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 20, Length = 20, Cost = 100 },
|
||||
new() { Width = 40, Length = 40, Cost = 400 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(40, 40) { PartSpacing = 0 };
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(20, result.ChosenSize.Width);
|
||||
Assert.True(result.Parts.Count >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefersMorePartsOverCheaperPlate()
|
||||
{
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 12, Length = 12, Cost = 50 },
|
||||
new() { Width = 24, Length = 12, Cost = 100 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(24, 12) { PartSpacing = 0 };
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(10, 10), Quantity = 2 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(24, result.ChosenSize.Width);
|
||||
Assert.Equal(2, result.Parts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SalvageRateReducesNetCost()
|
||||
{
|
||||
// Small: 20x20=400sqin, cost $400. Part=10x10=100sqin. Remnant=300.
|
||||
// Net = 400 - 300*(400/400)*1.0 = 400-300 = 100
|
||||
// Large: 40x40=1600sqin, cost $800. Part=10x10=100sqin. Remnant=1500.
|
||||
// Net = 800 - 1500*(800/1600)*1.0 = 800-750 = 50
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 20, Length = 20, Cost = 400 },
|
||||
new() { Width = 40, Length = 40, Cost = 800 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(40, 40) { PartSpacing = 0 };
|
||||
templatePlate.EdgeSpacing = new Spacing();
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 1.0, templatePlate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(40, result.ChosenSize.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsPlatesThatAreTooSmall()
|
||||
{
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 20, Length = 20, Cost = 100 },
|
||||
new() { Width = 40, Length = 40, Cost = 400 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(40, 40) { PartSpacing = 0 };
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(30, 30), Quantity = 1 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(40, result.ChosenSize.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsNullWhenNoPlatesFit()
|
||||
{
|
||||
var options = new List<PlateOption>
|
||||
{
|
||||
new() { Width = 10, Length = 10, Cost = 50 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(10, 10) { PartSpacing = 0 };
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(20, 20), Quantity = 1 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
@@ -6,61 +6,61 @@ public class BestCombinationTests
|
||||
public void BothFit_FindsZeroRemnant()
|
||||
{
|
||||
// 100 = 0*30 + 5*20 (algorithm iterates from countLength1=0, finds zero remnant first)
|
||||
var result = BestCombination.FindFrom2(30, 20, 100, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(30, 20, 100);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 30.0 + c2 * 20.0), 5);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0.0, 100.0 - (result.Count1 * 30.0 + result.Count2 * 20.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength1Fits_ReturnsMaxCount1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 200, 50, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(10, 200, 50);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1);
|
||||
Assert.Equal(0, c2);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(5, result.Count1);
|
||||
Assert.Equal(0, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength2Fits_ReturnsMaxCount2()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(200, 10, 50, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(200, 10, 50);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(5, c2);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0, result.Count1);
|
||||
Assert.Equal(5, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeitherFits_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(100, 200, 50, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(100, 200, 50);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
Assert.False(result.Found);
|
||||
Assert.Equal(0, result.Count1);
|
||||
Assert.Equal(0, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Length1FillsExactly_ZeroRemnant()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(25, 10, 100, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(25, 10, 100);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 25.0 + c2 * 10.0), 5);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0.0, 100.0 - (result.Count1 * 25.0 + result.Count2 * 10.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixMinimizesRemnant()
|
||||
{
|
||||
// 7 and 3 into 20: best is 2*7 + 2*3 = 20 (zero remnant)
|
||||
var result = BestCombination.FindFrom2(7, 3, 20, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(7, 3, 20);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, c1);
|
||||
Assert.Equal(2, c2);
|
||||
Assert.True(c1 * 7 + c2 * 3 <= 20);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(2, result.Count1);
|
||||
Assert.Equal(2, result.Count2);
|
||||
Assert.True(result.Count1 * 7 + result.Count2 * 3 <= 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,28 +68,28 @@ public class BestCombinationTests
|
||||
{
|
||||
// 6 and 5 into 17:
|
||||
// all length1: 2*6=12, remnant=5 -> actually 2*6+1*5=17 perfect
|
||||
var result = BestCombination.FindFrom2(6, 5, 17, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(6, 5, 17);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 17.0 - (c1 * 6.0 + c2 * 5.0), 5);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0.0, 17.0 - (result.Count1 * 6.0 + result.Count2 * 5.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualLengths_FillsWithLength1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 10, 50, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(10, 10, 50);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1 + c2);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(5, result.Count1 + result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallLengths_LargeOverall()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(3, 7, 100, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(3, 7, 100);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 3.0 + c2 * 7.0;
|
||||
Assert.True(result.Found);
|
||||
var used = result.Count1 * 3.0 + result.Count2 * 7.0;
|
||||
Assert.True(used <= 100);
|
||||
Assert.True(100 - used < 3); // remnant less than smallest piece
|
||||
}
|
||||
@@ -100,41 +100,41 @@ public class BestCombinationTests
|
||||
// length1=9, length2=5, overall=10:
|
||||
// length1 alone: 1*9=9 remnant=1
|
||||
// length2 alone: 2*5=10 remnant=0
|
||||
var result = BestCombination.FindFrom2(9, 5, 10, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(9, 5, 10);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(2, c2);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0, result.Count1);
|
||||
Assert.Equal(2, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FractionalLengths_WorkCorrectly()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(2.5, 3.5, 12, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(2.5, 3.5, 12);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 2.5 + c2 * 3.5;
|
||||
Assert.True(result.Found);
|
||||
var used = result.Count1 * 2.5 + result.Count2 * 3.5;
|
||||
Assert.True(used <= 12.0 + 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallExactlyOneOfEach()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(40, 60, 100, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(40, 60, 100);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, c1);
|
||||
Assert.Equal(1, c2);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(1, result.Count1);
|
||||
Assert.Equal(1, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallSmallerThanEither_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 20, 5, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(10, 20, 5);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
Assert.False(result.Found);
|
||||
Assert.Equal(0, result.Count1);
|
||||
Assert.Equal(0, result.Count2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -142,9 +142,9 @@ public class BestCombinationTests
|
||||
{
|
||||
// 4 and 6 into 24: 0*4+4*6=24 or 3*4+2*6=24 or 6*4+0*6=24
|
||||
// Algorithm iterates from 0 length1 upward, finds zero remnant and breaks
|
||||
var result = BestCombination.FindFrom2(4, 6, 24, out var c1, out var c2);
|
||||
var result = BestCombination.FindFrom2(4, 6, 24);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 24.0 - (c1 * 4.0 + c2 * 6.0), 5);
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal(0.0, 24.0 - (result.Count1 * 4.0 + result.Count2 * 6.0), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Tests.Fill
|
||||
{
|
||||
public class FillLinearCircleTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public FillLinearCircleTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
private static Drawing MakeCircleDrawing(double radius)
|
||||
{
|
||||
var pgm = new Program();
|
||||
var startPt = new Vector(radius * 2, radius); // rightmost point
|
||||
pgm.Codes.Add(new RapidMove(startPt));
|
||||
pgm.Codes.Add(new ArcMove(startPt, new Vector(radius, radius), RotationType.CCW));
|
||||
return new Drawing("circle", pgm);
|
||||
}
|
||||
|
||||
private static Drawing MakeRingDrawing(double outerRadius, double innerRadius)
|
||||
{
|
||||
var pgm = new Program();
|
||||
// Outer circle (CCW)
|
||||
var outerStart = new Vector(outerRadius * 2, outerRadius);
|
||||
pgm.Codes.Add(new RapidMove(outerStart));
|
||||
pgm.Codes.Add(new ArcMove(outerStart, new Vector(outerRadius, outerRadius), RotationType.CCW));
|
||||
// Inner circle (CW = hole)
|
||||
var innerStart = new Vector(outerRadius + innerRadius, outerRadius);
|
||||
pgm.Codes.Add(new RapidMove(innerStart));
|
||||
pgm.Codes.Add(new ArcMove(innerStart, new Vector(outerRadius, outerRadius), RotationType.CW));
|
||||
return new Drawing("ring", pgm);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2.0, 0.125)] // 4" diameter circle, 1/8" spacing
|
||||
[InlineData(1.0, 0.125)] // 2" diameter circle
|
||||
[InlineData(3.0, 0.0625)] // 6" diameter circle, 1/16" spacing
|
||||
[InlineData(0.5, 0.25)] // 1" diameter circle, 1/4" spacing
|
||||
public void CircleFill_OffsetBoundaries_DoNotOverlap(double radius, double spacing)
|
||||
{
|
||||
var drawing = MakeCircleDrawing(radius);
|
||||
var workArea = new Box(0, 0, 48, 48);
|
||||
var engine = new FillLinear(workArea, spacing);
|
||||
var parts = engine.Fill(drawing, 0, NestDirection.Horizontal);
|
||||
|
||||
_output.WriteLine($"Circle R={radius}, spacing={spacing}: {parts.Count} parts");
|
||||
|
||||
AssertNoOffsetOverlap(parts, spacing, radius * 2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2.0, 1.5, 0.125)] // Ring: outer R=2, inner R=1.5
|
||||
[InlineData(1.5, 1.0, 0.125)] // Ring: outer R=1.5, inner R=1.0
|
||||
public void RingFill_OffsetBoundaries_DoNotOverlap(double outerR, double innerR, double spacing)
|
||||
{
|
||||
var drawing = MakeRingDrawing(outerR, innerR);
|
||||
var workArea = new Box(0, 0, 48, 48);
|
||||
var engine = new FillLinear(workArea, spacing);
|
||||
var parts = engine.Fill(drawing, 0, NestDirection.Horizontal);
|
||||
|
||||
_output.WriteLine($"Ring outerR={outerR}, innerR={innerR}, spacing={spacing}: {parts.Count} parts");
|
||||
|
||||
AssertNoOffsetOverlap(parts, spacing, outerR * 2);
|
||||
}
|
||||
|
||||
private void AssertNoOffsetOverlap(List<Part> parts, double spacing, double expectedDiameter)
|
||||
{
|
||||
if (parts.Count < 2)
|
||||
{
|
||||
_output.WriteLine(" Only 1 part placed, skipping overlap check");
|
||||
return;
|
||||
}
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
var radius = expectedDiameter / 2;
|
||||
var minGap = double.MaxValue;
|
||||
var violationCount = 0;
|
||||
|
||||
// For circular parts, the center is at Location + (radius, radius).
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var ci = parts[i].Location + new Vector(radius, radius);
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var cj = parts[j].Location + new Vector(radius, radius);
|
||||
var centerDist = ci.DistanceTo(cj);
|
||||
|
||||
// Gap between raw circle perimeters
|
||||
var rawGap = centerDist - expectedDiameter;
|
||||
|
||||
// Gap between offset circle perimeters (halfSpacing each side)
|
||||
var offsetGap = centerDist - expectedDiameter - spacing;
|
||||
|
||||
if (rawGap < minGap)
|
||||
minGap = rawGap;
|
||||
|
||||
if (rawGap < spacing - Tolerance.Epsilon)
|
||||
{
|
||||
violationCount++;
|
||||
if (violationCount <= 5)
|
||||
{
|
||||
_output.WriteLine($" SPACING VIOLATION parts[{i}] vs parts[{j}]: " +
|
||||
$"centerDist={centerDist:F6}, rawGap={rawGap:F6}, offsetGap={offsetGap:F6}, " +
|
||||
$"expected>={spacing:F4}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($" Min gap={minGap:F6}, expected>={spacing:F4}, violations={violationCount}");
|
||||
|
||||
if (violationCount > 0)
|
||||
{
|
||||
var maxDeficit = spacing - minGap;
|
||||
_output.WriteLine($" Max deficit={maxDeficit:F6}");
|
||||
Assert.Fail($"{violationCount} pairs violate spacing: min gap={minGap:F6}, expected>={spacing}, deficit={maxDeficit:F6}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user